diff --git a/package.json b/package.json index 8f715db..28fab1b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/createRelativeHistory.js b/src/app/createRelativeHistory.js new file mode 100644 index 0000000..dec7e52 --- /dev/null +++ b/src/app/createRelativeHistory.js @@ -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; + (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, + }; +} diff --git a/src/app/createRelativeHistory.test.js b/src/app/createRelativeHistory.test.js new file mode 100644 index 0000000..20f9834 --- /dev/null +++ b/src/app/createRelativeHistory.test.js @@ -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 ( +
+

Welcome

+

+ currently viewing route:{" "} + {router.getCurrentLocation().pathname} +

+ logo + +
{this.props.children}
+
+ ); + } + } + class AboutPage extends React.Component<{|+router: Router|}> { + render() { + return

content coming soon

; + } + } + class App extends React.Component<{|+history: History|}> { + render() { + return ( + + + + + + ); + } + } + + function test(basename) { + it("should render to proper markup at index", () => { + const {memoryHistory, relativeHistory} = createHistory( + basename, + normalize(basename + "/") + ); + const e = render(); + 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(); + 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( + + ).html() + ); + }); + + function agreeWithServer(path) { + const server = render( + + ); + const client = render( + + ); + 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(); + 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/"); + }); + }); +});