diff options
author | Sebastian <sebasjm@gmail.com> | 2021-08-23 16:46:06 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2021-08-23 16:48:30 -0300 |
commit | 38acabfa6089ab8ac469c12b5f55022fb96935e5 (patch) | |
tree | 453dbf70000cc5e338b06201af1eaca8343f8f73 /history/modules | |
parent | f26125e039143b92dc0d84e7775f508ab0cdcaa8 (diff) | |
download | node-vendor-38acabfa6089ab8ac469c12b5f55022fb96935e5.tar.gz node-vendor-38acabfa6089ab8ac469c12b5f55022fb96935e5.tar.bz2 node-vendor-38acabfa6089ab8ac469c12b5f55022fb96935e5.zip |
Diffstat (limited to 'history/modules')
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'; |