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",
|
"commonmark": "^0.28.1",
|
||||||
"express": "^4.16.3",
|
"express": "^4.16.3",
|
||||||
"fs-extra": "3.0.1",
|
"fs-extra": "3.0.1",
|
||||||
|
"history": "^3.0.0",
|
||||||
"isomorphic-fetch": "^2.2.1",
|
"isomorphic-fetch": "^2.2.1",
|
||||||
"json-stable-stringify": "^1.0.1",
|
"json-stable-stringify": "^1.0.1",
|
||||||
"lodash.clonedeep": "^4.5.0",
|
"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