From 11cdab390c124a28a57d76c94d8286b7c7a2b7a3 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Mon, 8 Jun 2015 16:38:45 -0700 Subject: [PATCH] Add location.state --- modules/BrowserHistory.js | 60 +++++++++++++------------ modules/ChangeEmitter.js | 24 ++++++++++ modules/DOMHistory.js | 24 +--------- modules/DOMUtils.js | 27 +++++------ modules/HashHistory.js | 56 +++++++++++++++++------ modules/History.js | 40 ++++++++--------- modules/Location.js | 11 +++-- modules/MemoryHistory.js | 55 +++++++++++++++++++---- modules/Router.js | 35 +++++++-------- modules/__tests__/MemoryHistory-test.js | 4 +- modules/__tests__/describeHistory.js | 14 +++--- 11 files changed, 208 insertions(+), 142 deletions(-) create mode 100644 modules/ChangeEmitter.js diff --git a/modules/BrowserHistory.js b/modules/BrowserHistory.js index 7d13eaff55..5965108590 100644 --- a/modules/BrowserHistory.js +++ b/modules/BrowserHistory.js @@ -1,10 +1,7 @@ import DOMHistory from './DOMHistory'; -import { getWindowPath, supportsHistory } from './DOMUtils'; +import { getWindowPath, getWindowScrollPosition, supportsHistory } from './DOMUtils'; import NavigationTypes from './NavigationTypes'; - -function createRandomKey() { - return Math.random().toString(36).substr(2); -} +import Location from './Location'; /** * A history implementation for DOM environments that support the @@ -18,26 +15,31 @@ function createRandomKey() { */ export class BrowserHistory extends DOMHistory { - constructor(getScrollPosition) { - super(getScrollPosition); + constructor(getScrollPosition=getWindowScrollPosition) { + super(); + this.getScrollPosition = getScrollPosition; this.handlePopState = this.handlePopState.bind(this); this.isSupported = supportsHistory(); } _updateLocation(navigationType) { - var key = null; + var state = null; if (this.isSupported) { - var state = window.history.state; - key = state && state.key; + state = window.history.state || {}; - if (!key) { - key = createRandomKey(); - window.history.replaceState({ key }, ''); + if (!state.key) { + state.key = createRandomKey(); + window.history.replaceState(state, ''); } } - this.location = this._createLocation(getWindowPath(), key, navigationType); + this.location = new Location(state, getWindowPath(), navigationType); + } + + setup() { + if (this.location == null) + this._updateLocation(); } handlePopState(event) { @@ -72,19 +74,20 @@ export class BrowserHistory extends DOMHistory { } } - setup() { - if (this.location == null) - this._updateLocation(); - } - // http://www.w3.org/TR/2011/WD-html5-20110113/history.html#dom-history-pushstate - push(path) { + pushState(state, path) { if (this.isSupported) { - this._recordScrollPosition(); + var currentState = window.history.state; - var key = createRandomKey(); - window.history.pushState({ key }, '', path); - this.location = this._createLocation(path, key, NavigationTypes.PUSH); + if (currentState) { + Object.assign(currentState, this.getScrollPosition()); + window.history.replaceState(currentState, ''); + } + + state = this._createState(state); + + window.history.pushState(state, '', path); + this.location = new Location(state, path, NavigationTypes.PUSH); this._notifyChange(); } else { window.location = path; @@ -92,11 +95,12 @@ export class BrowserHistory extends DOMHistory { } // http://www.w3.org/TR/2011/WD-html5-20110113/history.html#dom-history-replacestate - replace(path) { + replaceState(state, path) { if (this.isSupported) { - var key = createRandomKey(); - window.history.replaceState({ key }, '', path); - this.location = this._createLocation(path, key, NavigationTypes.REPLACE); + state = this._createState(state); + + window.history.replaceState(state, '', path); + this.location = new Location(state, path, NavigationTypes.REPLACE); this._notifyChange(); } else { window.location.replace(path); diff --git a/modules/ChangeEmitter.js b/modules/ChangeEmitter.js new file mode 100644 index 0000000000..ffd05c2a5c --- /dev/null +++ b/modules/ChangeEmitter.js @@ -0,0 +1,24 @@ +class ChangeEmitter { + + constructor() { + this.changeListeners = []; + } + + _notifyChange() { + for (var i = 0, len = this.changeListeners.length; i < len; ++i) + this.changeListeners[i].call(this); + } + + addChangeListener(listener) { + this.changeListeners.push(listener); + } + + removeChangeListener(listener) { + this.changeListeners = this.changeListeners.filter(function (li) { + return li !== listener; + }); + } + +} + +export default ChangeEmitter; diff --git a/modules/DOMHistory.js b/modules/DOMHistory.js index 35ad09e48e..669c909e09 100644 --- a/modules/DOMHistory.js +++ b/modules/DOMHistory.js @@ -1,17 +1,9 @@ import History from './History'; -import { getWindowScrollPosition } from './DOMUtils'; -import Location from './Location'; /** * A history interface that assumes a DOM environment. */ -export class DOMHistory extends History { - - constructor(getScrollPosition=getWindowScrollPosition) { - super(); - this.getScrollPosition = getScrollPosition; - this.scrollHistory = {}; - } +class DOMHistory extends History { go(n) { if (n === 0) @@ -20,20 +12,6 @@ export class DOMHistory extends History { window.history.go(n); } - _createLocation(path, key, navigationType) { - var scrollKey = key || path; - var scrollPosition = this.scrollHistory[scrollKey]; - - return new Location(path, key, navigationType, scrollPosition); - } - - _recordScrollPosition() { - var location = this.location; - var scrollKey = location.key || location.path; - - this.scrollHistory[scrollKey] = this.getScrollPosition(); - } - } export default DOMHistory; diff --git a/modules/DOMUtils.js b/modules/DOMUtils.js index be635541bb..8ee6dcc464 100644 --- a/modules/DOMUtils.js +++ b/modules/DOMUtils.js @@ -1,4 +1,9 @@ -function getHashPath() { +export var canUseDOM = !!( + (typeof window !== 'undefined' && + window.document && window.document.createElement) +); + +export function getHashPath() { return decodeURI( // We can't use window.location.hash here because it's not // consistent across browsers - Firefox will pre-decode it! @@ -6,22 +11,22 @@ function getHashPath() { ); } -function replaceHashPath(path) { +export function replaceHashPath(path) { window.location.replace( window.location.pathname + window.location.search + '#' + path ); } -function getWindowPath() { +export function getWindowPath() { return decodeURI( window.location.pathname + window.location.search ); } -function getWindowScrollPosition() { +export function getWindowScrollPosition() { return { - x: window.pageXOffset || document.documentElement.scrollLeft, - y: window.pageYOffset || document.documentElement.scrollTop + scrollX: window.pageXOffset || document.documentElement.scrollLeft, + scrollY: window.pageYOffset || document.documentElement.scrollTop }; } @@ -31,18 +36,10 @@ function getWindowScrollPosition() { * https://github.com/Modernizr/Modernizr/blob/master/feature-detects/history.js * changed to avoid false negatives for Windows Phones: https://github.com/rackt/react-router/issues/586 */ -function supportsHistory() { +export function supportsHistory() { var ua = 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; } - -module.exports = { - getHashPath, - replaceHashPath, - getWindowPath, - getWindowScrollPosition, - supportsHistory -}; diff --git a/modules/HashHistory.js b/modules/HashHistory.js index 45ef8ca10a..d19a4c670c 100644 --- a/modules/HashHistory.js +++ b/modules/HashHistory.js @@ -1,7 +1,8 @@ import DOMHistory from './DOMHistory'; import NavigationTypes from './NavigationTypes'; -import { getHashPath, replaceHashPath } from './DOMUtils'; +import { getHashPath, getWindowScrollPosition, replaceHashPath } from './DOMUtils'; import { isAbsolutePath } from './URLUtils'; +import Location from './Location'; function ensureSlash() { var path = getHashPath(); @@ -21,13 +22,29 @@ function ensureSlash() { */ export class HashHistory extends DOMHistory { - constructor(getScrollPosition) { - super(getScrollPosition); + constructor(getScrollPosition=getWindowScrollPosition) { + super(); + this.getScrollPosition = getScrollPosition; this.handleHashChange = this.handleHashChange.bind(this); + + // We keep known states in memory so we don't have to keep a key + // in the URL. We could have persistence if we did, but we can always + // add that later if people need it. I suspect that at this point + // most users who need state are already using BrowserHistory. + this.states = {}; } _updateLocation(navigationType) { - this.location = this._createLocation(getHashPath(), null, navigationType); + var path = getHashPath(); + var state = this.states[path] || null; + this.location = new Location(state, path, navigationType); + } + + setup() { + if (this.location == null) { + ensureSlash(); + this._updateLocation(); + } } handleHashChange() { @@ -61,30 +78,41 @@ export class HashHistory extends DOMHistory { } } - setup() { - if (this.location == null) { - ensureSlash(); - this._updateLocation(); + pushState(state, path) { + var location = this.location; + var currentState = location && this.states[location.path]; + + if (currentState) { + Object.assign(currentState, this.getScrollPosition()); + this.states[location.path] = currentState; } - } - push(path) { - this._recordScrollPosition(); + state = this._createState(state); + this.states[path] = state; this._ignoreHashChange = true; window.location.hash = path; this._ignoreHashChange = false; - this.location = this._createLocation(path, null, NavigationTypes.PUSH); + this.location = new Location(state, path, NavigationTypes.PUSH); + this._notifyChange(); } - replace(path) { + replaceState(state, path) { + state = this._createState(state); + + var location = this.location; + + if (location && this.states[location.path]) + this.states[location.path] = state; + this._ignoreHashChange = true; replaceHashPath(path); this._ignoreHashChange = false; - this.location = this._createLocation(path, null, NavigationTypes.REPLACE); + this.location = new Location(state, path, NavigationTypes.REPLACE); + this._notifyChange(); } diff --git a/modules/History.js b/modules/History.js index a40cd5e5bd..08a354d4e6 100644 --- a/modules/History.js +++ b/modules/History.js @@ -1,19 +1,26 @@ import invariant from 'invariant'; +import ChangeEmitter from './ChangeEmitter'; -var RequiredSubclassMethods = [ 'push', 'replace', 'go' ]; +var RequiredSubclassMethods = [ 'pushState', 'replaceState', 'go' ]; + +function createRandomKey() { + return Math.random().toString(36).substr(2); +} /** * A history interface that normalizes the differences across * various environments and implementations. Requires concrete * subclasses to implement the following methods: * - * - push(path) - * - replace(path) + * - pushState(state, path) + * - replaceState(state, path) * - go(n) */ -export class History { +class History extends ChangeEmitter { constructor() { + super(); + RequiredSubclassMethods.forEach(function (method) { invariant( typeof this[method] === 'function', @@ -22,25 +29,9 @@ export class History { ); }, this); - this.changeListeners = []; this.location = null; } - _notifyChange() { - for (var i = 0, len = this.changeListeners.length; i < len; ++i) - this.changeListeners[i].call(this); - } - - addChangeListener(listener) { - this.changeListeners.push(listener); - } - - removeChangeListener(listener) { - this.changeListeners = this.changeListeners.filter(function (li) { - return li !== listener; - }); - } - back() { this.go(-1); } @@ -49,6 +40,15 @@ export class History { this.go(1); } + _createState(state) { + state = state || {}; + + if (!state.key) + state.key = createRandomKey(); + + return state; + } + } export default History; diff --git a/modules/Location.js b/modules/Location.js index da7703b36f..c119b684a7 100644 --- a/modules/Location.js +++ b/modules/Location.js @@ -6,7 +6,7 @@ import NavigationTypes from './NavigationTypes'; * 1. Where am I? * 2. How did I get here? */ -export class Location { +class Location { static isLocation(object) { return object instanceof Location; @@ -17,19 +17,18 @@ export class Location { return object; if (typeof object === 'string') - return new Location(object); + return new Location(null, object); if (object && object.path) - return new Location(object.path, object.key, object.navigationType, object.scrollPosition); + return new Location(object.state, object.path, object.navigationType); throw new Error('Unable to create a Location from ' + object); } - constructor(path, key=null, navigationType=NavigationTypes.POP, scrollPosition=null) { + constructor(state, path, navigationType=NavigationTypes.POP) { + this.state = state; this.path = path; - this.key = key; this.navigationType = navigationType; - this.scrollPosition = scrollPosition; } } diff --git a/modules/MemoryHistory.js b/modules/MemoryHistory.js index 0e8b51e4ca..bc1836d611 100644 --- a/modules/MemoryHistory.js +++ b/modules/MemoryHistory.js @@ -3,6 +3,22 @@ import NavigationTypes from './NavigationTypes'; import Location from './Location'; import History from './History'; +function createEntry(object) { + if (typeof object === 'string') + return { path: object }; + + if (object && typeof object === 'object') { + invariant( + typeof object.path === 'string', + 'A history entry must have a string path' + ); + + return object; + } + + throw new Error('Unable to create history entry from ' + object); +} + /** * A concrete History class that doesn't require a DOM. Ideal * for testing because it allows you to specify route history @@ -17,8 +33,12 @@ export class MemoryHistory extends History { entries = [ '/' ]; } else if (typeof entries === 'string') { entries = [ entries ]; + } else if (!Array.isArray(entries)) { + throw new Error('MemoryHistory needs an array of entries'); } + entries = entries.map(createEntry); + if (current == null) { current = entries.length - 1; } else { @@ -31,21 +51,33 @@ export class MemoryHistory extends History { this.entries = entries; this.current = current; - this.location = new Location(entries[current]); + + var currentEntry = entries[current]; + + this.location = new Location( + currentEntry.state || null, + currentEntry.path + ); } // http://www.w3.org/TR/2011/WD-html5-20110113/history.html#dom-history-pushstate - push(path) { + pushState(state, path) { + state = this._createState(state); + this.current += 1; - this.entries = this.entries.slice(0, this.current).concat([ path ]); - this.location = new Location(path, null, NavigationTypes.PUSH); + this.entries = this.entries.slice(0, this.current).concat([{ state, path }]); + this.location = new Location(state, path, NavigationTypes.PUSH); + this._notifyChange(); } // http://www.w3.org/TR/2011/WD-html5-20110113/history.html#dom-history-replacestate - replace(path) { - this.entries[this.current] = path; - this.location = new Location(path, null, NavigationTypes.REPLACE); + replaceState(state, path) { + state = this._createState(state); + + this.entries[this.current] = { state, path }; + this.location = new Location(state, path, NavigationTypes.REPLACE); + this._notifyChange(); } @@ -60,7 +92,14 @@ export class MemoryHistory extends History { ); this.current += n; - this.location = new Location(this.entries[this.current], null, NavigationTypes.POP); + var currentEntry = this.entries[this.current]; + + this.location = new Location( + currentEntry.state || null, + currentEntry.path, + NavigationTypes.POP + ); + this._notifyChange(); } diff --git a/modules/Router.js b/modules/Router.js index 5c09e373c5..608bf5a4f2 100644 --- a/modules/Router.js +++ b/modules/Router.js @@ -15,7 +15,7 @@ export var Router = React.createClass({ statics: { match(routes, location, callback) { - // TODO: Mimic what we're doing in _updateLocation, but statically + // TODO: Mimic what we're doing in _updateState, but statically // so we can get the right props for doing server-side rendering. } @@ -60,10 +60,7 @@ export var Router = React.createClass({ }; }, - _updateLocation(location) { - if (!Location.isLocation(location)) - location = Location.create(location); - + _updateState(location) { this.setState({ isTransitioning: true }); this.nextLocation = location; @@ -172,29 +169,29 @@ export var Router = React.createClass({ return makeHref(pathname, query, stringifyQuery, history); }, - transitionTo(pathname, query) { + transitionTo(pathname, query, state=null) { var path = this.makePath(pathname, query); var { history } = this.props; if (history) { if (this.nextLocation) { - history.replace(path); + history.replaceState(state, path); } else { - history.push(path); + history.pushState(state, path); } } else { - this._updateLocation(path); + this._updateState(new Location(state, path)); } }, - replaceWith(pathname, query) { + replaceWith(pathname, query, state=null) { var path = this.makePath(pathname, query); var { history } = this.props; if (history) { - history.replace(path); + history.replaceState(state, path); } else { - this._updateLocation(path); + this._updateState(new Location(state, path)); } }, @@ -223,14 +220,14 @@ export var Router = React.createClass({ if (typeof history.setup === 'function') history.setup(); - this._updateLocation(history.location); + this._updateState(history.location); } else { - this._updateLocation(location); + this._updateState(location); } }, handleHistoryChange() { - this._updateLocation(this.props.history.location); + this._updateState(this.props.history.location); }, componentDidMount() { @@ -256,11 +253,11 @@ export var Router = React.createClass({ if (this.props.children !== nextProps.children) { this.routes = createRoutes(nextProps.children); - // Call this now because _updateLocation uses - // this.routes to determine state. - this._updateLocation(nextProps.location); + // Call this here because _updateState + // uses this.routes to determine state. + this._updateState(nextProps.location); } else if (this.props.location !== nextProps.location) { - this._updateLocation(nextProps.location); + this._updateState(nextProps.location); } }, diff --git a/modules/__tests__/MemoryHistory-test.js b/modules/__tests__/MemoryHistory-test.js index a72026f73f..2bef39b6e4 100644 --- a/modules/__tests__/MemoryHistory-test.js +++ b/modules/__tests__/MemoryHistory-test.js @@ -22,7 +22,7 @@ describe('MemoryHistory', function () { describe('when pushing a new path', function () { beforeEach(function () { - history.push('/push'); + history.pushState(null, '/push'); }); it('increments current index by one', function () { @@ -43,7 +43,7 @@ describe('MemoryHistory', function () { describe('and then replacing that path', function () { beforeEach(function () { - history.replace('/replace'); + history.replaceState(null, '/replace'); }); it('maintains the current index', function () { diff --git a/modules/__tests__/describeHistory.js b/modules/__tests__/describeHistory.js index e703bac89e..acbe2b8d0f 100644 --- a/modules/__tests__/describeHistory.js +++ b/modules/__tests__/describeHistory.js @@ -6,7 +6,7 @@ export default function describeHistory(history) { expect(history).toBeA(History); }); - var RequiredMethods = [ 'push', 'replace', 'go' ]; + var RequiredMethods = [ 'pushState', 'replaceState', 'go' ]; RequiredMethods.forEach(function (method) { it('has a ' + method + ' method', function () { @@ -15,21 +15,21 @@ export default function describeHistory(history) { }); describe('adding/removing a listener', function () { - var push, go, pushSpy, goSpy; + var pushState, go, pushStateSpy, goSpy; beforeEach(function () { // It's a bit tricky to test change listeners properly because // they are triggered when the URL changes. So we need to stub // out push/go to only notify listeners ... but we can't make // assertions on the location because it will be wrong. - push = history.push; - pushSpy = spyOn(history, 'push').andCall(history._notifyChange); + pushState = history.pushState; + pushStateSpy = spyOn(history, 'pushState').andCall(history._notifyChange); go = history.go; goSpy = spyOn(history, 'go').andCall(history._notifyChange); }); afterEach(function () { - history.push = push; + history.push = pushState; history.go = go; }); @@ -37,8 +37,8 @@ export default function describeHistory(history) { var spy = expect.createSpy(function () {}); history.addChangeListener(spy); - history.push('/home'); // call #1 - expect(pushSpy).toHaveBeenCalled(); + history.pushState(null, '/home'); // call #1 + expect(pushStateSpy).toHaveBeenCalled(); expect(spy.calls.length).toEqual(1);