summaryrefslogtreecommitdiff
path: root/history/modules
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2021-08-23 16:46:06 -0300
committerSebastian <sebasjm@gmail.com>2021-08-23 16:48:30 -0300
commit38acabfa6089ab8ac469c12b5f55022fb96935e5 (patch)
tree453dbf70000cc5e338b06201af1eaca8343f8f73 /history/modules
parentf26125e039143b92dc0d84e7775f508ab0cdcaa8 (diff)
downloadnode-vendor-38acabfa6089ab8ac469c12b5f55022fb96935e5.tar.gz
node-vendor-38acabfa6089ab8ac469c12b5f55022fb96935e5.tar.bz2
node-vendor-38acabfa6089ab8ac469c12b5f55022fb96935e5.zip
added web vendorsHEADmaster
Diffstat (limited to 'history/modules')
-rw-r--r--history/modules/.babelrc4
-rw-r--r--history/modules/.eslintrc15
-rw-r--r--history/modules/DOMUtils.js54
-rw-r--r--history/modules/LocationUtils.js80
-rw-r--r--history/modules/PathUtils.js59
-rw-r--r--history/modules/__tests__/.eslintrc8
-rw-r--r--history/modules/__tests__/BrowserHistory-basename-test.js88
-rw-r--r--history/modules/__tests__/BrowserHistory-test.js214
-rw-r--r--history/modules/__tests__/HashHistory-base-test.js34
-rw-r--r--history/modules/__tests__/HashHistory-basename-test.js92
-rw-r--r--history/modules/__tests__/HashHistory-coding-test.js76
-rw-r--r--history/modules/__tests__/HashHistory-test.js215
-rw-r--r--history/modules/__tests__/MemoryHistory-test.js203
-rw-r--r--history/modules/__tests__/TestSequences/BackButtonTransitionHook.js41
-rw-r--r--history/modules/__tests__/TestSequences/BlockEverything.js25
-rw-r--r--history/modules/__tests__/TestSequences/BlockPopWithoutListening.js33
-rw-r--r--history/modules/__tests__/TestSequences/DenyGoBack.js42
-rw-r--r--history/modules/__tests__/TestSequences/DenyGoForward.js50
-rw-r--r--history/modules/__tests__/TestSequences/DenyPush.js31
-rw-r--r--history/modules/__tests__/TestSequences/EncodedReservedCharacters.js38
-rw-r--r--history/modules/__tests__/TestSequences/GoBack.js31
-rw-r--r--history/modules/__tests__/TestSequences/GoForward.js39
-rw-r--r--history/modules/__tests__/TestSequences/HashChangeTransitionHook.js33
-rw-r--r--history/modules/__tests__/TestSequences/HashbangHashPathCoding.js48
-rw-r--r--history/modules/__tests__/TestSequences/InitialLocationHasKey.js13
-rw-r--r--history/modules/__tests__/TestSequences/InitialLocationNoKey.js13
-rw-r--r--history/modules/__tests__/TestSequences/Listen.js12
-rw-r--r--history/modules/__tests__/TestSequences/LocationPathnameAlwaysSame.js44
-rw-r--r--history/modules/__tests__/TestSequences/NoslashHashPathCoding.js50
-rw-r--r--history/modules/__tests__/TestSequences/PushEncodedLocation.js28
-rw-r--r--history/modules/__tests__/TestSequences/PushInvalidPathname.js17
-rw-r--r--history/modules/__tests__/TestSequences/PushMissingPathname.js35
-rw-r--r--history/modules/__tests__/TestSequences/PushNewLocation.js25
-rw-r--r--history/modules/__tests__/TestSequences/PushRelativePathname.js35
-rw-r--r--history/modules/__tests__/TestSequences/PushSamePath.js39
-rw-r--r--history/modules/__tests__/TestSequences/PushSamePathWarning.js55
-rw-r--r--history/modules/__tests__/TestSequences/PushState.js26
-rw-r--r--history/modules/__tests__/TestSequences/PushStateWarning.js40
-rw-r--r--history/modules/__tests__/TestSequences/PushUnicodeLocation.js28
-rw-r--r--history/modules/__tests__/TestSequences/ReplaceInvalidPathname.js17
-rw-r--r--history/modules/__tests__/TestSequences/ReplaceNewLocation.js25
-rw-r--r--history/modules/__tests__/TestSequences/ReplaceSamePath.js37
-rw-r--r--history/modules/__tests__/TestSequences/ReplaceState.js26
-rw-r--r--history/modules/__tests__/TestSequences/ReplaceStateWarning.js40
-rw-r--r--history/modules/__tests__/TestSequences/ReturnFalseTransitionHook.js32
-rw-r--r--history/modules/__tests__/TestSequences/SlashHashPathCoding.js48
-rw-r--r--history/modules/__tests__/TestSequences/TransitionHookArgs.js32
-rw-r--r--history/modules/__tests__/TestSequences/execSteps.js34
-rw-r--r--history/modules/__tests__/TestSequences/index.js46
-rw-r--r--history/modules/__tests__/createLocation-test.js144
-rw-r--r--history/modules/createBrowserHistory.js329
-rw-r--r--history/modules/createHashHistory.js361
-rw-r--r--history/modules/createMemoryHistory.js186
-rw-r--r--history/modules/createTransitionManager.js78
-rw-r--r--history/modules/index.js5
-rw-r--r--history/modules/invariant.js1
-rw-r--r--history/modules/warning.js1
57 files changed, 3455 insertions, 0 deletions
diff --git a/history/modules/.babelrc b/history/modules/.babelrc
new file mode 100644
index 0000000..9b2b3bf
--- /dev/null
+++ b/history/modules/.babelrc
@@ -0,0 +1,4 @@
+{
+ "presets": [["@babel/env", { "loose": true }]],
+ "plugins": ["dev-expression", "@babel/transform-object-assign"]
+}
diff --git a/history/modules/.eslintrc b/history/modules/.eslintrc
new file mode 100644
index 0000000..dc32b84
--- /dev/null
+++ b/history/modules/.eslintrc
@@ -0,0 +1,15 @@
+{
+ "parser": "babel-eslint",
+ "plugins": ["import"],
+ "env": {
+ "browser": true
+ },
+ "extends": ["eslint:recommended", "plugin:import/errors"],
+ "rules": {
+ "prefer-arrow-callback": 2
+ },
+ "parserOptions": {
+ "ecmaVersion": 6,
+ "sourceType": "module"
+ }
+}
diff --git a/history/modules/DOMUtils.js b/history/modules/DOMUtils.js
new file mode 100644
index 0000000..95c3a6e
--- /dev/null
+++ b/history/modules/DOMUtils.js
@@ -0,0 +1,54 @@
+export const canUseDOM = !!(
+ typeof window !== 'undefined' &&
+ window.document &&
+ window.document.createElement
+);
+
+export function getConfirmation(message, callback) {
+ callback(window.confirm(message)); // eslint-disable-line no-alert
+}
+
+/**
+ * Returns true if the HTML5 history API is supported. Taken from Modernizr.
+ *
+ * https://github.com/Modernizr/Modernizr/blob/master/LICENSE
+ * https://github.com/Modernizr/Modernizr/blob/master/feature-detects/history.js
+ * changed to avoid false negatives for Windows Phones: https://github.com/reactjs/react-router/issues/586
+ */
+export function supportsHistory() {
+ const ua = window.navigator.userAgent;
+
+ if (
+ (ua.indexOf('Android 2.') !== -1 || ua.indexOf('Android 4.0') !== -1) &&
+ ua.indexOf('Mobile Safari') !== -1 &&
+ ua.indexOf('Chrome') === -1 &&
+ ua.indexOf('Windows Phone') === -1
+ )
+ return false;
+
+ return window.history && 'pushState' in window.history;
+}
+
+/**
+ * Returns true if browser fires popstate on hash change.
+ * IE10 and IE11 do not.
+ */
+export function supportsPopStateOnHashChange() {
+ return window.navigator.userAgent.indexOf('Trident') === -1;
+}
+
+/**
+ * Returns false if using go(n) with hash history causes a full page reload.
+ */
+export function supportsGoWithoutReloadUsingHash() {
+ return window.navigator.userAgent.indexOf('Firefox') === -1;
+}
+
+/**
+ * Returns true if a given popstate event is an extraneous WebKit event.
+ * Accounts for the fact that Chrome on iOS fires real popstate events
+ * containing undefined state when pressing the back button.
+ */
+export function isExtraneousPopstateEvent(event) {
+ return event.state === undefined && navigator.userAgent.indexOf('CriOS') === -1;
+}
diff --git a/history/modules/LocationUtils.js b/history/modules/LocationUtils.js
new file mode 100644
index 0000000..27cb0f8
--- /dev/null
+++ b/history/modules/LocationUtils.js
@@ -0,0 +1,80 @@
+import resolvePathname from 'resolve-pathname';
+import valueEqual from 'value-equal';
+
+import { parsePath } from './PathUtils.js';
+
+export function createLocation(path, state, key, currentLocation) {
+ let location;
+ if (typeof path === 'string') {
+ // Two-arg form: push(path, state)
+ location = parsePath(path);
+ location.state = state;
+ } else {
+ // One-arg form: push(location)
+ location = { ...path };
+
+ if (location.pathname === undefined) location.pathname = '';
+
+ if (location.search) {
+ if (location.search.charAt(0) !== '?')
+ location.search = '?' + location.search;
+ } else {
+ location.search = '';
+ }
+
+ if (location.hash) {
+ if (location.hash.charAt(0) !== '#') location.hash = '#' + location.hash;
+ } else {
+ location.hash = '';
+ }
+
+ if (state !== undefined && location.state === undefined)
+ location.state = state;
+ }
+
+ try {
+ location.pathname = decodeURI(location.pathname);
+ } catch (e) {
+ if (e instanceof URIError) {
+ throw new URIError(
+ 'Pathname "' +
+ location.pathname +
+ '" could not be decoded. ' +
+ 'This is likely caused by an invalid percent-encoding.'
+ );
+ } else {
+ throw e;
+ }
+ }
+
+ if (key) location.key = key;
+
+ if (currentLocation) {
+ // Resolve incomplete/relative pathname relative to current location.
+ if (!location.pathname) {
+ location.pathname = currentLocation.pathname;
+ } else if (location.pathname.charAt(0) !== '/') {
+ location.pathname = resolvePathname(
+ location.pathname,
+ currentLocation.pathname
+ );
+ }
+ } else {
+ // When there is no prior location and pathname is empty, set it to /
+ if (!location.pathname) {
+ location.pathname = '/';
+ }
+ }
+
+ return location;
+}
+
+export function locationsAreEqual(a, b) {
+ return (
+ a.pathname === b.pathname &&
+ a.search === b.search &&
+ a.hash === b.hash &&
+ a.key === b.key &&
+ valueEqual(a.state, b.state)
+ );
+}
diff --git a/history/modules/PathUtils.js b/history/modules/PathUtils.js
new file mode 100644
index 0000000..e5abec7
--- /dev/null
+++ b/history/modules/PathUtils.js
@@ -0,0 +1,59 @@
+export function addLeadingSlash(path) {
+ return path.charAt(0) === '/' ? path : '/' + path;
+}
+
+export function stripLeadingSlash(path) {
+ return path.charAt(0) === '/' ? path.substr(1) : path;
+}
+
+export function hasBasename(path, prefix) {
+ return (
+ path.toLowerCase().indexOf(prefix.toLowerCase()) === 0 &&
+ '/?#'.indexOf(path.charAt(prefix.length)) !== -1
+ );
+}
+
+export function stripBasename(path, prefix) {
+ return hasBasename(path, prefix) ? path.substr(prefix.length) : path;
+}
+
+export function stripTrailingSlash(path) {
+ return path.charAt(path.length - 1) === '/' ? path.slice(0, -1) : path;
+}
+
+export function parsePath(path) {
+ let pathname = path || '/';
+ let search = '';
+ let hash = '';
+
+ const hashIndex = pathname.indexOf('#');
+ if (hashIndex !== -1) {
+ hash = pathname.substr(hashIndex);
+ pathname = pathname.substr(0, hashIndex);
+ }
+
+ const searchIndex = pathname.indexOf('?');
+ if (searchIndex !== -1) {
+ search = pathname.substr(searchIndex);
+ pathname = pathname.substr(0, searchIndex);
+ }
+
+ return {
+ pathname,
+ search: search === '?' ? '' : search,
+ hash: hash === '#' ? '' : hash
+ };
+}
+
+export function createPath(location) {
+ const { pathname, search, hash } = location;
+
+ let path = pathname || '/';
+
+ if (search && search !== '?')
+ path += search.charAt(0) === '?' ? search : `?${search}`;
+
+ if (hash && hash !== '#') path += hash.charAt(0) === '#' ? hash : `#${hash}`;
+
+ return path;
+}
diff --git a/history/modules/__tests__/.eslintrc b/history/modules/__tests__/.eslintrc
new file mode 100644
index 0000000..09113c6
--- /dev/null
+++ b/history/modules/__tests__/.eslintrc
@@ -0,0 +1,8 @@
+{
+ "env": {
+ "mocha": true
+ },
+ "rules": {
+ "import/no-unresolved": [2, { "ignore": ["history"] }]
+ }
+}
diff --git a/history/modules/__tests__/BrowserHistory-basename-test.js b/history/modules/__tests__/BrowserHistory-basename-test.js
new file mode 100644
index 0000000..97a67fa
--- /dev/null
+++ b/history/modules/__tests__/BrowserHistory-basename-test.js
@@ -0,0 +1,88 @@
+import expect from 'expect';
+import mock from 'jest-mock';
+import { createBrowserHistory } from 'history';
+
+describe('a browser history with a basename', () => {
+ it('knows how to create hrefs', () => {
+ window.history.replaceState(null, null, '/the/base');
+ const history = createBrowserHistory({ basename: '/the/base' });
+ const href = history.createHref({
+ pathname: '/the/path',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+
+ expect(href).toEqual('/the/base/the/path?the=query#the-hash');
+ });
+
+ describe('with a bad basename', () => {
+ it('knows how to create hrefs', () => {
+ window.history.replaceState(null, null, '/the/bad/base');
+ const history = createBrowserHistory({ basename: '/the/bad/base/' });
+ const href = history.createHref({
+ pathname: '/the/path',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+
+ expect(href).toEqual('/the/bad/base/the/path?the=query#the-hash');
+ });
+ });
+
+ describe('with a slash basename', () => {
+ it('knows how to create hrefs', () => {
+ window.history.replaceState(null, null, '/');
+ const history = createBrowserHistory({ basename: '/' });
+ const href = history.createHref({
+ pathname: '/the/path',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+
+ expect(href).toEqual('/the/path?the=query#the-hash');
+ });
+ });
+
+ it('strips the basename from the pathname', () => {
+ window.history.replaceState(null, null, '/prefix/pathname');
+ const history = createBrowserHistory({ basename: '/prefix' });
+ expect(history.location.pathname).toEqual('/pathname');
+ });
+
+ it('is not case-sensitive', () => {
+ window.history.replaceState(null, null, '/PREFIX/pathname');
+ const history = createBrowserHistory({ basename: '/prefix' });
+ expect(history.location.pathname).toEqual('/pathname');
+ });
+
+ it('does not strip partial prefix matches', () => {
+ const spy = mock.spyOn(console, 'warn').mockImplementation(() => {});
+
+ window.history.replaceState(null, null, '/prefixed/pathname');
+ const history = createBrowserHistory({ basename: '/prefix' });
+ expect(history.location.pathname).toEqual('/prefixed/pathname');
+
+ expect(spy).toHaveBeenCalledTimes(1);
+ spy.mockRestore();
+ });
+
+ describe('when the pathname is only the prefix', () => {
+ it('strips the prefix', () => {
+ window.history.replaceState(null, null, '/prefix');
+ const history = createBrowserHistory({ basename: '/prefix' });
+ expect(history.location.pathname).toEqual('/');
+ });
+
+ it('strips the prefix when there is a search string', () => {
+ window.history.replaceState(null, null, '/prefix?a=b');
+ const history = createBrowserHistory({ basename: '/prefix' });
+ expect(history.location.pathname).toEqual('/');
+ });
+
+ it('strips the prefix when there is a hash', () => {
+ window.history.replaceState(null, null, '/prefix#rest');
+ const history = createBrowserHistory({ basename: '/prefix' });
+ expect(history.location.pathname).toEqual('/');
+ });
+ });
+});
diff --git a/history/modules/__tests__/BrowserHistory-test.js b/history/modules/__tests__/BrowserHistory-test.js
new file mode 100644
index 0000000..a9ac32d
--- /dev/null
+++ b/history/modules/__tests__/BrowserHistory-test.js
@@ -0,0 +1,214 @@
+import expect from 'expect';
+import { createBrowserHistory } from 'history';
+
+import * as TestSequences from './TestSequences/index.js';
+
+describe('a browser history', () => {
+ let history;
+ beforeEach(() => {
+ if (window.location.pathname !== '/') {
+ window.history.replaceState(null, null, '/');
+ }
+ history = createBrowserHistory();
+ });
+
+ it('knows how to create hrefs', () => {
+ const href = history.createHref({
+ pathname: '/the/path',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+
+ expect(href).toEqual('/the/path?the=query#the-hash');
+ });
+
+ it('does not encode the generated path', () => {
+ // encoded
+ const encodedHref = history.createHref({
+ pathname: '/%23abc'
+ });
+ // unencoded
+ const unencodedHref = history.createHref({
+ pathname: '/#abc'
+ });
+
+ expect(encodedHref).toEqual('/%23abc');
+ expect(unencodedHref).toEqual('/#abc');
+ });
+
+ describe('listen', () => {
+ it('does not immediately call listeners', done => {
+ TestSequences.Listen(history, done);
+ });
+ });
+
+ describe('the initial location', () => {
+ it('does not have a key', done => {
+ TestSequences.InitialLocationNoKey(history, done);
+ });
+ });
+
+ describe('push a new path', () => {
+ it('calls change listeners with the new location', done => {
+ TestSequences.PushNewLocation(history, done);
+ });
+ });
+
+ describe('push the same path', () => {
+ it('calls change listeners with the new location', done => {
+ TestSequences.PushSamePath(history, done);
+ });
+ });
+
+ describe('push state', () => {
+ it('calls change listeners with the new location', done => {
+ TestSequences.PushState(history, done);
+ });
+ });
+
+ describe('push with no pathname', () => {
+ it('calls change listeners with the normalized location', done => {
+ TestSequences.PushMissingPathname(history, done);
+ });
+ });
+
+ describe('push with a relative pathname', () => {
+ it('calls change listeners with the normalized location', done => {
+ TestSequences.PushRelativePathname(history, done);
+ });
+ });
+
+ describe('push with an invalid pathname (bad percent-encoding)', () => {
+ it('throws an error', done => {
+ TestSequences.PushInvalidPathname(history, done);
+ });
+ });
+
+ describe('push with a unicode path string', () => {
+ it('creates a location with decoded properties', done => {
+ TestSequences.PushUnicodeLocation(history, done);
+ });
+ });
+
+ describe('push with an encoded path string', () => {
+ it('creates a location object with encoded pathname', done => {
+ TestSequences.PushEncodedLocation(history, done);
+ });
+ });
+
+ describe('replace a new path', () => {
+ it('calls change listeners with the new location', done => {
+ TestSequences.ReplaceNewLocation(history, done);
+ });
+ });
+
+ describe('replace the same path', () => {
+ it('calls change listeners with the new location', done => {
+ TestSequences.ReplaceSamePath(history, done);
+ });
+ });
+
+ describe('replace with an invalid pathname (bad percent-encoding)', () => {
+ it('throws an error', done => {
+ TestSequences.ReplaceInvalidPathname(history, done);
+ });
+ });
+
+ describe('replace state', () => {
+ it('calls change listeners with the new location', done => {
+ TestSequences.ReplaceState(history, done);
+ });
+ });
+
+ describe('location created by encoded and unencoded pathname', () => {
+ it('produces the same location.pathname', done => {
+ TestSequences.LocationPathnameAlwaysSame(history, done);
+ });
+ });
+
+ describe('location created with encoded/unencoded reserved characters', () => {
+ it('produces different location objects', done => {
+ TestSequences.EncodedReservedCharacters(history, done);
+ });
+ });
+
+ describe('goBack', () => {
+ it('calls change listeners with the previous location', done => {
+ TestSequences.GoBack(history, done);
+ });
+ });
+
+ describe('goForward', () => {
+ it('calls change listeners with the next location', done => {
+ TestSequences.GoForward(history, done);
+ });
+ });
+
+ describe('block', () => {
+ it('blocks all transitions', done => {
+ TestSequences.BlockEverything(history, done);
+ });
+ });
+
+ describe('block a POP without listening', () => {
+ it('receives the next location and action as arguments', done => {
+ TestSequences.BlockPopWithoutListening(history, done);
+ });
+ });
+
+ describe('that accepts all transitions', () => {
+ let history;
+ beforeEach(() => {
+ history = createBrowserHistory({
+ getUserConfirmation(_, callback) {
+ callback(true);
+ }
+ });
+ });
+
+ it('receives the next location and action as arguments', done => {
+ TestSequences.TransitionHookArgs(history, done);
+ });
+
+ it('cancels the transition when it returns false', done => {
+ TestSequences.ReturnFalseTransitionHook(history, done);
+ });
+
+ it('is called when the back button is clicked', done => {
+ TestSequences.BackButtonTransitionHook(history, done);
+ });
+
+ it('is called on the hashchange event', done => {
+ TestSequences.HashChangeTransitionHook(history, done);
+ });
+ });
+
+ describe('that denies all transitions', () => {
+ let history;
+ beforeEach(() => {
+ history = createBrowserHistory({
+ getUserConfirmation(_, callback) {
+ callback(false);
+ }
+ });
+ });
+
+ describe('clicking on a link (push)', () => {
+ it('does not update the location', done => {
+ TestSequences.DenyPush(history, done);
+ });
+ });
+
+ describe('clicking the back button (goBack)', () => {
+ it('does not update the location', done => {
+ TestSequences.DenyGoBack(history, done);
+ });
+ });
+
+ describe('clicking the forward button (goForward)', () => {
+ it('does not update the location', done => {
+ TestSequences.DenyGoForward(history, done);
+ });
+ });
+ });
+});
diff --git a/history/modules/__tests__/HashHistory-base-test.js b/history/modules/__tests__/HashHistory-base-test.js
new file mode 100644
index 0000000..b3bd8b9
--- /dev/null
+++ b/history/modules/__tests__/HashHistory-base-test.js
@@ -0,0 +1,34 @@
+import expect from 'expect';
+import { createHashHistory } from 'history';
+
+describe('a hash history on a page with a <base> tag', () => {
+ let history, base;
+ beforeEach(() => {
+ base = document.createElement('base');
+ base.setAttribute('href', '/prefix');
+
+ document.head.appendChild(base);
+
+ history = createHashHistory();
+ });
+
+ afterEach(() => {
+ document.head.removeChild(base);
+ });
+
+ it('knows how to create hrefs', () => {
+ const hashIndex = window.location.href.indexOf('#');
+ const upToHash =
+ hashIndex === -1
+ ? window.location.href
+ : window.location.href.slice(0, hashIndex);
+
+ const href = history.createHref({
+ pathname: '/the/path',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+
+ expect(href).toEqual(upToHash + '#/the/path?the=query#the-hash');
+ });
+});
diff --git a/history/modules/__tests__/HashHistory-basename-test.js b/history/modules/__tests__/HashHistory-basename-test.js
new file mode 100644
index 0000000..1f5e530
--- /dev/null
+++ b/history/modules/__tests__/HashHistory-basename-test.js
@@ -0,0 +1,92 @@
+import expect from 'expect';
+import mock from 'jest-mock';
+import { createHashHistory } from 'history';
+
+describe('a hash history with a basename', () => {
+ it('knows how to create hrefs', () => {
+ window.location.hash = '#/the/base';
+ const history = createHashHistory({ basename: '/the/base' });
+ const href = history.createHref({
+ pathname: '/the/path',
+ search: '?the=query'
+ });
+
+ expect(href).toEqual('#/the/base/the/path?the=query');
+ });
+
+ describe('with a bad basename', () => {
+ it('knows how to create hrefs', () => {
+ window.location.hash = '#/the/bad/base/';
+ const history = createHashHistory({ basename: '/the/bad/base/' });
+ const href = history.createHref({
+ pathname: '/the/path',
+ search: '?the=query'
+ });
+
+ expect(href).toEqual('#/the/bad/base/the/path?the=query');
+ });
+ });
+
+ describe('with a slash basename', () => {
+ it('knows how to create hrefs', () => {
+ const history = createHashHistory({ basename: '/' });
+ const href = history.createHref({
+ pathname: '/the/path',
+ search: '?the=query'
+ });
+
+ expect(href).toEqual('#/the/path?the=query');
+ });
+ });
+
+ it('strips the basename from the pathname', () => {
+ window.location.hash = '/prefix/hello';
+ const history = createHashHistory({ basename: '/prefix' });
+ expect(history.location.pathname).toEqual('/hello');
+ });
+
+ it('is not case-sensitive', () => {
+ window.location.hash = '/PREFIX/hello';
+ const history = createHashHistory({ basename: '/prefix' });
+ expect(history.location.pathname).toEqual('/hello');
+ });
+
+ it('allows special regex characters', () => {
+ window.location.hash = '/prefix$special/hello';
+ const history = createHashHistory({ basename: '/prefix$special' });
+ expect(history.location.pathname).toEqual('/hello');
+ });
+
+ it('does not strip partial prefix matches', () => {
+ window.location.hash = '/no-match/hello';
+
+ // A warning is issued when the prefix is not present.
+ const spy = mock.spyOn(console, 'warn').mockImplementation(() => {});
+
+ const history = createHashHistory({ basename: '/prefix' });
+ expect(history.location.pathname).toEqual('/no-match/hello');
+
+ expect(spy).toHaveBeenCalledTimes(1);
+ spy.mockRestore();
+ });
+
+ describe('when the pathname is only the prefix', () => {
+ it('strips the prefix', () => {
+ window.location.hash = '/prefix';
+ const history = createHashHistory({ basename: '/prefix' });
+ expect(history.location.pathname).toEqual('/');
+ });
+
+ it('strips the prefix when there is a search string', () => {
+ window.location.hash = '/prefix?a=b';
+ const history = createHashHistory({ basename: '/prefix' });
+ expect(history.location.pathname).toEqual('/');
+ });
+
+ it('strips the prefix when there is a hash', () => {
+ window.location.hash = '/prefix#hash';
+ const history = createHashHistory({ basename: '/prefix' });
+ expect(history.location.pathname).toEqual('/');
+ });
+ });
+});
diff --git a/history/modules/__tests__/HashHistory-coding-test.js b/history/modules/__tests__/HashHistory-coding-test.js
new file mode 100644
index 0000000..8faae20
--- /dev/null
+++ b/history/modules/__tests__/HashHistory-coding-test.js
@@ -0,0 +1,76 @@
+import expect from 'expect';
+import { createHashHistory } from 'history';
+
+import * as TestSequences from './TestSequences/index.js';
+
+describe('a hash history with "slash" path coding', () => {
+ beforeEach(() => {
+ if (window.location.hash !== '#/') {
+ window.location.hash = '/';
+ }
+ });
+
+ it('knows how to create hrefs', () => {
+ const history = createHashHistory({ hashType: 'slash' });
+ const href = history.createHref({
+ pathname: '/the/path',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+
+ expect(href).toEqual('#/the/path?the=query#the-hash');
+ });
+
+ it('properly encodes and decodes window.location.hash', done => {
+ const history = createHashHistory({ hashType: 'slash' });
+ TestSequences.SlashHashPathCoding(history, done);
+ });
+});
+
+describe('a hash history with "hashbang" path coding', () => {
+ beforeEach(() => {
+ if (window.location.hash !== '#!/') {
+ window.location.hash = '!/';
+ }
+ });
+
+ it('knows how to create hrefs', () => {
+ const history = createHashHistory({ hashType: 'hashbang' });
+ const href = history.createHref({
+ pathname: '/the/path',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+
+ expect(href).toEqual('#!/the/path?the=query#the-hash');
+ });
+
+ it('properly encodes and decodes window.location.hash', done => {
+ const history = createHashHistory({ hashType: 'hashbang' });
+ TestSequences.HashbangHashPathCoding(history, done);
+ });
+});
+
+describe('a hash history with "noslash" path coding', () => {
+ beforeEach(() => {
+ if (window.location.hash !== '') {
+ window.location.hash = '';
+ }
+ });
+
+ it('knows how to create hrefs', () => {
+ const history = createHashHistory({ hashType: 'noslash' });
+ const href = history.createHref({
+ pathname: '/the/path',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+
+ expect(href).toEqual('#the/path?the=query#the-hash');
+ });
+
+ it('properly encodes and decodes window.location.hash', done => {
+ const history = createHashHistory({ hashType: 'noslash' });
+ TestSequences.NoslashHashPathCoding(history, done);
+ });
+});
diff --git a/history/modules/__tests__/HashHistory-test.js b/history/modules/__tests__/HashHistory-test.js
new file mode 100644
index 0000000..63c4432
--- /dev/null
+++ b/history/modules/__tests__/HashHistory-test.js
@@ -0,0 +1,215 @@
+import expect from 'expect';
+import { createHashHistory } from 'history';
+
+import * as TestSequences from './TestSequences/index.js';
+
+const canGoWithoutReload = window.navigator.userAgent.indexOf('Firefox') === -1;
+const describeGo = canGoWithoutReload ? describe : describe.skip;
+
+describe('a hash history', () => {
+ let history;
+ beforeEach(() => {
+ if (window.location.hash !== '#/') {
+ window.location.hash = '/';
+ }
+ history = createHashHistory();
+ });
+
+ it('knows how to create hrefs', () => {
+ const href = history.createHref({
+ pathname: '/the/path',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+
+ expect(href).toEqual('#/the/path?the=query#the-hash');
+ });
+
+ it('does not encode the generated path', () => {
+ // encoded
+ const encodedHref = history.createHref({
+ pathname: '/%23abc'
+ });
+ // unencoded
+ const unencodedHref = history.createHref({
+ pathname: '/#abc'
+ });
+
+ expect(encodedHref).toEqual('#/%23abc');
+ expect(unencodedHref).toEqual('#/#abc');
+ });
+
+ describe('listen', () => {
+ it('does not immediately call listeners', done => {
+ TestSequences.Listen(history, done);
+ });
+ });
+
+ describe('the initial location', () => {
+ it('does not have a key', done => {
+ TestSequences.InitialLocationNoKey(history, done);
+ });
+ });
+
+ describe('push a new path', () => {
+ it('calls change listeners with the new location', done => {
+ TestSequences.PushNewLocation(history, done);
+ });
+ });
+
+ describe('push the same path', () => {
+ it('calls change listeners with the same location and emits a warning', done => {
+ TestSequences.PushSamePathWarning(history, done);
+ });
+ });
+
+ describe('push state', () => {
+ it('calls change listeners with the new location and emits a warning', done => {
+ TestSequences.PushStateWarning(history, done);
+ });
+ });
+
+ describe('push with no pathname', () => {
+ it('calls change listeners with the normalized location', done => {
+ TestSequences.PushMissingPathname(history, done);
+ });
+ });
+
+ describe('push with a relative pathname', () => {
+ it('calls change listeners with the normalized location', done => {
+ TestSequences.PushRelativePathname(history, done);
+ });
+ });
+
+ describe('push with an invalid pathname (bad percent-encoding)', () => {
+ it('throws an error', done => {
+ TestSequences.PushInvalidPathname(history, done);
+ });
+ });
+
+ describe('push with a unicode path string', () => {
+ it('creates a location with decoded properties', done => {
+ TestSequences.PushUnicodeLocation(history, done);
+ });
+ });
+
+ describe('push with an encoded path string', () => {
+ it('creates a location object with encoded pathname', done => {
+ TestSequences.PushEncodedLocation(history, done);
+ });
+ });
+
+ describe('replace a new path', () => {
+ it('calls change listeners with the new location', done => {
+ TestSequences.ReplaceNewLocation(history, done);
+ });
+ });
+
+ describe('replace the same path', () => {
+ it('calls change listeners with the new location', done => {
+ TestSequences.ReplaceSamePath(history, done);
+ });
+ });
+
+ describe('replace with an invalid pathname (bad percent-encoding)', () => {
+ it('throws an error', done => {
+ TestSequences.ReplaceInvalidPathname(history, done);
+ });
+ });
+
+ describe('replace state', () => {
+ it('calls change listeners with the new location and emits a warning', done => {
+ TestSequences.ReplaceStateWarning(history, done);
+ });
+ });
+
+ describe('location created by encoded and unencoded pathname', () => {
+ it('produces the same location.pathname', done => {
+ TestSequences.LocationPathnameAlwaysSame(history, done);
+ });
+ });
+
+ describe('location created with encoded/unencoded reserved characters', () => {
+ it('produces different location objects', done => {
+ TestSequences.EncodedReservedCharacters(history, done);
+ });
+ });
+
+ describeGo('goBack', () => {
+ it('calls change listeners with the previous location', done => {
+ TestSequences.GoBack(history, done);
+ });
+ });
+
+ describeGo('goForward', () => {
+ it('calls change listeners with the next location', done => {
+ TestSequences.GoForward(history, done);
+ });
+ });
+
+ describe('block', () => {
+ it('blocks all transitions', done => {
+ TestSequences.BlockEverything(history, done);
+ });
+ });
+
+ describeGo('block a POP without listening', () => {
+ it('receives the next location and action as arguments', done => {
+ TestSequences.BlockPopWithoutListening(history, done);
+ });
+ });
+
+ describe('that accepts all transitions', () => {
+ let history;
+ beforeEach(() => {
+ history = createHashHistory({
+ getUserConfirmation(_, callback) {
+ callback(true);
+ }
+ });
+ });
+
+ it('receives the next location and action as arguments', done => {
+ TestSequences.TransitionHookArgs(history, done);
+ });
+
+ const itBackButton = canGoWithoutReload ? it : it.skip;
+
+ itBackButton('is called when the back button is clicked', done => {
+ TestSequences.BackButtonTransitionHook(history, done);
+ });
+
+ it('cancels the transition when it returns false', done => {
+ TestSequences.ReturnFalseTransitionHook(history, done);
+ });
+ });
+
+ describe('that denies all transitions', () => {
+ let history;
+ beforeEach(() => {
+ history = createHashHistory({
+ getUserConfirmation(_, callback) {
+ callback(false);
+ }
+ });
+ });
+
+ describe('clicking on a link (push)', () => {
+ it('does not update the location', done => {
+ TestSequences.DenyPush(history, done);
+ });
+ });
+
+ describeGo('clicking the back button (goBack)', () => {
+ it('does not update the location', done => {
+ TestSequences.DenyGoBack(history, done);
+ });
+ });
+
+ describeGo('clicking the forward button (goForward)', () => {
+ it('does not update the location', done => {
+ TestSequences.DenyGoForward(history, done);
+ });
+ });
+ });
+});
diff --git a/history/modules/__tests__/MemoryHistory-test.js b/history/modules/__tests__/MemoryHistory-test.js
new file mode 100644
index 0000000..176f874
--- /dev/null
+++ b/history/modules/__tests__/MemoryHistory-test.js
@@ -0,0 +1,203 @@
+import expect from 'expect';
+import { createMemoryHistory } from 'history';
+
+import * as TestSequences from './TestSequences/index.js';
+
+describe('a memory history', () => {
+ let history;
+ beforeEach(() => {
+ history = createMemoryHistory();
+ });
+
+ it('knows how to create hrefs', () => {
+ const href = history.createHref({
+ pathname: '/the/path',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+
+ expect(href).toEqual('/the/path?the=query#the-hash');
+ });
+
+ it('does not encode the generated path', () => {
+ // encoded
+ const encodedHref = history.createHref({
+ pathname: '/%23abc'
+ });
+ // unencoded
+ const unencodedHref = history.createHref({
+ pathname: '/#abc'
+ });
+
+ expect(encodedHref).toEqual('/%23abc');
+ expect(unencodedHref).toEqual('/#abc');
+ });
+
+ describe('listen', () => {
+ it('does not immediately call listeners', done => {
+ TestSequences.Listen(history, done);
+ });
+ });
+
+ describe('the initial location', () => {
+ it('has a key', done => {
+ TestSequences.InitialLocationHasKey(history, done);
+ });
+ });
+
+ describe('push a new path', () => {
+ it('calls change listeners with the new location', done => {
+ TestSequences.PushNewLocation(history, done);
+ });
+ });
+
+ describe('push the same path', () => {
+ it('calls change listeners with the new location', done => {
+ TestSequences.PushSamePath(history, done);
+ });
+ });
+
+ describe('push state', () => {
+ it('calls change listeners with the new location', done => {
+ TestSequences.PushState(history, done);
+ });
+ });
+
+ describe('push with no pathname', () => {
+ it('calls change listeners with the normalized location', done => {
+ TestSequences.PushMissingPathname(history, done);
+ });
+ });
+
+ describe('push with a relative pathname', () => {
+ it('calls change listeners with the normalized location', done => {
+ TestSequences.PushRelativePathname(history, done);
+ });
+ });
+
+ describe('push with an invalid pathname (bad percent-encoding)', () => {
+ it('throws an error', done => {
+ TestSequences.PushInvalidPathname(history, done);
+ });
+ });
+
+ describe('push with a unicode path string', () => {
+ it('creates a location with decoded properties', done => {
+ TestSequences.PushUnicodeLocation(history, done);
+ });
+ });
+
+ describe('push with an encoded path string', () => {
+ it('creates a location object with encoded pathname', done => {
+ TestSequences.PushEncodedLocation(history, done);
+ });
+ });
+
+ describe('replace a new path', () => {
+ it('calls change listeners with the new location', done => {
+ TestSequences.ReplaceNewLocation(history, done);
+ });
+ });
+
+ describe('replace the same path', () => {
+ it('calls change listeners with the new location', done => {
+ TestSequences.ReplaceSamePath(history, done);
+ });
+ });
+
+ describe('replace with an invalid pathname (bad percent-encoding)', () => {
+ it('throws an error', done => {
+ TestSequences.ReplaceInvalidPathname(history, done);
+ });
+ });
+
+ describe('replace state', () => {
+ it('calls change listeners with the new location', done => {
+ TestSequences.ReplaceState(history, done);
+ });
+ });
+
+ describe('location created by encoded and unencoded pathname', () => {
+ it('produces the same location.pathname', done => {
+ TestSequences.LocationPathnameAlwaysSame(history, done);
+ });
+ });
+
+ describe('location created with encoded/unencoded reserved characters', () => {
+ it('produces different location objects', done => {
+ TestSequences.EncodedReservedCharacters(history, done);
+ });
+ });
+
+ describe('goBack', () => {
+ it('calls change listeners with the previous location', done => {
+ TestSequences.GoBack(history, done);
+ });
+ });
+
+ describe('goForward', () => {
+ it('calls change listeners with the next location', done => {
+ TestSequences.GoForward(history, done);
+ });
+ });
+
+ describe('block', () => {
+ it('blocks all transitions', done => {
+ TestSequences.BlockEverything(history, done);
+ });
+ });
+
+ describe('block a POP without listening', () => {
+ it('receives the next location and action as arguments', done => {
+ TestSequences.BlockPopWithoutListening(history, done);
+ });
+ });
+
+ describe('that accepts all transitions', () => {
+ let history;
+ beforeEach(() => {
+ history = createMemoryHistory({
+ getUserConfirmation(_, callback) {
+ callback(true);
+ }
+ });
+ });
+
+ it('receives the next location and action as arguments', done => {
+ TestSequences.TransitionHookArgs(history, done);
+ });
+
+ it('cancels the transition when it returns false', done => {
+ TestSequences.ReturnFalseTransitionHook(history, done);
+ });
+ });
+
+ describe('that denies all transitions', () => {
+ let history;
+ beforeEach(() => {
+ history = createMemoryHistory({
+ getUserConfirmation(_, callback) {
+ callback(false);
+ }
+ });
+ });
+
+ describe('push', () => {
+ it('does not update the location', done => {
+ TestSequences.DenyPush(history, done);
+ });
+ });
+
+ describe('goBack', () => {
+ it('does not update the location', done => {
+ TestSequences.DenyGoBack(history, done);
+ });
+ });
+
+ describe('goForward', () => {
+ it('does not update the location', done => {
+ TestSequences.DenyGoForward(history, done);
+ });
+ });
+ });
+});
diff --git a/history/modules/__tests__/TestSequences/BackButtonTransitionHook.js b/history/modules/__tests__/TestSequences/BackButtonTransitionHook.js
new file mode 100644
index 0000000..9c6783c
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/BackButtonTransitionHook.js
@@ -0,0 +1,41 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ let unblock,
+ hookWasCalled = false;
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.push('/home');
+ },
+ (location, action) => {
+ expect(action).toBe('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/home'
+ });
+
+ unblock = history.block(() => {
+ hookWasCalled = true;
+ });
+
+ window.history.go(-1);
+ },
+ (location, action) => {
+ expect(action).toBe('POP');
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ expect(hookWasCalled).toBe(true);
+
+ unblock();
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/BlockEverything.js b/history/modules/__tests__/TestSequences/BlockEverything.js
new file mode 100644
index 0000000..b5860c3
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/BlockEverything.js
@@ -0,0 +1,25 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ const unblock = history.block();
+
+ history.push('/home');
+
+ expect(history.location).toMatchObject({
+ pathname: '/'
+ });
+
+ unblock();
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/BlockPopWithoutListening.js b/history/modules/__tests__/TestSequences/BlockPopWithoutListening.js
new file mode 100644
index 0000000..883fce3
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/BlockPopWithoutListening.js
@@ -0,0 +1,33 @@
+import expect from 'expect';
+
+export default function(history, done) {
+ expect(history.location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.push('/home');
+
+ let transitionHookWasCalled = false;
+ const unblock = history.block(() => {
+ transitionHookWasCalled = true;
+ });
+
+ // These timeouts are a hack to allow for the time it takes
+ // for histories to reflect the change in the URL. Normally
+ // we could just listen and avoid the waiting time. But this
+ // test is designed to test what happens when we don't listen(),
+ // so that's not an option here.
+
+ // Allow some time for history to detect the PUSH.
+ setTimeout(() => {
+ history.goBack();
+
+ // Allow some time for history to detect the POP.
+ setTimeout(() => {
+ expect(transitionHookWasCalled).toBe(true);
+ unblock();
+
+ done();
+ }, 100);
+ }, 10);
+}
diff --git a/history/modules/__tests__/TestSequences/DenyGoBack.js b/history/modules/__tests__/TestSequences/DenyGoBack.js
new file mode 100644
index 0000000..122d336
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/DenyGoBack.js
@@ -0,0 +1,42 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ let unblock;
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.push('/home');
+ },
+ (location, action) => {
+ expect(action).toBe('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/home'
+ });
+
+ unblock = history.block(nextLocation => {
+ expect(nextLocation).toMatchObject({
+ pathname: '/'
+ });
+
+ return 'Are you sure?';
+ });
+
+ history.goBack();
+ },
+ (location, action) => {
+ expect(action).toBe('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/home'
+ });
+
+ unblock();
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/DenyGoForward.js b/history/modules/__tests__/TestSequences/DenyGoForward.js
new file mode 100644
index 0000000..fa27a93
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/DenyGoForward.js
@@ -0,0 +1,50 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ let unblock;
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.push('/home');
+ },
+ (location, action) => {
+ expect(action).toBe('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/home'
+ });
+
+ history.goBack();
+ },
+ (location, action) => {
+ expect(action).toBe('POP');
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ unblock = history.block(nextLocation => {
+ expect(nextLocation).toMatchObject({
+ pathname: '/home'
+ });
+
+ return 'Are you sure?';
+ });
+
+ history.goForward();
+ },
+ (location, action) => {
+ expect(action).toBe('POP');
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ unblock();
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/DenyPush.js b/history/modules/__tests__/TestSequences/DenyPush.js
new file mode 100644
index 0000000..1fd82ae
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/DenyPush.js
@@ -0,0 +1,31 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ const unblock = history.block(nextLocation => {
+ expect(nextLocation).toMatchObject({
+ pathname: '/home'
+ });
+
+ return 'Are you sure?';
+ });
+
+ history.push('/home');
+
+ expect(history.location).toMatchObject({
+ pathname: '/'
+ });
+
+ unblock();
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/EncodedReservedCharacters.js b/history/modules/__tests__/TestSequences/EncodedReservedCharacters.js
new file mode 100644
index 0000000..355a2ff
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/EncodedReservedCharacters.js
@@ -0,0 +1,38 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ () => {
+ // encoded string
+ const pathname = '/view/%23abc';
+ history.replace(pathname);
+ },
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/view/%23abc'
+ });
+
+ // encoded object
+ const pathname = '/view/%23abc';
+ history.replace({ pathname });
+ },
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/view/%23abc'
+ });
+ // unencoded string
+ const pathname = '/view/#abc';
+ history.replace(pathname);
+ },
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/view/',
+ hash: '#abc'
+ });
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/GoBack.js b/history/modules/__tests__/TestSequences/GoBack.js
new file mode 100644
index 0000000..b5a5752
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/GoBack.js
@@ -0,0 +1,31 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.push('/home');
+ },
+ (location, action) => {
+ expect(action).toEqual('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/home'
+ });
+
+ history.goBack();
+ },
+ (location, action) => {
+ expect(action).toEqual('POP');
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/GoForward.js b/history/modules/__tests__/TestSequences/GoForward.js
new file mode 100644
index 0000000..06d7f61
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/GoForward.js
@@ -0,0 +1,39 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.push('/home');
+ },
+ (location, action) => {
+ expect(action).toEqual('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/home'
+ });
+
+ history.goBack();
+ },
+ (location, action) => {
+ expect(action).toEqual('POP');
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.goForward();
+ },
+ (location, action) => {
+ expect(action).toEqual('POP');
+ expect(location).toMatchObject({
+ pathname: '/home'
+ });
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/HashChangeTransitionHook.js b/history/modules/__tests__/TestSequences/HashChangeTransitionHook.js
new file mode 100644
index 0000000..73fdeb9
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/HashChangeTransitionHook.js
@@ -0,0 +1,33 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ let unblock,
+ hookWasCalled = false;
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ unblock = history.block(() => {
+ hookWasCalled = true;
+ });
+
+ window.location.hash = 'something-new';
+ },
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/',
+ hash: '#something-new'
+ });
+
+ expect(hookWasCalled).toBe(true);
+
+ unblock();
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/HashbangHashPathCoding.js b/history/modules/__tests__/TestSequences/HashbangHashPathCoding.js
new file mode 100644
index 0000000..3380797
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/HashbangHashPathCoding.js
@@ -0,0 +1,48 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ expect(window.location.hash).toBe('#!/');
+
+ history.push('/home?the=query#the-hash');
+ },
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/home',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+
+ expect(window.location.hash).toBe('#!/home?the=query#the-hash');
+
+ history.goBack();
+ },
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ expect(window.location.hash).toBe('#!/');
+
+ history.goForward();
+ },
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/home',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+
+ expect(window.location.hash).toBe('#!/home?the=query#the-hash');
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/InitialLocationHasKey.js b/history/modules/__tests__/TestSequences/InitialLocationHasKey.js
new file mode 100644
index 0000000..e9a22b6
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/InitialLocationHasKey.js
@@ -0,0 +1,13 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location.key).toBeTruthy();
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/InitialLocationNoKey.js b/history/modules/__tests__/TestSequences/InitialLocationNoKey.js
new file mode 100644
index 0000000..8c79d34
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/InitialLocationNoKey.js
@@ -0,0 +1,13 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location.key).toBeFalsy();
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/Listen.js b/history/modules/__tests__/TestSequences/Listen.js
new file mode 100644
index 0000000..84393eb
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/Listen.js
@@ -0,0 +1,12 @@
+import expect from 'expect';
+import mock from 'jest-mock';
+
+export default function(history, done) {
+ const spy = mock.fn();
+ const unlisten = history.listen(spy);
+
+ expect(spy).not.toHaveBeenCalled();
+
+ unlisten();
+ done();
+}
diff --git a/history/modules/__tests__/TestSequences/LocationPathnameAlwaysSame.js b/history/modules/__tests__/TestSequences/LocationPathnameAlwaysSame.js
new file mode 100644
index 0000000..48facbc
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/LocationPathnameAlwaysSame.js
@@ -0,0 +1,44 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ () => {
+ // encoded string
+ const pathname = '/%E6%AD%B4%E5%8F%B2';
+ history.replace(pathname);
+ },
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/歴史'
+ });
+ // encoded object
+ const pathname = '/%E6%AD%B4%E5%8F%B2';
+ history.replace({ pathname });
+ },
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/歴史'
+ });
+ // unencoded string
+ const pathname = '/歴史';
+ history.replace(pathname);
+ },
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/歴史'
+ });
+ // unencoded object
+ const pathname = '/歴史';
+ history.replace({ pathname });
+ },
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/歴史'
+ });
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/NoslashHashPathCoding.js b/history/modules/__tests__/TestSequences/NoslashHashPathCoding.js
new file mode 100644
index 0000000..e6df14f
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/NoslashHashPathCoding.js
@@ -0,0 +1,50 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ // IE 10+ gives us "#", everyone else gives us ""
+ expect(window.location.hash).toMatch(/^#?$/);
+
+ history.push('/home?the=query#the-hash');
+ },
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/home',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+
+ expect(window.location.hash).toBe('#home?the=query#the-hash');
+
+ history.goBack();
+ },
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ // IE 10+ gives us "#", everyone else gives us ""
+ expect(window.location.hash).toMatch(/^#?$/);
+
+ history.goForward();
+ },
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/home',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+
+ expect(window.location.hash).toBe('#home?the=query#the-hash');
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/PushEncodedLocation.js b/history/modules/__tests__/TestSequences/PushEncodedLocation.js
new file mode 100644
index 0000000..f71d671
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/PushEncodedLocation.js
@@ -0,0 +1,28 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ const pathname = '/歴史';
+ const search = '?%E3%82%AD%E3%83%BC=%E5%80%A4';
+ const hash = '#%E3%83%8F%E3%83%83%E3%82%B7%E3%83%A5';
+ history.push(pathname + search + hash);
+ },
+ (location, action) => {
+ expect(action).toBe('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/歴史',
+ search: '?%E3%82%AD%E3%83%BC=%E5%80%A4',
+ hash: '#%E3%83%8F%E3%83%83%E3%82%B7%E3%83%A5'
+ });
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/PushInvalidPathname.js b/history/modules/__tests__/TestSequences/PushInvalidPathname.js
new file mode 100644
index 0000000..5024eeb
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/PushInvalidPathname.js
@@ -0,0 +1,17 @@
+import expect from 'expect';
+
+import execSteps from './execSteps';
+
+export default function(history, done) {
+ const steps = [
+ () => {
+ expect(() => {
+ history.push('/hello%');
+ }).toThrow(
+ 'Pathname "/hello%" could not be decoded. This is likely caused by an invalid percent-encoding.'
+ );
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/PushMissingPathname.js b/history/modules/__tests__/TestSequences/PushMissingPathname.js
new file mode 100644
index 0000000..28e7da7
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/PushMissingPathname.js
@@ -0,0 +1,35 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.push('/home?the=query#the-hash');
+ },
+ (location, action) => {
+ expect(action).toBe('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/home',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+
+ history.push('?another=query#another-hash');
+ },
+ (location, action) => {
+ expect(action).toBe('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/home',
+ search: '?another=query',
+ hash: '#another-hash'
+ });
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/PushNewLocation.js b/history/modules/__tests__/TestSequences/PushNewLocation.js
new file mode 100644
index 0000000..073f630
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/PushNewLocation.js
@@ -0,0 +1,25 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.push('/home?the=query#the-hash');
+ },
+ (location, action) => {
+ expect(action).toBe('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/home',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/PushRelativePathname.js b/history/modules/__tests__/TestSequences/PushRelativePathname.js
new file mode 100644
index 0000000..418bf37
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/PushRelativePathname.js
@@ -0,0 +1,35 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.push('/the/path?the=query#the-hash');
+ },
+ (location, action) => {
+ expect(action).toBe('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/the/path',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+
+ history.push('../other/path?another=query#another-hash');
+ },
+ (location, action) => {
+ expect(action).toBe('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/other/path',
+ search: '?another=query',
+ hash: '#another-hash'
+ });
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/PushSamePath.js b/history/modules/__tests__/TestSequences/PushSamePath.js
new file mode 100644
index 0000000..07cdc7e
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/PushSamePath.js
@@ -0,0 +1,39 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.push('/home');
+ },
+ (location, action) => {
+ expect(action).toBe('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/home'
+ });
+
+ history.push('/home');
+ },
+ (location, action) => {
+ expect(action).toBe('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/home'
+ });
+
+ history.goBack();
+ },
+ (location, action) => {
+ expect(action).toBe('POP');
+ expect(location).toMatchObject({
+ pathname: '/home'
+ });
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/PushSamePathWarning.js b/history/modules/__tests__/TestSequences/PushSamePathWarning.js
new file mode 100644
index 0000000..df144ef
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/PushSamePathWarning.js
@@ -0,0 +1,55 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ let prevLocation;
+
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.push('/home');
+ },
+ (location, action) => {
+ expect(action).toBe('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/home'
+ });
+
+ prevLocation = location;
+
+ history.push('/home');
+ },
+ (location, action) => {
+ expect(action).toBe('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/home'
+ });
+
+ // We should get the SAME location object. Nothing
+ // new was added to the history stack.
+ expect(location).toBe(prevLocation);
+
+ // We should see a warning message.
+ expect(warningMessage).toMatch(
+ 'Hash history cannot PUSH the same path; a new entry will not be added to the history stack'
+ );
+ }
+ ];
+
+ let consoleWarn = console.error; // eslint-disable-line no-console
+ let warningMessage;
+
+ // eslint-disable-next-line no-console
+ console.warn = message => {
+ warningMessage = message;
+ };
+
+ execSteps(steps, history, (...args) => {
+ console.warn = consoleWarn; // eslint-disable-line no-console
+ done(...args);
+ });
+}
diff --git a/history/modules/__tests__/TestSequences/PushState.js b/history/modules/__tests__/TestSequences/PushState.js
new file mode 100644
index 0000000..b22813b
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/PushState.js
@@ -0,0 +1,26 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.push('/home?the=query#the-hash', { the: 'state' });
+ },
+ (location, action) => {
+ expect(action).toBe('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/home',
+ search: '?the=query',
+ hash: '#the-hash',
+ state: { the: 'state' }
+ });
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/PushStateWarning.js b/history/modules/__tests__/TestSequences/PushStateWarning.js
new file mode 100644
index 0000000..d19fdab
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/PushStateWarning.js
@@ -0,0 +1,40 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.push('/home', { the: 'state' });
+ },
+ (location, action) => {
+ expect(action).toBe('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/home',
+ state: undefined
+ });
+
+ // We should see a warning message.
+ expect(warningMessage).toMatch(
+ 'Hash history cannot push state; it is ignored'
+ );
+ }
+ ];
+
+ let consoleWarn = console.warn; // eslint-disable-line no-console
+ let warningMessage;
+
+ // eslint-disable-next-line no-console
+ console.warn = message => {
+ warningMessage = message;
+ };
+
+ execSteps(steps, history, (...args) => {
+ console.warn = consoleWarn; // eslint-disable-line no-console
+ done(...args);
+ });
+}
diff --git a/history/modules/__tests__/TestSequences/PushUnicodeLocation.js b/history/modules/__tests__/TestSequences/PushUnicodeLocation.js
new file mode 100644
index 0000000..2bad464
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/PushUnicodeLocation.js
@@ -0,0 +1,28 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ const pathname = '/歴史';
+ const search = '?キー=値';
+ const hash = '#ハッシュ';
+ history.push(pathname + search + hash);
+ },
+ (location, action) => {
+ expect(action).toBe('PUSH');
+ expect(location).toMatchObject({
+ pathname: '/歴史',
+ search: '?キー=値',
+ hash: '#ハッシュ'
+ });
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/ReplaceInvalidPathname.js b/history/modules/__tests__/TestSequences/ReplaceInvalidPathname.js
new file mode 100644
index 0000000..5b85431
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/ReplaceInvalidPathname.js
@@ -0,0 +1,17 @@
+import expect from 'expect';
+
+import execSteps from './execSteps';
+
+export default function(history, done) {
+ const steps = [
+ () => {
+ expect(() => {
+ history.replace('/hello%');
+ }).toThrow(
+ 'Pathname "/hello%" could not be decoded. This is likely caused by an invalid percent-encoding.'
+ );
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/ReplaceNewLocation.js b/history/modules/__tests__/TestSequences/ReplaceNewLocation.js
new file mode 100644
index 0000000..7fb49c8
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/ReplaceNewLocation.js
@@ -0,0 +1,25 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.replace('/home?the=query#the-hash');
+ },
+ (location, action) => {
+ expect(action).toBe('REPLACE');
+ expect(location).toMatchObject({
+ pathname: '/home',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/ReplaceSamePath.js b/history/modules/__tests__/TestSequences/ReplaceSamePath.js
new file mode 100644
index 0000000..2dd7ce3
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/ReplaceSamePath.js
@@ -0,0 +1,37 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ let prevLocation;
+
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.replace('/home');
+ },
+ (location, action) => {
+ expect(action).toBe('REPLACE');
+ expect(location).toMatchObject({
+ pathname: '/home'
+ });
+
+ prevLocation = location;
+
+ history.replace('/home');
+ },
+ (location, action) => {
+ expect(action).toBe('REPLACE');
+ expect(location).toMatchObject({
+ pathname: '/home'
+ });
+
+ expect(location).not.toBe(prevLocation);
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/ReplaceState.js b/history/modules/__tests__/TestSequences/ReplaceState.js
new file mode 100644
index 0000000..7f425d7
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/ReplaceState.js
@@ -0,0 +1,26 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.replace('/home?the=query#the-hash', { the: 'state' });
+ },
+ (location, action) => {
+ expect(action).toBe('REPLACE');
+ expect(location).toMatchObject({
+ pathname: '/home',
+ search: '?the=query',
+ hash: '#the-hash',
+ state: { the: 'state' }
+ });
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/ReplaceStateWarning.js b/history/modules/__tests__/TestSequences/ReplaceStateWarning.js
new file mode 100644
index 0000000..a2804ab
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/ReplaceStateWarning.js
@@ -0,0 +1,40 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.replace('/home', { the: 'state' });
+ },
+ (location, action) => {
+ expect(action).toBe('REPLACE');
+ expect(location).toMatchObject({
+ pathname: '/home',
+ state: undefined
+ });
+
+ // We should see a warning message.
+ expect(warningMessage).toMatch(
+ 'Hash history cannot replace state; it is ignored'
+ );
+ }
+ ];
+
+ let consoleWarn = console.warn; // eslint-disable-line no-console
+ let warningMessage;
+
+ // eslint-disable-next-line no-console
+ console.warn = message => {
+ warningMessage = message;
+ };
+
+ execSteps(steps, history, (...args) => {
+ console.warn = consoleWarn; // eslint-disable-line no-console
+ done(...args);
+ });
+}
diff --git a/history/modules/__tests__/TestSequences/ReturnFalseTransitionHook.js b/history/modules/__tests__/TestSequences/ReturnFalseTransitionHook.js
new file mode 100644
index 0000000..d3894fb
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/ReturnFalseTransitionHook.js
@@ -0,0 +1,32 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ const unblock = history.block(nextLocation => {
+ expect(nextLocation).toMatchObject({
+ pathname: '/home'
+ });
+
+ // Cancel the transition.
+ return false;
+ });
+
+ history.push('/home');
+
+ expect(history.location).toMatchObject({
+ pathname: '/'
+ });
+
+ unblock();
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/SlashHashPathCoding.js b/history/modules/__tests__/TestSequences/SlashHashPathCoding.js
new file mode 100644
index 0000000..6911d21
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/SlashHashPathCoding.js
@@ -0,0 +1,48 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ expect(window.location.hash).toBe('#/');
+
+ history.push('/home?the=query#the-hash');
+ },
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/home',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+
+ expect(window.location.hash).toBe('#/home?the=query#the-hash');
+
+ history.goBack();
+ },
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ expect(window.location.hash).toBe('#/');
+
+ history.goForward();
+ },
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/home',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+
+ expect(window.location.hash).toBe('#/home?the=query#the-hash');
+ }
+ ];
+
+ execSteps(steps, history, done);
+}
diff --git a/history/modules/__tests__/TestSequences/TransitionHookArgs.js b/history/modules/__tests__/TestSequences/TransitionHookArgs.js
new file mode 100644
index 0000000..bc79f02
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/TransitionHookArgs.js
@@ -0,0 +1,32 @@
+import expect from 'expect';
+
+import execSteps from './execSteps.js';
+
+export default function(history, done) {
+ let hookLocation, hookAction;
+ const steps = [
+ location => {
+ expect(location).toMatchObject({
+ pathname: '/'
+ });
+
+ history.push('/home');
+ },
+ (location, action) => {
+ expect(hookAction).toBe(action);
+ expect(hookLocation).toBe(location);
+ }
+ ];
+
+ const unblock = history.block((location, action) => {
+ hookLocation = location;
+ hookAction = action;
+
+ return 'Are you sure?';
+ });
+
+ execSteps(steps, history, (...args) => {
+ unblock();
+ done(...args);
+ });
+}
diff --git a/history/modules/__tests__/TestSequences/execSteps.js b/history/modules/__tests__/TestSequences/execSteps.js
new file mode 100644
index 0000000..264560a
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/execSteps.js
@@ -0,0 +1,34 @@
+export default function execSteps(steps, history, done) {
+ let index = 0,
+ unlisten,
+ cleanedUp = false;
+
+ const cleanup = (...args) => {
+ if (!cleanedUp) {
+ cleanedUp = true;
+ unlisten();
+ done(...args);
+ }
+ };
+
+ const execNextStep = (...args) => {
+ try {
+ const nextStep = steps[index++];
+
+ if (!nextStep) throw new Error('Test is missing step ' + index);
+
+ nextStep(...args);
+
+ if (index === steps.length) cleanup();
+ } catch (error) {
+ cleanup(error);
+ }
+ };
+
+ if (steps.length) {
+ unlisten = history.listen(execNextStep);
+ execNextStep(history.location);
+ } else {
+ done();
+ }
+}
diff --git a/history/modules/__tests__/TestSequences/index.js b/history/modules/__tests__/TestSequences/index.js
new file mode 100644
index 0000000..e48e6e7
--- /dev/null
+++ b/history/modules/__tests__/TestSequences/index.js
@@ -0,0 +1,46 @@
+export {
+ default as BackButtonTransitionHook
+} from './BackButtonTransitionHook.js';
+export { default as BlockEverything } from './BlockEverything.js';
+export {
+ default as BlockPopWithoutListening
+} from './BlockPopWithoutListening.js';
+export { default as DenyPush } from './DenyPush.js';
+export { default as DenyGoBack } from './DenyGoBack.js';
+export { default as DenyGoForward } from './DenyGoForward.js';
+export {
+ default as EncodedReservedCharacters
+} from './EncodedReservedCharacters.js';
+export { default as GoBack } from './GoBack.js';
+export { default as GoForward } from './GoForward.js';
+export { default as HashbangHashPathCoding } from './HashbangHashPathCoding.js';
+export {
+ default as HashChangeTransitionHook
+} from './HashChangeTransitionHook.js';
+export { default as InitialLocationNoKey } from './InitialLocationNoKey.js';
+export { default as InitialLocationHasKey } from './InitialLocationHasKey.js';
+export { default as Listen } from './Listen.js';
+export {
+ default as LocationPathnameAlwaysSame
+} from './LocationPathnameAlwaysSame.js';
+export { default as NoslashHashPathCoding } from './NoslashHashPathCoding.js';
+export { default as PushEncodedLocation } from './PushEncodedLocation.js';
+export { default as PushInvalidPathname } from './PushInvalidPathname.js';
+export { default as PushNewLocation } from './PushNewLocation.js';
+export { default as PushMissingPathname } from './PushMissingPathname.js';
+export { default as PushSamePath } from './PushSamePath.js';
+export { default as PushSamePathWarning } from './PushSamePathWarning.js';
+export { default as PushState } from './PushState.js';
+export { default as PushStateWarning } from './PushStateWarning.js';
+export { default as PushRelativePathname } from './PushRelativePathname.js';
+export { default as PushUnicodeLocation } from './PushUnicodeLocation.js';
+export { default as ReplaceInvalidPathname } from './ReplaceInvalidPathname.js';
+export { default as ReplaceNewLocation } from './ReplaceNewLocation.js';
+export { default as ReplaceSamePath } from './ReplaceSamePath.js';
+export { default as ReplaceState } from './ReplaceState.js';
+export { default as ReplaceStateWarning } from './ReplaceStateWarning.js';
+export {
+ default as ReturnFalseTransitionHook
+} from './ReturnFalseTransitionHook.js';
+export { default as SlashHashPathCoding } from './SlashHashPathCoding.js';
+export { default as TransitionHookArgs } from './TransitionHookArgs.js';
diff --git a/history/modules/__tests__/createLocation-test.js b/history/modules/__tests__/createLocation-test.js
new file mode 100644
index 0000000..27e0842
--- /dev/null
+++ b/history/modules/__tests__/createLocation-test.js
@@ -0,0 +1,144 @@
+import expect from 'expect';
+import { createLocation } from 'history';
+
+describe('createLocation', () => {
+ describe('with a full path', () => {
+ describe('given as a string', () => {
+ it('has the correct properties', () => {
+ expect(createLocation('/the/path?the=query#the-hash')).toMatchObject({
+ pathname: '/the/path',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+ });
+ });
+
+ describe('given as an object', () => {
+ it('has the correct properties', () => {
+ expect(
+ createLocation({
+ pathname: '/the/path',
+ search: '?the=query',
+ hash: '#the-hash'
+ })
+ ).toMatchObject({
+ pathname: '/the/path',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+ });
+ });
+ });
+
+ describe('with a relative path', () => {
+ describe('given as a string', () => {
+ it('has the correct properties', () => {
+ expect(createLocation('the/path?the=query#the-hash')).toMatchObject({
+ pathname: 'the/path',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+ });
+ });
+
+ describe('given as an object', () => {
+ it('has the correct properties', () => {
+ expect(
+ createLocation({
+ pathname: 'the/path',
+ search: '?the=query',
+ hash: '#the-hash'
+ })
+ ).toMatchObject({
+ pathname: 'the/path',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+ });
+ });
+ });
+
+ describe('with a path with no pathname', () => {
+ describe('given as a string', () => {
+ it('has the correct properties', () => {
+ expect(createLocation('?the=query#the-hash')).toMatchObject({
+ pathname: '/',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+ });
+ });
+
+ describe('given as an object', () => {
+ it('has the correct properties', () => {
+ expect(
+ createLocation({ search: '?the=query', hash: '#the-hash' })
+ ).toMatchObject({
+ pathname: '/',
+ search: '?the=query',
+ hash: '#the-hash'
+ });
+ });
+ });
+ });
+
+ describe('with a path with no search', () => {
+ describe('given as a string', () => {
+ it('has the correct properties', () => {
+ expect(createLocation('/the/path#the-hash')).toMatchObject({
+ pathname: '/the/path',
+ search: '',
+ hash: '#the-hash'
+ });
+ });
+ });
+
+ describe('given as an object', () => {
+ it('has the correct properties', () => {
+ expect(
+ createLocation({ pathname: '/the/path', hash: '#the-hash' })
+ ).toMatchObject({
+ pathname: '/the/path',
+ search: '',
+ hash: '#the-hash'
+ });
+ });
+ });
+ });
+
+ describe('with a path with no hash', () => {
+ describe('given as a string', () => {
+ it('has the correct properties', () => {
+ expect(createLocation('/the/path?the=query')).toMatchObject({
+ pathname: '/the/path',
+ search: '?the=query',
+ hash: ''
+ });
+ });
+ });
+
+ describe('given as an object', () => {
+ it('has the correct properties', () => {
+ expect(
+ createLocation({ pathname: '/the/path', search: '?the=query' })
+ ).toMatchObject({
+ pathname: '/the/path',
+ search: '?the=query',
+ hash: ''
+ });
+ });
+ });
+ });
+
+ describe('key', () => {
+ it('has a key property if a key is provided', () => {
+ const location = createLocation('/the/path', undefined, 'key');
+ expect(Object.keys(location)).toContain('key');
+ });
+
+ it('has no key property if no key is provided', () => {
+ const location = createLocation('/the/path');
+ expect(Object.keys(location)).not.toContain('key');
+ });
+ });
+});
diff --git a/history/modules/createBrowserHistory.js b/history/modules/createBrowserHistory.js
new file mode 100644
index 0000000..dd1983d
--- /dev/null
+++ b/history/modules/createBrowserHistory.js
@@ -0,0 +1,329 @@
+import { createLocation } from './LocationUtils.js';
+import {
+ addLeadingSlash,
+ stripTrailingSlash,
+ hasBasename,
+ stripBasename,
+ createPath
+} from './PathUtils.js';
+import createTransitionManager from './createTransitionManager.js';
+import {
+ canUseDOM,
+ getConfirmation,
+ supportsHistory,
+ supportsPopStateOnHashChange,
+ isExtraneousPopstateEvent
+} from './DOMUtils.js';
+import invariant from './invariant.js';
+import warning from './warning.js';
+
+const PopStateEvent = 'popstate';
+const HashChangeEvent = 'hashchange';
+
+function getHistoryState() {
+ try {
+ return window.history.state || {};
+ } catch (e) {
+ // IE 11 sometimes throws when accessing window.history.state
+ // See https://github.com/ReactTraining/history/pull/289
+ return {};
+ }
+}
+
+/**
+ * Creates a history object that uses the HTML5 history API including
+ * pushState, replaceState, and the popstate event.
+ */
+function createBrowserHistory(props = {}) {
+ invariant(canUseDOM, 'Browser history needs a DOM');
+
+ const globalHistory = window.history;
+ const canUseHistory = supportsHistory();
+ const needsHashChangeListener = !supportsPopStateOnHashChange();
+
+ const {
+ forceRefresh = false,
+ getUserConfirmation = getConfirmation,
+ keyLength = 6
+ } = props;
+ const basename = props.basename
+ ? stripTrailingSlash(addLeadingSlash(props.basename))
+ : '';
+
+ function getDOMLocation(historyState) {
+ const { key, state } = historyState || {};
+ const { pathname, search, hash } = window.location;
+
+ let path = pathname + search + hash;
+
+ warning(
+ !basename || hasBasename(path, basename),
+ 'You are attempting to use a basename on a page whose URL path does not begin ' +
+ 'with the basename. Expected path "' +
+ path +
+ '" to begin with "' +
+ basename +
+ '".'
+ );
+
+ if (basename) path = stripBasename(path, basename);
+
+ return createLocation(path, state, key);
+ }
+
+ function createKey() {
+ return Math.random()
+ .toString(36)
+ .substr(2, keyLength);
+ }
+
+ const transitionManager = createTransitionManager();
+
+ function setState(nextState) {
+ Object.assign(history, nextState);
+ history.length = globalHistory.length;
+ transitionManager.notifyListeners(history.location, history.action);
+ }
+
+ function handlePopState(event) {
+ // Ignore extraneous popstate events in WebKit.
+ if (isExtraneousPopstateEvent(event)) return;
+ handlePop(getDOMLocation(event.state));
+ }
+
+ function handleHashChange() {
+ handlePop(getDOMLocation(getHistoryState()));
+ }
+
+ let forceNextPop = false;
+
+ function handlePop(location) {
+ if (forceNextPop) {
+ forceNextPop = false;
+ setState();
+ } else {
+ const action = 'POP';
+
+ transitionManager.confirmTransitionTo(
+ location,
+ action,
+ getUserConfirmation,
+ ok => {
+ if (ok) {
+ setState({ action, location });
+ } else {
+ revertPop(location);
+ }
+ }
+ );
+ }
+ }
+
+ function revertPop(fromLocation) {
+ const toLocation = history.location;
+
+ // TODO: We could probably make this more reliable by
+ // keeping a list of keys we've seen in sessionStorage.
+ // Instead, we just default to 0 for keys we don't know.
+
+ let toIndex = allKeys.indexOf(toLocation.key);
+
+ if (toIndex === -1) toIndex = 0;
+
+ let fromIndex = allKeys.indexOf(fromLocation.key);
+
+ if (fromIndex === -1) fromIndex = 0;
+
+ const delta = toIndex - fromIndex;
+
+ if (delta) {
+ forceNextPop = true;
+ go(delta);
+ }
+ }
+
+ const initialLocation = getDOMLocation(getHistoryState());
+ let allKeys = [initialLocation.key];
+
+ // Public interface
+
+ function createHref(location) {
+ return basename + createPath(location);
+ }
+
+ function push(path, state) {
+ warning(
+ !(
+ typeof path === 'object' &&
+ path.state !== undefined &&
+ state !== undefined
+ ),
+ 'You should avoid providing a 2nd state argument to push when the 1st ' +
+ 'argument is a location-like object that already has state; it is ignored'
+ );
+
+ const action = 'PUSH';
+ const location = createLocation(path, state, createKey(), history.location);
+
+ transitionManager.confirmTransitionTo(
+ location,
+ action,
+ getUserConfirmation,
+ ok => {
+ if (!ok) return;
+
+ const href = createHref(location);
+ const { key, state } = location;
+
+ if (canUseHistory) {
+ globalHistory.pushState({ key, state }, null, href);
+
+ if (forceRefresh) {
+ window.location.href = href;
+ } else {
+ const prevIndex = allKeys.indexOf(history.location.key);
+ const nextKeys = allKeys.slice(0, prevIndex + 1);
+
+ nextKeys.push(location.key);
+ allKeys = nextKeys;
+
+ setState({ action, location });
+ }
+ } else {
+ warning(
+ state === undefined,
+ 'Browser history cannot push state in browsers that do not support HTML5 history'
+ );
+
+ window.location.href = href;
+ }
+ }
+ );
+ }
+
+ function replace(path, state) {
+ warning(
+ !(
+ typeof path === 'object' &&
+ path.state !== undefined &&
+ state !== undefined
+ ),
+ 'You should avoid providing a 2nd state argument to replace when the 1st ' +
+ 'argument is a location-like object that already has state; it is ignored'
+ );
+
+ const action = 'REPLACE';
+ const location = createLocation(path, state, createKey(), history.location);
+
+ transitionManager.confirmTransitionTo(
+ location,
+ action,
+ getUserConfirmation,
+ ok => {
+ if (!ok) return;
+
+ const href = createHref(location);
+ const { key, state } = location;
+
+ if (canUseHistory) {
+ globalHistory.replaceState({ key, state }, null, href);
+
+ if (forceRefresh) {
+ window.location.replace(href);
+ } else {
+ const prevIndex = allKeys.indexOf(history.location.key);
+
+ if (prevIndex !== -1) allKeys[prevIndex] = location.key;
+
+ setState({ action, location });
+ }
+ } else {
+ warning(
+ state === undefined,
+ 'Browser history cannot replace state in browsers that do not support HTML5 history'
+ );
+
+ window.location.replace(href);
+ }
+ }
+ );
+ }
+
+ function go(n) {
+ globalHistory.go(n);
+ }
+
+ function goBack() {
+ go(-1);
+ }
+
+ function goForward() {
+ go(1);
+ }
+
+ let listenerCount = 0;
+
+ function checkDOMListeners(delta) {
+ listenerCount += delta;
+
+ if (listenerCount === 1 && delta === 1) {
+ window.addEventListener(PopStateEvent, handlePopState);
+
+ if (needsHashChangeListener)
+ window.addEventListener(HashChangeEvent, handleHashChange);
+ } else if (listenerCount === 0) {
+ window.removeEventListener(PopStateEvent, handlePopState);
+
+ if (needsHashChangeListener)
+ window.removeEventListener(HashChangeEvent, handleHashChange);
+ }
+ }
+
+ let isBlocked = false;
+
+ function block(prompt = false) {
+ const unblock = transitionManager.setPrompt(prompt);
+
+ if (!isBlocked) {
+ checkDOMListeners(1);
+ isBlocked = true;
+ }
+
+ return () => {
+ if (isBlocked) {
+ isBlocked = false;
+ checkDOMListeners(-1);
+ }
+
+ return unblock();
+ };
+ }
+
+ function listen(listener) {
+ const unlisten = transitionManager.appendListener(listener);
+ checkDOMListeners(1);
+
+ return () => {
+ checkDOMListeners(-1);
+ unlisten();
+ };
+ }
+
+ const history = {
+ length: globalHistory.length,
+ action: 'POP',
+ location: initialLocation,
+ createHref,
+ push,
+ replace,
+ go,
+ goBack,
+ goForward,
+ block,
+ listen
+ };
+
+ return history;
+}
+
+export default createBrowserHistory;
diff --git a/history/modules/createHashHistory.js b/history/modules/createHashHistory.js
new file mode 100644
index 0000000..6c29725
--- /dev/null
+++ b/history/modules/createHashHistory.js
@@ -0,0 +1,361 @@
+import { createLocation } from './LocationUtils.js';
+import {
+ addLeadingSlash,
+ stripLeadingSlash,
+ stripTrailingSlash,
+ hasBasename,
+ stripBasename,
+ createPath
+} from './PathUtils.js';
+import createTransitionManager from './createTransitionManager.js';
+import {
+ canUseDOM,
+ getConfirmation,
+ supportsGoWithoutReloadUsingHash
+} from './DOMUtils.js';
+import invariant from './invariant.js';
+import warning from './warning.js';
+
+const HashChangeEvent = 'hashchange';
+
+const HashPathCoders = {
+ hashbang: {
+ encodePath: path =>
+ path.charAt(0) === '!' ? path : '!/' + stripLeadingSlash(path),
+ decodePath: path => (path.charAt(0) === '!' ? path.substr(1) : path)
+ },
+ noslash: {
+ encodePath: stripLeadingSlash,
+ decodePath: addLeadingSlash
+ },
+ slash: {
+ encodePath: addLeadingSlash,
+ decodePath: addLeadingSlash
+ }
+};
+
+function stripHash(url) {
+ const hashIndex = url.indexOf('#');
+ return hashIndex === -1 ? url : url.slice(0, hashIndex);
+}
+
+function getHashPath() {
+ // We can't use window.location.hash here because it's not
+ // consistent across browsers - Firefox will pre-decode it!
+ const href = window.location.href;
+ const hashIndex = href.indexOf('#');
+ return hashIndex === -1 ? '' : href.substring(hashIndex + 1);
+}
+
+function pushHashPath(path) {
+ window.location.hash = path;
+}
+
+function replaceHashPath(path) {
+ window.location.replace(stripHash(window.location.href) + '#' + path);
+}
+
+function createHashHistory(props = {}) {
+ invariant(canUseDOM, 'Hash history needs a DOM');
+
+ const globalHistory = window.history;
+ const canGoWithoutReload = supportsGoWithoutReloadUsingHash();
+
+ const { getUserConfirmation = getConfirmation, hashType = 'slash' } = props;
+ const basename = props.basename
+ ? stripTrailingSlash(addLeadingSlash(props.basename))
+ : '';
+
+ const { encodePath, decodePath } = HashPathCoders[hashType];
+
+ function getDOMLocation() {
+ let path = decodePath(getHashPath());
+
+ warning(
+ !basename || hasBasename(path, basename),
+ 'You are attempting to use a basename on a page whose URL path does not begin ' +
+ 'with the basename. Expected path "' +
+ path +
+ '" to begin with "' +
+ basename +
+ '".'
+ );
+
+ if (basename) path = stripBasename(path, basename);
+
+ return createLocation(path);
+ }
+
+ const transitionManager = createTransitionManager();
+
+ function setState(nextState) {
+ Object.assign(history, nextState);
+ history.length = globalHistory.length;
+ transitionManager.notifyListeners(history.location, history.action);
+ }
+
+ let forceNextPop = false;
+ let ignorePath = null;
+
+ function locationsAreEqual(a, b) {
+ return (
+ a.pathname === b.pathname && a.search === b.search && a.hash === b.hash
+ );
+ }
+
+ function handleHashChange() {
+ const path = getHashPath();
+ const encodedPath = encodePath(path);
+
+ if (path !== encodedPath) {
+ // Ensure we always have a properly-encoded hash.
+ replaceHashPath(encodedPath);
+ } else {
+ const location = getDOMLocation();
+ const prevLocation = history.location;
+
+ if (!forceNextPop && locationsAreEqual(prevLocation, location)) return; // A hashchange doesn't always == location change.
+
+ if (ignorePath === createPath(location)) return; // Ignore this change; we already setState in push/replace.
+
+ ignorePath = null;
+
+ handlePop(location);
+ }
+ }
+
+ function handlePop(location) {
+ if (forceNextPop) {
+ forceNextPop = false;
+ setState();
+ } else {
+ const action = 'POP';
+
+ transitionManager.confirmTransitionTo(
+ location,
+ action,
+ getUserConfirmation,
+ ok => {
+ if (ok) {
+ setState({ action, location });
+ } else {
+ revertPop(location);
+ }
+ }
+ );
+ }
+ }
+
+ function revertPop(fromLocation) {
+ const toLocation = history.location;
+
+ // TODO: We could probably make this more reliable by
+ // keeping a list of paths we've seen in sessionStorage.
+ // Instead, we just default to 0 for paths we don't know.
+
+ let toIndex = allPaths.lastIndexOf(createPath(toLocation));
+
+ if (toIndex === -1) toIndex = 0;
+
+ let fromIndex = allPaths.lastIndexOf(createPath(fromLocation));
+
+ if (fromIndex === -1) fromIndex = 0;
+
+ const delta = toIndex - fromIndex;
+
+ if (delta) {
+ forceNextPop = true;
+ go(delta);
+ }
+ }
+
+ // Ensure the hash is encoded properly before doing anything else.
+ const path = getHashPath();
+ const encodedPath = encodePath(path);
+
+ if (path !== encodedPath) replaceHashPath(encodedPath);
+
+ const initialLocation = getDOMLocation();
+ let allPaths = [createPath(initialLocation)];
+
+ // Public interface
+
+ function createHref(location) {
+ const baseTag = document.querySelector('base');
+ let href = '';
+ if (baseTag && baseTag.getAttribute('href')) {
+ href = stripHash(window.location.href);
+ }
+ return href + '#' + encodePath(basename + createPath(location));
+ }
+
+ function push(path, state) {
+ warning(
+ state === undefined,
+ 'Hash history cannot push state; it is ignored'
+ );
+
+ const action = 'PUSH';
+ const location = createLocation(
+ path,
+ undefined,
+ undefined,
+ history.location
+ );
+
+ transitionManager.confirmTransitionTo(
+ location,
+ action,
+ getUserConfirmation,
+ ok => {
+ if (!ok) return;
+
+ const path = createPath(location);
+ const encodedPath = encodePath(basename + path);
+ const hashChanged = getHashPath() !== encodedPath;
+
+ if (hashChanged) {
+ // We cannot tell if a hashchange was caused by a PUSH, so we'd
+ // rather setState here and ignore the hashchange. The caveat here
+ // is that other hash histories in the page will consider it a POP.
+ ignorePath = path;
+ pushHashPath(encodedPath);
+
+ const prevIndex = allPaths.lastIndexOf(createPath(history.location));
+ const nextPaths = allPaths.slice(0, prevIndex + 1);
+
+ nextPaths.push(path);
+ allPaths = nextPaths;
+
+ setState({ action, location });
+ } else {
+ warning(
+ false,
+ 'Hash history cannot PUSH the same path; a new entry will not be added to the history stack'
+ );
+
+ setState();
+ }
+ }
+ );
+ }
+
+ function replace(path, state) {
+ warning(
+ state === undefined,
+ 'Hash history cannot replace state; it is ignored'
+ );
+
+ const action = 'REPLACE';
+ const location = createLocation(
+ path,
+ undefined,
+ undefined,
+ history.location
+ );
+
+ transitionManager.confirmTransitionTo(
+ location,
+ action,
+ getUserConfirmation,
+ ok => {
+ if (!ok) return;
+
+ const path = createPath(location);
+ const encodedPath = encodePath(basename + path);
+ const hashChanged = getHashPath() !== encodedPath;
+
+ if (hashChanged) {
+ // We cannot tell if a hashchange was caused by a REPLACE, so we'd
+ // rather setState here and ignore the hashchange. The caveat here
+ // is that other hash histories in the page will consider it a POP.
+ ignorePath = path;
+ replaceHashPath(encodedPath);
+ }
+
+ const prevIndex = allPaths.indexOf(createPath(history.location));
+
+ if (prevIndex !== -1) allPaths[prevIndex] = path;
+
+ setState({ action, location });
+ }
+ );
+ }
+
+ function go(n) {
+ warning(
+ canGoWithoutReload,
+ 'Hash history go(n) causes a full page reload in this browser'
+ );
+
+ globalHistory.go(n);
+ }
+
+ function goBack() {
+ go(-1);
+ }
+
+ function goForward() {
+ go(1);
+ }
+
+ let listenerCount = 0;
+
+ function checkDOMListeners(delta) {
+ listenerCount += delta;
+
+ if (listenerCount === 1 && delta === 1) {
+ window.addEventListener(HashChangeEvent, handleHashChange);
+ } else if (listenerCount === 0) {
+ window.removeEventListener(HashChangeEvent, handleHashChange);
+ }
+ }
+
+ let isBlocked = false;
+
+ function block(prompt = false) {
+ const unblock = transitionManager.setPrompt(prompt);
+
+ if (!isBlocked) {
+ checkDOMListeners(1);
+ isBlocked = true;
+ }
+
+ return () => {
+ if (isBlocked) {
+ isBlocked = false;
+ checkDOMListeners(-1);
+ }
+
+ return unblock();
+ };
+ }
+
+ function listen(listener) {
+ const unlisten = transitionManager.appendListener(listener);
+ checkDOMListeners(1);
+
+ return () => {
+ checkDOMListeners(-1);
+ unlisten();
+ };
+ }
+
+ const history = {
+ length: globalHistory.length,
+ action: 'POP',
+ location: initialLocation,
+ createHref,
+ push,
+ replace,
+ go,
+ goBack,
+ goForward,
+ block,
+ listen
+ };
+
+ return history;
+}
+
+export default createHashHistory;
diff --git a/history/modules/createMemoryHistory.js b/history/modules/createMemoryHistory.js
new file mode 100644
index 0000000..8b541ea
--- /dev/null
+++ b/history/modules/createMemoryHistory.js
@@ -0,0 +1,186 @@
+import { createPath } from './PathUtils.js';
+import { createLocation } from './LocationUtils.js';
+import createTransitionManager from './createTransitionManager.js';
+import warning from './warning.js';
+
+function clamp(n, lowerBound, upperBound) {
+ return Math.min(Math.max(n, lowerBound), upperBound);
+}
+
+/**
+ * Creates a history object that stores locations in memory.
+ */
+function createMemoryHistory(props = {}) {
+ const {
+ getUserConfirmation,
+ initialEntries = ['/'],
+ initialIndex = 0,
+ keyLength = 6
+ } = props;
+
+ const transitionManager = createTransitionManager();
+
+ function setState(nextState) {
+ Object.assign(history, nextState);
+ history.length = history.entries.length;
+ transitionManager.notifyListeners(history.location, history.action);
+ }
+
+ function createKey() {
+ return Math.random()
+ .toString(36)
+ .substr(2, keyLength);
+ }
+
+ const index = clamp(initialIndex, 0, initialEntries.length - 1);
+ const entries = initialEntries.map(entry =>
+ typeof entry === 'string'
+ ? createLocation(entry, undefined, createKey())
+ : createLocation(entry, undefined, entry.key || createKey())
+ );
+
+ // Public interface
+
+ const createHref = createPath;
+
+ function push(path, state) {
+ warning(
+ !(
+ typeof path === 'object' &&
+ path.state !== undefined &&
+ state !== undefined
+ ),
+ 'You should avoid providing a 2nd state argument to push when the 1st ' +
+ 'argument is a location-like object that already has state; it is ignored'
+ );
+
+ const action = 'PUSH';
+ const location = createLocation(path, state, createKey(), history.location);
+
+ transitionManager.confirmTransitionTo(
+ location,
+ action,
+ getUserConfirmation,
+ ok => {
+ if (!ok) return;
+
+ const prevIndex = history.index;
+ const nextIndex = prevIndex + 1;
+
+ const nextEntries = history.entries.slice(0);
+ if (nextEntries.length > nextIndex) {
+ nextEntries.splice(
+ nextIndex,
+ nextEntries.length - nextIndex,
+ location
+ );
+ } else {
+ nextEntries.push(location);
+ }
+
+ setState({
+ action,
+ location,
+ index: nextIndex,
+ entries: nextEntries
+ });
+ }
+ );
+ }
+
+ function replace(path, state) {
+ warning(
+ !(
+ typeof path === 'object' &&
+ path.state !== undefined &&
+ state !== undefined
+ ),
+ 'You should avoid providing a 2nd state argument to replace when the 1st ' +
+ 'argument is a location-like object that already has state; it is ignored'
+ );
+
+ const action = 'REPLACE';
+ const location = createLocation(path, state, createKey(), history.location);
+
+ transitionManager.confirmTransitionTo(
+ location,
+ action,
+ getUserConfirmation,
+ ok => {
+ if (!ok) return;
+
+ history.entries[history.index] = location;
+
+ setState({ action, location });
+ }
+ );
+ }
+
+ function go(n) {
+ const nextIndex = clamp(history.index + n, 0, history.entries.length - 1);
+
+ const action = 'POP';
+ const location = history.entries[nextIndex];
+
+ transitionManager.confirmTransitionTo(
+ location,
+ action,
+ getUserConfirmation,
+ ok => {
+ if (ok) {
+ setState({
+ action,
+ location,
+ index: nextIndex
+ });
+ } else {
+ // Mimic the behavior of DOM histories by
+ // causing a render after a cancelled POP.
+ setState();
+ }
+ }
+ );
+ }
+
+ function goBack() {
+ go(-1);
+ }
+
+ function goForward() {
+ go(1);
+ }
+
+ function canGo(n) {
+ const nextIndex = history.index + n;
+ return nextIndex >= 0 && nextIndex < history.entries.length;
+ }
+
+ function block(prompt = false) {
+ return transitionManager.setPrompt(prompt);
+ }
+
+ function listen(listener) {
+ return transitionManager.appendListener(listener);
+ }
+
+ const history = {
+ length: entries.length,
+ action: 'POP',
+ location: entries[index],
+ index,
+ entries,
+ createHref,
+ push,
+ replace,
+ go,
+ goBack,
+ goForward,
+ canGo,
+ block,
+ listen
+ };
+
+ return history;
+}
+
+export default createMemoryHistory;
diff --git a/history/modules/createTransitionManager.js b/history/modules/createTransitionManager.js
new file mode 100644
index 0000000..4373250
--- /dev/null
+++ b/history/modules/createTransitionManager.js
@@ -0,0 +1,78 @@
+import warning from './warning.js';
+
+function createTransitionManager() {
+ let prompt = null;
+
+ function setPrompt(nextPrompt) {
+ warning(prompt == null, 'A history supports only one prompt at a time');
+
+ prompt = nextPrompt;
+
+ return () => {
+ if (prompt === nextPrompt) prompt = null;
+ };
+ }
+
+ function confirmTransitionTo(
+ location,
+ action,
+ getUserConfirmation,
+ callback
+ ) {
+ // TODO: If another transition starts while we're still confirming
+ // the previous one, we may end up in a weird state. Figure out the
+ // best way to handle this.
+ if (prompt != null) {
+ const result =
+ typeof prompt === 'function' ? prompt(location, action) : prompt;
+
+ if (typeof result === 'string') {
+ if (typeof getUserConfirmation === 'function') {
+ getUserConfirmation(result, callback);
+ } else {
+ warning(
+ false,
+ 'A history needs a getUserConfirmation function in order to use a prompt message'
+ );
+
+ callback(true);
+ }
+ } else {
+ // Return false from a transition hook to cancel the transition.
+ callback(result !== false);
+ }
+ } else {
+ callback(true);
+ }
+ }
+
+ let listeners = [];
+
+ function appendListener(fn) {
+ let isActive = true;
+
+ function listener(...args) {
+ if (isActive) fn(...args);
+ }
+
+ listeners.push(listener);
+
+ return () => {
+ isActive = false;
+ listeners = listeners.filter(item => item !== listener);
+ };
+ }
+
+ function notifyListeners(...args) {
+ listeners.forEach(listener => listener(...args));
+ }
+
+ return {
+ setPrompt,
+ confirmTransitionTo,
+ appendListener,
+ notifyListeners
+ };
+}
+
+export default createTransitionManager;
diff --git a/history/modules/index.js b/history/modules/index.js
new file mode 100644
index 0000000..b02317d
--- /dev/null
+++ b/history/modules/index.js
@@ -0,0 +1,5 @@
+export { default as createBrowserHistory } from './createBrowserHistory';
+export { default as createHashHistory } from './createHashHistory';
+export { default as createMemoryHistory } from './createMemoryHistory';
+export { createLocation, locationsAreEqual } from './LocationUtils';
+export { parsePath, createPath } from './PathUtils';
diff --git a/history/modules/invariant.js b/history/modules/invariant.js
new file mode 100644
index 0000000..98b4860
--- /dev/null
+++ b/history/modules/invariant.js
@@ -0,0 +1 @@
+export { default } from 'tiny-invariant';
diff --git a/history/modules/warning.js b/history/modules/warning.js
new file mode 100644
index 0000000..510d489
--- /dev/null
+++ b/history/modules/warning.js
@@ -0,0 +1 @@
+export { default } from 'tiny-warning';