Add `createRelativeHistory` history implementation (#666)
Summary: See #643 and the module docstring on `createRelativeHistory.js` for context and explanation. This patch adds `history@^3.0.0` as an explicit dependency—previously, we were depending on it only implicitly through `react-router` (which was fine then, but is not now). The dependency is chosen to match the version specified in `react-router`’s `package.json`. Test Plan: Extensive unit tests included, with full coverage; `yarn test` suffices. wchargin-branch: createRelativeHistory
This commit is contained in:
parent
00bc9a9461
commit
ad0e98ac2c
|
@ -11,6 +11,7 @@
|
|||
"commonmark": "^0.28.1",
|
||||
"express": "^4.16.3",
|
||||
"fs-extra": "3.0.1",
|
||||
"history": "^3.0.0",
|
||||
"isomorphic-fetch": "^2.2.1",
|
||||
"json-stable-stringify": "^1.0.1",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
|
|
|
@ -0,0 +1,229 @@
|
|||
// @flow
|
||||
|
||||
/*
|
||||
* This module mediates between three representations of paths:
|
||||
*
|
||||
* - In React-space, paths are absolute, rooted semantically with
|
||||
* respect to the application.
|
||||
* - In browser-space, paths are absolute, rooted with respect to the
|
||||
* actual host.
|
||||
* - In DOM-space, paths are relative.
|
||||
*
|
||||
* For instance, suppose that an application is being served from
|
||||
* http://example.com/gateway/. Suppose that we are on the "about us"
|
||||
* page, with route "/about-us/". Then the route for the "contact us"
|
||||
* page has the following three representations:
|
||||
*
|
||||
* - in React-space: "/contact-us/";
|
||||
* - in browser-space: "/gateway/contact-us/";
|
||||
* - in DOM-space: "../contact-us/".
|
||||
*
|
||||
* These different spaces interact as follows:
|
||||
*
|
||||
* - Actual interaction with the `window.history` API uses
|
||||
* browser-space. This is necessary/convenient because
|
||||
* `window.location` is always represented in browser-space.
|
||||
*
|
||||
* - Interactions with React Router are in React-space. In particular,
|
||||
* the result of `getCurrentLocation()` is in React-space, and the
|
||||
* argument to `createHref` is in React-space. This is
|
||||
* necessary/convenient because it is an assumption of React Router
|
||||
* (e.g., actual route data must be specified thus).
|
||||
*
|
||||
* - The result of `createHref` is in DOM-space. This is
|
||||
* necessary/convenient because an `a` element must have a relative
|
||||
* href, because the gateway is not known at the time that the
|
||||
* static site is generated.
|
||||
*
|
||||
* Use `createRelativeHistory` to get a history object that provides the
|
||||
* right interface to each client.
|
||||
*/
|
||||
|
||||
import type {History /* actually `any` */} from "history";
|
||||
|
||||
/**
|
||||
* Given a history implementation that operates in browser-space with
|
||||
* the provided basename, create a history implementation that operates
|
||||
* in React-space, except for `createHref`, which provides results in
|
||||
* DOM-space.
|
||||
*
|
||||
* In a server-side rendering context, the basename should be "/". On
|
||||
* the client, the basename depends on the particular gateway from which
|
||||
* the page is served, which is known only at runtime and must be
|
||||
* computed from `window.location.pathname`.
|
||||
*
|
||||
* For instance, if `window.location.pathname` is "/foo/bar/about-us/",
|
||||
* and we are rendering what is semantically the "/about-us/" route,
|
||||
* then `basename` should be "/foo/bar/".
|
||||
*
|
||||
* The basename must begin and end with a slash. (These may be the same
|
||||
* slash.)
|
||||
*
|
||||
* See module docstring for more details.
|
||||
*/
|
||||
export default function createRelativeHistory(
|
||||
delegate: History,
|
||||
basename: string
|
||||
): History {
|
||||
if (!delegate.getCurrentLocation) {
|
||||
// (The `Router` component of `react-router` uses the same check.)
|
||||
throw new Error(
|
||||
"delegate: expected history@3 implementation, got: " + String(delegate)
|
||||
);
|
||||
}
|
||||
if (typeof basename !== "string") {
|
||||
throw new Error("basename: expected string, got: " + basename);
|
||||
}
|
||||
if (!basename.startsWith("/")) {
|
||||
throw new Error("basename: must be absolute: " + basename);
|
||||
}
|
||||
if (!basename.endsWith("/")) {
|
||||
throw new Error("basename: must end in slash: " + basename);
|
||||
}
|
||||
verifyBasename(delegate.getCurrentLocation().pathname);
|
||||
|
||||
interface Lens {
|
||||
(pathname: string): string;
|
||||
<T: {+pathname: string}>(location: T): T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a function that transforms a pathname, return a function
|
||||
* that:
|
||||
* - transforms strings by interpreting them as pathnames;
|
||||
* - transforms location objects by transforming their pathnames;
|
||||
* - passes through `null` and `undefined` unchanged, with warning.
|
||||
*/
|
||||
function lens(transformPathname: (string) => string): Lens {
|
||||
return (value) => {
|
||||
// istanbul ignore if
|
||||
if (value == null) {
|
||||
console.warn("unexpected lens argument: " + String(value));
|
||||
// Pass through unchanged.
|
||||
return value;
|
||||
} else if (typeof value === "string") {
|
||||
return (transformPathname(value): any);
|
||||
} else {
|
||||
const pathname = transformPathname(value.pathname);
|
||||
return ({...value, pathname}: any);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Check that the provided browser-space path does indeed begin with
|
||||
* the expected basename. If it doesn't, this means that we somehow
|
||||
* navigated out of our "sandbox" (maybe someone manually called
|
||||
* `window.history.pushState`). All bets are off in that case.
|
||||
*/
|
||||
function verifyBasename(browserPath) {
|
||||
if (!browserPath.startsWith(basename)) {
|
||||
const p = JSON.stringify(browserPath);
|
||||
const b = JSON.stringify(basename);
|
||||
throw new Error(`basename violation: ${b} is not a prefix of ${p}`);
|
||||
}
|
||||
}
|
||||
|
||||
const reactToBrowser = lens((path) => basename + path.replace(/^\//, ""));
|
||||
const browserToReact = lens((path) => {
|
||||
verifyBasename(path);
|
||||
return "/" + path.slice(basename.length);
|
||||
});
|
||||
const browserToDom = lens((path) => {
|
||||
verifyBasename(path);
|
||||
const current = delegate.getCurrentLocation().pathname;
|
||||
verifyBasename(current);
|
||||
const relativeRoot = current
|
||||
.slice(basename.length)
|
||||
// Strip any file component in the current directory.
|
||||
.replace(/\/[^/]*$/, "/")
|
||||
// Traverse back up any intermediate directory.
|
||||
.replace(/[^/]+/g, "..");
|
||||
return relativeRoot + path.slice(basename.length);
|
||||
});
|
||||
|
||||
function getCurrentLocation() {
|
||||
return browserToReact(delegate.getCurrentLocation());
|
||||
}
|
||||
function listenBefore(listener) {
|
||||
// Result is a function `unlisten: () => void`; no need to
|
||||
// transform.
|
||||
return delegate.listenBefore((currentLocation) => {
|
||||
return listener(browserToReact(currentLocation));
|
||||
});
|
||||
}
|
||||
function listen(listener) {
|
||||
// Result is a function `unlisten: () => void`; no need to
|
||||
// transform.
|
||||
return delegate.listen((currentLocation) => {
|
||||
return listener(browserToReact(currentLocation));
|
||||
});
|
||||
}
|
||||
function transitionTo(location) {
|
||||
// Result is `undefined`; no need to transform.
|
||||
return delegate.transitionTo(reactToBrowser(location));
|
||||
}
|
||||
function push(location) {
|
||||
// Result is `undefined`; no need to transform.
|
||||
return delegate.push(reactToBrowser(location));
|
||||
}
|
||||
function replace(location) {
|
||||
// Result is `undefined`; no need to transform.
|
||||
return delegate.replace(reactToBrowser(location));
|
||||
}
|
||||
function go(n) {
|
||||
// Result is `undefined`; no need to transform.
|
||||
// `n` is an integer; no need to transform.
|
||||
return delegate.go(n);
|
||||
}
|
||||
function goBack() {
|
||||
// Result is `undefined`; no need to transform.
|
||||
return delegate.goBack();
|
||||
}
|
||||
function goForward() {
|
||||
// Result is `undefined`; no need to transform.
|
||||
return delegate.goForward();
|
||||
}
|
||||
function createKey() {
|
||||
// Result is not a path; no need to transform.
|
||||
return delegate.createKey();
|
||||
}
|
||||
function createPath(_unused_location) {
|
||||
// It is not clear whether this function is part of the public
|
||||
// API. If it is, it is not clear what kind of URL (which
|
||||
// representation space) it is supposed to return. This is because
|
||||
// the `history` module does not actually have any API docs. This
|
||||
// function is not called by React Router v3, so, given that we do
|
||||
// not know what the semantics should be, we refrain from
|
||||
// implementing it.
|
||||
//
|
||||
// If this ever throws, maybe we'll have a better idea of what to
|
||||
// do.
|
||||
throw new Error("createPath is not part of the public API");
|
||||
}
|
||||
function createHref(location) {
|
||||
return browserToDom(delegate.createHref(reactToBrowser(location)));
|
||||
}
|
||||
function createLocation(location, action) {
|
||||
// `action` is an enum constant ("POP", "PUSH", or "REPLACE"); no
|
||||
// need to transform it.
|
||||
return browserToReact(
|
||||
delegate.createLocation(reactToBrowser(location), action)
|
||||
);
|
||||
}
|
||||
return {
|
||||
getCurrentLocation,
|
||||
listenBefore,
|
||||
listen,
|
||||
transitionTo,
|
||||
push,
|
||||
replace,
|
||||
go,
|
||||
goBack,
|
||||
goForward,
|
||||
createKey,
|
||||
createPath,
|
||||
createHref,
|
||||
createLocation,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,698 @@
|
|||
// @flow
|
||||
|
||||
import React, {type Node as ReactNode} from "react";
|
||||
import {Router, Route, Link} from "react-router";
|
||||
import {mount, render} from "enzyme";
|
||||
|
||||
import normalize from "../util/pathNormalize";
|
||||
import type {History /* actually `any` */} from "history";
|
||||
import createMemoryHistory from "history/lib/createMemoryHistory";
|
||||
import createRelativeHistory from "./createRelativeHistory";
|
||||
|
||||
require("./testUtil").configureEnzyme();
|
||||
|
||||
describe("app/createRelativeHistory", () => {
|
||||
beforeEach(() => {
|
||||
// $ExpectFlowError
|
||||
console.error = jest.fn();
|
||||
// $ExpectFlowError
|
||||
console.warn = jest.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
expect(console.warn).not.toHaveBeenCalled();
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function createHistory(basename, path) {
|
||||
const memoryHistory = createMemoryHistory(path);
|
||||
const relativeHistory = createRelativeHistory(memoryHistory, basename);
|
||||
return {memoryHistory, relativeHistory};
|
||||
}
|
||||
|
||||
describe("by direct interaction", () => {
|
||||
describe("construction", () => {
|
||||
it("should require a valid `history` implementation", () => {
|
||||
const historyV4Object = {
|
||||
length: 1,
|
||||
action: "POP",
|
||||
location: {
|
||||
pathname: "/foo/",
|
||||
search: "",
|
||||
hash: "",
|
||||
key: "123456",
|
||||
state: undefined,
|
||||
},
|
||||
createHref: () => "wat",
|
||||
push: () => undefined,
|
||||
replace: () => undefined,
|
||||
go: () => undefined,
|
||||
goBack: () => undefined,
|
||||
goForward: () => undefined,
|
||||
canGo: () => true,
|
||||
block: () => undefined,
|
||||
listen: () => undefined,
|
||||
};
|
||||
expect(() => createRelativeHistory(historyV4Object, "/")).toThrow(
|
||||
"delegate: expected history@3 implementation, got: [object Object]"
|
||||
);
|
||||
});
|
||||
it("should require a basename", () => {
|
||||
// $ExpectFlowError
|
||||
expect(() => createHistory(undefined, "undefined/")).toThrow(
|
||||
"basename: expected string, got: undefined"
|
||||
);
|
||||
});
|
||||
it("should reject a basename that does not start with a slash", () => {
|
||||
expect(() =>
|
||||
createHistory("not-a-slash/", "not-a-slash/thing")
|
||||
).toThrow("basename: must be absolute: not-a-slash/");
|
||||
});
|
||||
it("should reject a basename that does not end with a slash", () => {
|
||||
expect(() => createHistory("/not-a-dir", "/not-a-dir/thing")).toThrow(
|
||||
"basename: must end in slash: /not-a-dir"
|
||||
);
|
||||
});
|
||||
it("should reject a basename that is not a prefix of the location", () => {
|
||||
expect(() => createHistory("/foo/bar/", "/not/foo/bar/")).toThrow(
|
||||
'basename violation: "/foo/bar/" is not a prefix of "/not/foo/bar/"'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// We perform some minimal testing with a root basename. Most of the
|
||||
// interesting cases can only be usefully covered with a non-root
|
||||
// basename, and are unlikely to break only for a root basename, so
|
||||
// there's no need to duplicate the tests.
|
||||
describe('with a root basename ("/")', () => {
|
||||
it("should return React-space from `getCurrentLocation`", () => {
|
||||
const {memoryHistory, relativeHistory} = createHistory(
|
||||
"/",
|
||||
"/foo/bar/"
|
||||
);
|
||||
expect(relativeHistory.getCurrentLocation().pathname).toEqual(
|
||||
"/foo/bar/"
|
||||
);
|
||||
memoryHistory.push("/baz/quux/");
|
||||
expect(relativeHistory.getCurrentLocation().pathname).toEqual(
|
||||
"/baz/quux/"
|
||||
);
|
||||
});
|
||||
it("should return DOM-space from `createHref` at root", () => {
|
||||
expect(
|
||||
createHistory("/", "/").relativeHistory.createHref("/favicon.png")
|
||||
).toEqual("favicon.png");
|
||||
});
|
||||
it("should return DOM-space from `createHref` at non-root", () => {
|
||||
expect(
|
||||
createHistory("/", "/foo/bar/").relativeHistory.createHref(
|
||||
"/favicon.png"
|
||||
)
|
||||
).toEqual("../../favicon.png");
|
||||
});
|
||||
it("should accept a location string for `push`", () => {
|
||||
const {memoryHistory, relativeHistory} = createHistory(
|
||||
"/",
|
||||
"/foo/bar/"
|
||||
);
|
||||
relativeHistory.push("/baz/quux/#browns");
|
||||
expect(memoryHistory.getCurrentLocation()).toEqual(
|
||||
expect.objectContaining({
|
||||
pathname: "/baz/quux/",
|
||||
search: "",
|
||||
hash: "#browns",
|
||||
state: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
it("should accept a location object for `push`", () => {
|
||||
const {memoryHistory, relativeHistory} = createHistory(
|
||||
"/",
|
||||
"/foo/bar/"
|
||||
);
|
||||
relativeHistory.push({pathname: "/baz/quux/", hash: "#browns"});
|
||||
expect(memoryHistory.getCurrentLocation()).toEqual(
|
||||
expect.objectContaining({
|
||||
pathname: "/baz/quux/",
|
||||
search: "",
|
||||
hash: "#browns",
|
||||
state: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a non-root basename ("/my/gateway/")', () => {
|
||||
const createStandardHistory = () =>
|
||||
createHistory("/my/gateway/", "/my/gateway/foo/bar/");
|
||||
|
||||
describe("getCurrentLocation", () => {
|
||||
it("should return the initial location, in React-space", () => {
|
||||
const {relativeHistory} = createStandardHistory();
|
||||
expect(relativeHistory.getCurrentLocation().pathname).toEqual(
|
||||
"/foo/bar/"
|
||||
);
|
||||
});
|
||||
it("should accommodate changes in the delegate location", () => {
|
||||
const {memoryHistory, relativeHistory} = createStandardHistory();
|
||||
memoryHistory.push("/my/gateway/baz/quux/");
|
||||
expect(relativeHistory.getCurrentLocation().pathname).toEqual(
|
||||
"/baz/quux/"
|
||||
);
|
||||
});
|
||||
it("should throw if the delegate moves out of basename scope", () => {
|
||||
const {memoryHistory, relativeHistory} = createStandardHistory();
|
||||
expect(relativeHistory.getCurrentLocation().pathname).toEqual(
|
||||
"/foo/bar/"
|
||||
);
|
||||
memoryHistory.push("/not/my/gateway/baz/quux/");
|
||||
expect(() => relativeHistory.getCurrentLocation()).toThrow(
|
||||
'basename violation: "/my/gateway/" is not ' +
|
||||
'a prefix of "/not/my/gateway/baz/quux/"'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listenBefore", () => {
|
||||
function testListener(target: "RELATIVE" | "MEMORY") {
|
||||
const {memoryHistory, relativeHistory} = createStandardHistory();
|
||||
const listener = jest.fn();
|
||||
relativeHistory.listenBefore(listener);
|
||||
expect(listener).toHaveBeenCalledTimes(0);
|
||||
listener.mockImplementationOnce((newLocation) => {
|
||||
// We should _not_ already have transitioned. (Strictly,
|
||||
// this doesn't mean that the pathnames must not be
|
||||
// equal---an event could be fired if, say, only the hash
|
||||
// changes---but it suffices for our test cases.)
|
||||
expect(relativeHistory.getCurrentLocation().pathname).not.toEqual(
|
||||
newLocation.pathname
|
||||
);
|
||||
expect(newLocation.pathname).toEqual("/baz/quux/");
|
||||
expect(newLocation.hash).toEqual("#browns");
|
||||
expect(newLocation.search).toEqual("");
|
||||
});
|
||||
if (target === "RELATIVE") {
|
||||
relativeHistory.push("/baz/quux/#browns");
|
||||
} else if (target === "MEMORY") {
|
||||
memoryHistory.push("/my/gateway/baz/quux/#browns");
|
||||
} else {
|
||||
throw new Error((target: empty));
|
||||
}
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
|
||||
it("should handle events fired on the relative history", () => {
|
||||
testListener("RELATIVE");
|
||||
});
|
||||
|
||||
it("should handle events fired on the delegate history", () => {
|
||||
testListener("MEMORY");
|
||||
});
|
||||
|
||||
it("should unlisten when asked", () => {
|
||||
const {memoryHistory, relativeHistory} = createStandardHistory();
|
||||
const listener = jest.fn();
|
||||
const unlisten = relativeHistory.listenBefore(listener);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(0);
|
||||
memoryHistory.push("/my/gateway/baz/quux/#browns");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
unlisten();
|
||||
memoryHistory.push("/my/gateway/some/thing/else/");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listen", () => {
|
||||
function testListener(target: "RELATIVE" | "MEMORY") {
|
||||
const {memoryHistory, relativeHistory} = createStandardHistory();
|
||||
const listener = jest.fn();
|
||||
relativeHistory.listen(listener);
|
||||
expect(listener).toHaveBeenCalledTimes(0);
|
||||
listener.mockImplementationOnce((newLocation) => {
|
||||
// We should already have transitioned.
|
||||
expect(relativeHistory.getCurrentLocation().pathname).toEqual(
|
||||
newLocation.pathname
|
||||
);
|
||||
expect(newLocation.pathname).toEqual("/baz/quux/");
|
||||
expect(newLocation.hash).toEqual("#browns");
|
||||
expect(newLocation.search).toEqual("");
|
||||
});
|
||||
if (target === "RELATIVE") {
|
||||
relativeHistory.push("/baz/quux/#browns");
|
||||
} else if (target === "MEMORY") {
|
||||
memoryHistory.push("/my/gateway/baz/quux/#browns");
|
||||
} else {
|
||||
throw new Error((target: empty));
|
||||
}
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
|
||||
it("should handle events fired on the relative history", () => {
|
||||
testListener("RELATIVE");
|
||||
});
|
||||
|
||||
it("should handle events fired on the delegate history", () => {
|
||||
testListener("MEMORY");
|
||||
});
|
||||
|
||||
it("should unlisten when asked", () => {
|
||||
const {memoryHistory, relativeHistory} = createStandardHistory();
|
||||
const listener = jest.fn();
|
||||
const unlisten = relativeHistory.listen(listener);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(0);
|
||||
memoryHistory.push("/my/gateway/baz/quux/#browns");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
unlisten();
|
||||
memoryHistory.push("/my/gateway/some/thing/else/");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// I have no idea what `transitionTo` is supposed to do. One would
|
||||
// think that it effects a transition, but one would be wrong:
|
||||
//
|
||||
// > var mh = require("history/lib/createMemoryHistory").default();
|
||||
// > mh.transitionTo("/foo/");
|
||||
// > mh.getCurrentLocation().pathname;
|
||||
// '/'
|
||||
//
|
||||
// The best that I can think of to do is to verify that the
|
||||
// appropriate argument is passed along.
|
||||
describe("transitionTo", () => {
|
||||
it("forwards a browser-space string", () => {
|
||||
const {memoryHistory, relativeHistory} = createStandardHistory();
|
||||
const spy = jest.spyOn(memoryHistory, "transitionTo");
|
||||
relativeHistory.transitionTo("/baz/quux/");
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy).toHaveBeenCalledWith("/my/gateway/baz/quux/");
|
||||
});
|
||||
it("forwards a browser-space location object", () => {
|
||||
const {memoryHistory, relativeHistory} = createStandardHistory();
|
||||
const spy = jest.spyOn(memoryHistory, "transitionTo");
|
||||
relativeHistory.transitionTo({
|
||||
pathname: "/baz/quux/",
|
||||
hash: "#browns",
|
||||
state: "california",
|
||||
});
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
pathname: "/my/gateway/baz/quux/",
|
||||
hash: "#browns",
|
||||
state: "california",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// For some reason, the `memoryHistory` delegate seems to treat
|
||||
// `push`, and `replace` identically: in particular, the action
|
||||
// assigned tot he resulting location is, in all cases, "POP".
|
||||
// I don't know what the difference is supposed to be.
|
||||
function testTransitionFunction(method: "push" | "replace") {
|
||||
it("should accept a location string", () => {
|
||||
const {memoryHistory, relativeHistory} = createStandardHistory();
|
||||
relativeHistory[method].call(relativeHistory, "/baz/quux/#browns");
|
||||
expect(memoryHistory.getCurrentLocation()).toEqual(
|
||||
expect.objectContaining({
|
||||
pathname: "/my/gateway/baz/quux/",
|
||||
search: "",
|
||||
hash: "#browns",
|
||||
state: undefined,
|
||||
action: "POP",
|
||||
})
|
||||
);
|
||||
expect(relativeHistory.getCurrentLocation()).toEqual(
|
||||
expect.objectContaining({
|
||||
pathname: "/baz/quux/",
|
||||
search: "",
|
||||
hash: "#browns",
|
||||
state: undefined,
|
||||
action: "POP",
|
||||
})
|
||||
);
|
||||
});
|
||||
it("should accept a location object", () => {
|
||||
const {memoryHistory, relativeHistory} = createStandardHistory();
|
||||
relativeHistory[method].call(relativeHistory, {
|
||||
pathname: "/baz/quux/",
|
||||
hash: "#browns",
|
||||
state: "california",
|
||||
});
|
||||
expect(memoryHistory.getCurrentLocation()).toEqual(
|
||||
expect.objectContaining({
|
||||
pathname: "/my/gateway/baz/quux/",
|
||||
search: "",
|
||||
hash: "#browns",
|
||||
state: "california",
|
||||
action: "POP",
|
||||
})
|
||||
);
|
||||
expect(relativeHistory.getCurrentLocation()).toEqual(
|
||||
expect.objectContaining({
|
||||
pathname: "/baz/quux/",
|
||||
search: "",
|
||||
hash: "#browns",
|
||||
state: "california",
|
||||
action: "POP",
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
describe("push", () => {
|
||||
testTransitionFunction("push");
|
||||
});
|
||||
describe("replace", () => {
|
||||
testTransitionFunction("replace");
|
||||
});
|
||||
|
||||
describe("go, goForward, and goBack", () => {
|
||||
const createFivePageHistory = () => {
|
||||
const {memoryHistory, relativeHistory} = createStandardHistory();
|
||||
relativeHistory.push("/1/");
|
||||
relativeHistory.push("/2/");
|
||||
relativeHistory.push("/3/");
|
||||
relativeHistory.push("/4/");
|
||||
relativeHistory.push("/5/");
|
||||
return {
|
||||
memoryHistory,
|
||||
relativeHistory,
|
||||
expectPageNumber: (n) =>
|
||||
expectPageNumber(relativeHistory, memoryHistory, n),
|
||||
};
|
||||
};
|
||||
function expectPageNumber(relativeHistory, memoryHistory, n) {
|
||||
expect(relativeHistory.getCurrentLocation().pathname).toEqual(
|
||||
`/${n}/`
|
||||
);
|
||||
expect(memoryHistory.getCurrentLocation().pathname).toEqual(
|
||||
`/my/gateway/${n}/`
|
||||
);
|
||||
}
|
||||
|
||||
it("navigates back three, then forward two", () => {
|
||||
const {relativeHistory, expectPageNumber} = createFivePageHistory();
|
||||
expectPageNumber(5);
|
||||
relativeHistory.go(-3);
|
||||
expectPageNumber(2);
|
||||
relativeHistory.go(2);
|
||||
expectPageNumber(4);
|
||||
});
|
||||
|
||||
it("goes back", () => {
|
||||
const {relativeHistory, expectPageNumber} = createFivePageHistory();
|
||||
expectPageNumber(5);
|
||||
relativeHistory.goBack();
|
||||
expectPageNumber(4);
|
||||
relativeHistory.goBack();
|
||||
expectPageNumber(3);
|
||||
});
|
||||
|
||||
it("goes forward", () => {
|
||||
const {relativeHistory, expectPageNumber} = createFivePageHistory();
|
||||
expectPageNumber(5);
|
||||
relativeHistory.go(-2);
|
||||
relativeHistory.goBack();
|
||||
expectPageNumber(2);
|
||||
relativeHistory.goForward();
|
||||
expectPageNumber(3);
|
||||
relativeHistory.goForward();
|
||||
expectPageNumber(4);
|
||||
});
|
||||
|
||||
it("warns on overflow", () => {
|
||||
const {relativeHistory} = createFivePageHistory();
|
||||
relativeHistory.goBack();
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
relativeHistory.go(2);
|
||||
expect(console.error).toHaveBeenCalledTimes(1);
|
||||
expect(console.error.mock.calls[0][0]).toMatch(
|
||||
/Warning:.*there is not enough history/
|
||||
);
|
||||
// $ExpectFlowError
|
||||
console.error = jest.fn();
|
||||
});
|
||||
|
||||
it("warns on underflow", () => {
|
||||
const {relativeHistory} = createFivePageHistory();
|
||||
relativeHistory.go(-4);
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
relativeHistory.go(-2);
|
||||
expect(console.error).toHaveBeenCalledTimes(1);
|
||||
expect(console.error.mock.calls[0][0]).toMatch(
|
||||
/Warning:.*there is not enough history/
|
||||
);
|
||||
// $ExpectFlowError
|
||||
console.error = jest.fn();
|
||||
});
|
||||
|
||||
it("accounts for interleaved changes in the delegate state", () => {
|
||||
const {memoryHistory, relativeHistory} = createStandardHistory();
|
||||
relativeHistory.push("/1/");
|
||||
memoryHistory.push("/my/gateway/2/");
|
||||
relativeHistory.push("/3/");
|
||||
memoryHistory.push("/my/gateway/4/");
|
||||
relativeHistory.push("/5/");
|
||||
|
||||
expectPageNumber(relativeHistory, memoryHistory, 5);
|
||||
relativeHistory.go(-3);
|
||||
expectPageNumber(relativeHistory, memoryHistory, 2);
|
||||
relativeHistory.go(2);
|
||||
expectPageNumber(relativeHistory, memoryHistory, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createKey", () => {
|
||||
it("returns a string", () => {
|
||||
const {relativeHistory} = createStandardHistory();
|
||||
const key = relativeHistory.createKey(); // nondeterministic
|
||||
expect(key).toEqual(expect.stringContaining(""));
|
||||
});
|
||||
it("delegates", () => {
|
||||
const {memoryHistory, relativeHistory} = createStandardHistory();
|
||||
const secret = "ouagadougou";
|
||||
memoryHistory.createKey = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(() => secret);
|
||||
expect(relativeHistory.createKey()).toEqual(secret);
|
||||
expect(memoryHistory.createKey).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPath", () => {
|
||||
// We have no idea what this function is supposed to do. It
|
||||
// shouldn't be called. If it is, fail.
|
||||
it("throws unconditionally", () => {
|
||||
const {relativeHistory} = createStandardHistory();
|
||||
expect(() => relativeHistory.createPath("/wat/")).toThrow(
|
||||
"createPath is not part of the public API"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHref", () => {
|
||||
it("should return DOM-space at root", () => {
|
||||
expect(
|
||||
createHistory(
|
||||
"/my/gateway/",
|
||||
"/my/gateway/"
|
||||
).relativeHistory.createHref("/favicon.png")
|
||||
).toEqual("favicon.png");
|
||||
});
|
||||
it("should return DOM-space at non-root", () => {
|
||||
expect(
|
||||
createStandardHistory().relativeHistory.createHref("/favicon.png")
|
||||
).toEqual("../../favicon.png");
|
||||
});
|
||||
it("should traverse up and back down the tree", () => {
|
||||
expect(
|
||||
createStandardHistory().relativeHistory.createHref(
|
||||
"/baz/quux/data.csv"
|
||||
)
|
||||
).toEqual("../../baz/quux/data.csv");
|
||||
});
|
||||
it("should resolve the root", () => {
|
||||
expect(
|
||||
createStandardHistory().relativeHistory.createHref("/")
|
||||
).toEqual("../../");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createLocation", () => {
|
||||
it("should return React-space at root", () => {
|
||||
expect(
|
||||
createHistory(
|
||||
"/my/gateway/",
|
||||
"/my/gateway/"
|
||||
).relativeHistory.createLocation("/baz/quux/")
|
||||
).toEqual(expect.objectContaining({pathname: "/baz/quux/"}));
|
||||
});
|
||||
it("should return React-space at non-root", () => {
|
||||
expect(
|
||||
createStandardHistory().relativeHistory.createLocation("/baz/quux/")
|
||||
).toEqual(expect.objectContaining({pathname: "/baz/quux/"}));
|
||||
});
|
||||
it("should include the given action", () => {
|
||||
expect(
|
||||
createStandardHistory().relativeHistory.createLocation(
|
||||
"/baz/quux/",
|
||||
"REPLACE"
|
||||
)
|
||||
).toEqual(expect.objectContaining({action: "REPLACE"}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("with another instance of itself as the delegate", () => {
|
||||
it("seems to work", () => {
|
||||
// Why? Because it's classy, mostly.
|
||||
const h0 = createMemoryHistory("/a1/a2/b1/b2/c/");
|
||||
const h1 = createRelativeHistory(h0, "/a1/a2/");
|
||||
const h2 = createRelativeHistory(h1, "/b1/b2/");
|
||||
expect(h2.getCurrentLocation().pathname).toEqual("/c/");
|
||||
h2.push("/c1/c2/");
|
||||
expect(h0.getCurrentLocation().pathname).toEqual("/a1/a2/b1/b2/c1/c2/");
|
||||
expect(h1.getCurrentLocation().pathname).toEqual("/b1/b2/c1/c2/");
|
||||
expect(h2.getCurrentLocation().pathname).toEqual("/c1/c2/");
|
||||
h2.goBack();
|
||||
expect(h0.getCurrentLocation().pathname).toEqual("/a1/a2/b1/b2/c/");
|
||||
expect(h1.getCurrentLocation().pathname).toEqual("/b1/b2/c/");
|
||||
expect(h2.getCurrentLocation().pathname).toEqual("/c/");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("in a React app", () => {
|
||||
class MainPage extends React.Component<{|
|
||||
+router: Router,
|
||||
+children: ReactNode,
|
||||
|}> {
|
||||
render() {
|
||||
const {router} = this.props;
|
||||
return (
|
||||
<div>
|
||||
<h1>Welcome</h1>
|
||||
<p>
|
||||
<i>currently viewing route:</i>{" "}
|
||||
<tt>{router.getCurrentLocation().pathname}</tt>
|
||||
</p>
|
||||
<img alt="logo" src={router.createHref("/logo.png")} />
|
||||
<nav>
|
||||
<Link to="/about/">About us</Link>
|
||||
</nav>
|
||||
<main>{this.props.children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
class AboutPage extends React.Component<{|+router: Router|}> {
|
||||
render() {
|
||||
return <p>content coming soon</p>;
|
||||
}
|
||||
}
|
||||
class App extends React.Component<{|+history: History|}> {
|
||||
render() {
|
||||
return (
|
||||
<Router history={this.props.history}>
|
||||
<Route path="/" component={MainPage}>
|
||||
<Route path="/about/" component={AboutPage} />
|
||||
</Route>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function test(basename) {
|
||||
it("should render to proper markup at index", () => {
|
||||
const {memoryHistory, relativeHistory} = createHistory(
|
||||
basename,
|
||||
normalize(basename + "/")
|
||||
);
|
||||
const e = render(<App history={relativeHistory} />);
|
||||
expect(e.find("tt").text()).toEqual("/");
|
||||
expect(e.find("img").attr("src")).toEqual("logo.png");
|
||||
expect(e.find("a").attr("href")).toEqual("about/");
|
||||
expect(e.find("main").children()).toHaveLength(0);
|
||||
expect(e.find("main").text()).toEqual("");
|
||||
expect(memoryHistory.getCurrentLocation().pathname).toEqual(
|
||||
normalize(basename + "/")
|
||||
);
|
||||
});
|
||||
|
||||
it("should render to proper markup at subroute", () => {
|
||||
const {memoryHistory, relativeHistory} = createHistory(
|
||||
basename,
|
||||
normalize(basename + "/about/")
|
||||
);
|
||||
const e = render(<App history={relativeHistory} />);
|
||||
expect(e.find("tt").text()).toEqual("/about/");
|
||||
expect(e.find("img").attr("src")).toEqual("../logo.png");
|
||||
expect(e.find("a").attr("href")).toEqual("../about/");
|
||||
expect(e.find("main").children()).toHaveLength(1);
|
||||
expect(e.find("main").text()).toEqual("content coming soon");
|
||||
expect(memoryHistory.getCurrentLocation().pathname).toEqual(
|
||||
normalize(basename + "/about/")
|
||||
);
|
||||
expect(e.html()).toEqual(
|
||||
render(
|
||||
<App history={createHistory("/", "/about/").relativeHistory} />
|
||||
).html()
|
||||
);
|
||||
});
|
||||
|
||||
function agreeWithServer(path) {
|
||||
const server = render(
|
||||
<App history={createHistory("/", path).relativeHistory} />
|
||||
);
|
||||
const client = render(
|
||||
<App
|
||||
history={
|
||||
createHistory(basename, normalize(basename + path))
|
||||
.relativeHistory
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(server.html()).toEqual(client.html());
|
||||
}
|
||||
it("should agree between client and server at index", () => {
|
||||
agreeWithServer("/");
|
||||
});
|
||||
it("should agree between client and server at subroute", () => {
|
||||
agreeWithServer("/about/");
|
||||
});
|
||||
|
||||
function click(link) {
|
||||
// React Router only transitions if the event appears to be from
|
||||
// a left-click (button index 0) event on a mouse.
|
||||
const event = {button: 0};
|
||||
link.simulate("click", event);
|
||||
}
|
||||
|
||||
it("should properly transition when clicking a link", () => {
|
||||
const {memoryHistory, relativeHistory} = createHistory(
|
||||
basename,
|
||||
normalize(basename + "/")
|
||||
);
|
||||
const e = mount(<App history={relativeHistory} />);
|
||||
expect(e.find("tt").text()).toEqual("/");
|
||||
expect(e.find("Link")).toHaveLength(1);
|
||||
click(e.find("a"));
|
||||
expect(relativeHistory.getCurrentLocation().pathname).toEqual(
|
||||
"/about/"
|
||||
);
|
||||
expect(memoryHistory.getCurrentLocation().pathname).toEqual(
|
||||
normalize(basename + "/about/")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("when hosted at root", () => {
|
||||
test("/");
|
||||
});
|
||||
|
||||
describe("when hosted at a non-root gateway", () => {
|
||||
test("/some/arbitrary/gateway/");
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue