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:
William Chargin 2018-08-15 12:01:27 -07:00 committed by GitHub
parent 00bc9a9461
commit ad0e98ac2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 928 additions and 0 deletions

View File

@ -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",

View File

@ -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,
};
}

View File

@ -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/");
});
});
});