Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Constant Domain History #962

Open
wants to merge 6 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 69 additions & 5 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ The history library provides an API for tracking application history using [loca

### Environments

The history library includes support for three different "environments", or modes of operation.
The history library includes support for four different "environments", or modes of operation.

- [Browser history](#createbrowserhistory) is used in web apps
- [Constant Domain history](#createconstantdomainhistory) is similar to browser history, but it will block back and forward navigation to a different domain.
- [Hash history](#createhashhistory) is used in web apps where you don't want to/can't send the URL to the server for some reason
- [Memory history](#creatememoryhistory) - is used in native apps and testing

Expand Down Expand Up @@ -79,7 +80,7 @@ See [the Getting Started guide](getting-started.md) for more information.

### `History`

A `History` object represents the shared interface for `BrowserHistory`, `HashHistory`, and `MemoryHistory`.
A `History` object represents the shared interface for `BrowserHistory`, `ConstantDomainHistory`, `HashHistory`, and `MemoryHistory`.

<details>
<summary>Type declaration</summary>
Expand All @@ -91,9 +92,9 @@ interface History {
createHref(to: To): string;
push(to: To, state?: any): void;
replace(to: To, state?: any): void;
go(delta: number): void;
back(): void;
forward(): void;
go(delta: number): boolean;
back(): boolean;
forward(): boolean;
listen(listener: Listener): () => void;
block(blocker: Blocker): () => void;
}
Expand Down Expand Up @@ -125,6 +126,66 @@ let history = createBrowserHistory();

See [the Getting Started guide](getting-started.md) for more information.

<a name="createconstantdomainhistory"></a>
<a name="constantdomainhistory"></a>

### `createConstantDomainHistory`

<details>
<summary>Type declaration</summary>

```tsx
function createConstantDomainHistory(options?: {
window?: Window;
}): ConstantDomainHistory;

interface ConstantDomainHistory extends History {}
```

</details>

A constant domain history is similar to browser history, but it will block back and forward navigation to a different domain.

#### The Problem

For security reasons, browsers block accessing the entries on the history stack itself, hence it is impossible to check upfront where history.back() and history.forward() will bring you.
When using these functions in a web app you usually only want to navigate on the same app and avoid going back to a different domain.

Examples of potentially unwanted scenarios:

Imagine you've built a web app, with a navigation menu that contains a couple of links to various pages and a go back button that is supposed to go back to the previously visited page (on your app).

- Start from a different page (e.g. google.com) > overwrite the url with the url of your app > press enter to load > press the go back button on your app => you will go back to google.com and there is no way to avoid that or to check if the previous history entry was from a different domain.
- You implemented authentication with a 3rd party system. On the first visit you redirect to the authentication provider which will redirect you back to your web app after successfully logged in. Now press the go back button => you will go back to the login (success) page again even though you are already logged in.
- You could try to keep your own history stack in memory, but this is lost when you refresh your browser. Also, this is unaware of any pages you visited before your own app. Hence it will never be an exact copy of your browser's real history stack.

#### Solution

We can't access the history stack, but we do control the next location to navigate to. So on **every** redirect (push and replace) within your app, we set a flag `fromDomain` with the current domain in the [location state](https://developer.mozilla.org/en-US/docs/Web/API/History/state) (which is stored in the browser's history stack and hence persisted between reloads). This flag is sufficient to know if the current location was reached from a page of your own web app and hence if it is safe to go back.
If this flag is not set (or set to a different domain) it means the previously visited page was not from your own web app. The [`history.back`](#history.back) function will now return a flag if the going back navigation was blocked or not. If it returns true (navigation was blocked) you can implement a safe fallback to navigate somewhere else (on your app).

NOTE: This solution only works correctly if you set the `fromDomain` flag consistently from every redirect in your app. Hence it is required to create a singleton history object and **only** use these functions.
To help with this, an ESLint rule [`no-native-navigation`](../rules/no-native-navigation.js) was added that prohibits the direct usage of the native browser's history and location API.

#### Limitation

The history API also provides functions to navigate an arbitrary amount of entries back or forth in the history stack. Since the above solution only gives you some knowledge what the direct predecessor of the current location is, the go(delta !== -1 ) and forward() functions will always return true and don't navigate.

#### Example

```ts
import { createConstantDomainHistory } from "history";
let history = createBrowserHistory();

let blocked = history.back(); // Go back, only if we will go back to the same domain.
if (blocked) {
history.push("/"); // Fallback in case going back was prevented.
}

blocked = history.forward(); // Will always be blocked and return true
blocked = history.go(-2); // Will always be blocked and return true
```

<a name="createpath"></a>
<a name="parsepath"></a>
<a name="createpath-and-parsepath"></a>
Expand Down Expand Up @@ -234,6 +295,7 @@ See also [`history.listen`](#history.listen).
### `history.back()`

Goes back one entry in the history stack. Alias for `history.go(-1)`.
Will return a boolean that indicates when the navigation was blocked or not.

See [the Navigation guide](navigation.md) for more information.

Expand Down Expand Up @@ -284,6 +346,7 @@ the given destination.
### `history.forward()`

Goes forward one entry in the history stack. Alias for `history.go(1)`.
Will return a boolean that indicates when the navigation was blocked or not.

See [the Navigation guide](navigation.md) for more information.

Expand All @@ -292,6 +355,7 @@ See [the Navigation guide](navigation.md) for more information.
### `history.go(delta: number)`

Navigates back/forward by `delta` entries in the stack.
Will return a boolean that indicates when the navigation was blocked or not.

See [the Navigation guide](navigation.md) for more information.

Expand Down
7 changes: 4 additions & 3 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@ The history library provides history tracking and navigation primitives for Java

If you haven't yet, please take a second to read through [the Installation guide](installation.md) to get the library installed and running on your system.

We provide 3 different methods for working with history, depending on your environment:
We provide 4 different methods for working with history, depending on your environment:

- A "browser history" is for use in modern web browsers that support the [HTML5 history API](http://diveintohtml5.info/history.html) (see [cross-browser compatibility](http://caniuse.com/#feat=history))
- A "constant domain history" is similar to browser history, but it will block back and forward navigation if it would redirect you to a different domain.
- A "hash history" is for use in web browsers where you want to store the location in the [hash](https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/hash) portion of the current URL to avoid sending it to the server when the page reloads
- A "memory history" is used as a reference implementation that may be used in non-browser environments, like [React Native](https://facebook.github.io/react-native/) or tests

The main bundle exports one method for each environment: [`createBrowserHistory`](api-reference.md#createbrowserhistory) for browsers, [`createHashHistory`](api-reference.md#createhashhistory) for using hash history in browsers, and [`createMemoryHistory`](api-reference.md#creatememoryhistory) for creating an in-memory history.
The main bundle exports one method for each environment: [`createBrowserHistory`](api-reference.md#createbrowserhistory) for browsers, [`createConstantDomainHistory`](api-reference.md#createconstantdomainHistory) for navigating on the same domain in browsers, [`createHashHistory`](api-reference.md#createhashhistory) for using hash history in browsers, and [`createMemoryHistory`](api-reference.md#creatememoryhistory) for creating an in-memory history.

In addition to the main bundle, the library also includes `history/browser` and `history/hash` bundles that export singletons you can use to quickly get a history instance for [the current `document`](https://developer.mozilla.org/en-US/docs/Web/API/Window/document) (web page).
In addition to the main bundle, the library also includes `history/browser`, `history/constantDomain` and `history/hash` bundles that export singletons you can use to quickly get a history instance for [the current `document`](https://developer.mozilla.org/en-US/docs/Web/API/Window/document) (web page).

## Basic Usage

Expand Down
177 changes: 177 additions & 0 deletions packages/history/__tests__/constantDomain-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import expect from "expect";
import { createConstantDomainHistory } from "history";

import PushNewLocation from "./TestSequences/PushNewLocation.js";
import PushSamePath from "./TestSequences/PushSamePath.js";
import PushState from "./TestSequences/PushState.js";
import PushMissingPathname from "./TestSequences/PushMissingPathname.js";
import PushRelativePathname from "./TestSequences/PushRelativePathname.js";
import ReplaceNewLocation from "./TestSequences/ReplaceNewLocation.js";
import ReplaceSamePath from "./TestSequences/ReplaceSamePath.js";
import ReplaceState from "./TestSequences/ReplaceState.js";
import EncodedReservedCharacters from "./TestSequences/EncodedReservedCharacters.js";
import GoBack from "./TestSequences/GoBack.js";
import BlockEverything from "./TestSequences/BlockEverything.js";
import { execSteps } from "./TestSequences/utils.js";

describe("a constant domain history", () => {
let history;
beforeEach(() => {
window.history.replaceState(null, null, "/");
history = createConstantDomainHistory();
});

describe("push a new path", () => {
it("calls change listeners with the new location", (done) => {
PushNewLocation(history, done);
});
});

describe("push the same path", () => {
it("calls change listeners with the new location", (done) => {
PushSamePath(history, done);
});
});

describe("push state", () => {
it("calls change listeners with the new location", (done) => {
PushState(history, done);
});
});

describe("push with no pathname", () => {
it("reuses the current location pathname", (done) => {
PushMissingPathname(history, done);
});
});

describe("push with a relative pathname", () => {
it("normalizes the pathname relative to the current location", (done) => {
PushRelativePathname(history, done);
});
});

describe("replace a new path", () => {
it("calls change listeners with the new location", (done) => {
ReplaceNewLocation(history, done);
});
});

describe("replace the same path", () => {
it("calls change listeners with the new location", (done) => {
ReplaceSamePath(history, done);
});
});

describe("replace state", () => {
it("calls change listeners with the new location", (done) => {
ReplaceState(history, done);
});
});

describe("location created with encoded/unencoded reserved characters", () => {
it("produces different location objects", (done) => {
EncodedReservedCharacters(history, done);
});
});

describe("back", () => {
it("calls change listeners with the previous location", (done) => {
GoBack(history, done);
});
it("avoid going back to a different domain (on initial route)", (done) => {
let steps = [
({ action, location }) => {
expect(location).toMatchObject({
pathname: "/",
});

const blocked = history.back();
expect(blocked).toBeTruthy();
},
];
execSteps(steps, history, done);
});
it("avoid going back to a different domain, after replace", (done) => {
let steps = [
({ location }) => {
expect(location).toMatchObject({
pathname: "/",
});
history.replace("/home?the=query#the-hash", { the: "state" });
},
({ action, location }) => {
expect(action).toBe("REPLACE");
expect(location).toMatchObject({
pathname: "/home",
search: "?the=query",
hash: "#the-hash",
state: { the: "state" },
key: expect.any(String),
});

const blocked = history.back();
expect(blocked).toBeTruthy();
},
];
execSteps(steps, history, done);
});
it("avoid going back to a different domain, starting from a different domain", (done) => {
// TODO: how to test?
done();
});
it("go back after a reload", (done) => {
// TODO: how to test?
done();
});
});

describe("forward", () => {
it("forward is not allowed in constant domain history", (done) => {
let steps = [
({ location }) => {
expect(location).toMatchObject({
pathname: "/",
});

history.push("/home");
},
({ action, location }) => {
expect(action).toEqual("PUSH");
expect(location).toMatchObject({
pathname: "/home",
});

const blocked = history.back();
expect(blocked).toBeFalsy();
},
({ action, location }) => {
expect(action).toEqual("POP");
expect(location).toMatchObject({
pathname: "/",
});

const blocked = history.forward();
expect(blocked).toBeTruthy();
expect(history.location).toMatchObject({
pathname: "/",
});
},
];

execSteps(steps, history, done);
});
});

describe("block", () => {
it("blocks all transitions", (done) => {
BlockEverything(history, done);
});
});

// describe("block a POP without listening", () => {
// it("receives the next ({ action, location })", (done) => {
// BlockPopWithoutListening(history, done);
// });
// });
});
6 changes: 6 additions & 0 deletions packages/history/constantDomain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createConstantDomainHistory } from "history";

/**
* Create a default instance for the current document.
*/
export default createConstantDomainHistory();
Loading