From 0e7a182b64ccd9613021fdb046da3b38d012d985 Mon Sep 17 00:00:00 2001 From: Sean Adkinson Date: Mon, 4 Aug 2014 17:15:52 -0700 Subject: [PATCH] [added] pluggable history implementations closes #166 --- Location.js | 1 + index.js | 1 + modules/components/Routes.js | 12 ++- modules/helpers/DisabledLocation.js | 34 ++++++++ modules/helpers/HashLocation.js | 60 +++++++++++++ modules/helpers/HistoryLocation.js | 50 +++++++++++ modules/helpers/Location.js | 10 +++ modules/helpers/MemoryLocation.js | 53 ++++++++++++ modules/helpers/getWindowPath.js | 9 ++ modules/stores/URLStore.js | 100 ++++------------------ specs/URLStore.spec.js | 128 ++++++++++++++++------------ 11 files changed, 318 insertions(+), 140 deletions(-) create mode 100644 Location.js create mode 100644 modules/helpers/DisabledLocation.js create mode 100644 modules/helpers/HashLocation.js create mode 100644 modules/helpers/HistoryLocation.js create mode 100644 modules/helpers/Location.js create mode 100644 modules/helpers/MemoryLocation.js create mode 100644 modules/helpers/getWindowPath.js diff --git a/Location.js b/Location.js new file mode 100644 index 0000000000..76105d5d6f --- /dev/null +++ b/Location.js @@ -0,0 +1 @@ +module.exports = require('./modules/helpers/Location'); diff --git a/index.js b/index.js index 436d957f0e..379cff1e8b 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,7 @@ exports.Link = require('./Link'); exports.Redirect = require('./Redirect'); exports.Route = require('./Route'); exports.Routes = require('./Routes'); +exports.Location = require('./Location'); exports.goBack = require('./goBack'); exports.replaceWith = require('./replaceWith'); exports.transitionTo = require('./transitionTo'); diff --git a/modules/components/Routes.js b/modules/components/Routes.js index 902735ca99..5054309fed 100644 --- a/modules/components/Routes.js +++ b/modules/components/Routes.js @@ -5,6 +5,7 @@ var mergeProperties = require('../helpers/mergeProperties'); var goBack = require('../helpers/goBack'); var replaceWith = require('../helpers/replaceWith'); var transitionTo = require('../helpers/transitionTo'); +var Location = require('../helpers/Location'); var Route = require('../components/Route'); var Path = require('../helpers/Path'); var ActiveStore = require('../stores/ActiveStore'); @@ -53,8 +54,15 @@ var Routes = React.createClass({ }, propTypes: { - location: React.PropTypes.oneOf([ 'hash', 'history' ]).isRequired, - preserveScrollPosition: React.PropTypes.bool + preserveScrollPosition: React.PropTypes.bool, + location: function(props, propName, componentName) { + var location = props[propName]; + if (!Location[location]) { + return new Error('No matching location: "' + location + + '". Must be one of: ' + Object.keys(Location) + + '. See: ' + componentName); + } + } }, getDefaultProps: function () { diff --git a/modules/helpers/DisabledLocation.js b/modules/helpers/DisabledLocation.js new file mode 100644 index 0000000000..a7ba1fa012 --- /dev/null +++ b/modules/helpers/DisabledLocation.js @@ -0,0 +1,34 @@ +var getWindowPath = require('./getWindowPath'); + +/** + * Location handler that doesn't actually do any location handling. Instead, requests + * are sent to the server as normal. + */ +var DisabledLocation = { + + type: 'disabled', + + init: function() { }, + + destroy: function() { }, + + getCurrentPath: function() { + return getWindowPath(); + }, + + push: function(path) { + window.location = path; + }, + + replace: function(path) { + window.location.replace(path); + }, + + back: function() { + window.history.back(); + } + +}; + +module.exports = DisabledLocation; + diff --git a/modules/helpers/HashLocation.js b/modules/helpers/HashLocation.js new file mode 100644 index 0000000000..954bfbbc0f --- /dev/null +++ b/modules/helpers/HashLocation.js @@ -0,0 +1,60 @@ +var getWindowPath = require('./getWindowPath'); + +function getWindowChangeEvent() { + return window.addEventListener ? 'hashchange' : 'onhashchange'; +} + +/** + * Location handler which uses the `window.location.hash` to push and replace URLs + */ +var HashLocation = { + + type: 'hash', + onChange: null, + + init: function(onChange) { + var changeEvent = getWindowChangeEvent(); + + if (window.location.hash === '') { + this.replace('/'); + } + + if (window.addEventListener) { + window.addEventListener(changeEvent, onChange, false); + } else { + window.attachEvent(changeEvent, onChange); + } + + this.onChange = onChange; + onChange(); + }, + + destroy: function() { + var changeEvent = getWindowChangeEvent(); + if (window.removeEventListener) { + window.removeEventListener(changeEvent, this.onChange, false); + } else { + window.detachEvent(changeEvent, this.onChange); + } + }, + + getCurrentPath: function() { + return window.location.hash.substr(1); + }, + + push: function(path) { + window.location.hash = path; + }, + + replace: function(path) { + window.location.replace(getWindowPath() + '#' + path); + }, + + back: function() { + window.history.back(); + } + +}; + +module.exports = HashLocation; + diff --git a/modules/helpers/HistoryLocation.js b/modules/helpers/HistoryLocation.js new file mode 100644 index 0000000000..071e596a35 --- /dev/null +++ b/modules/helpers/HistoryLocation.js @@ -0,0 +1,50 @@ +var getWindowPath = require('./getWindowPath'); + +/** + * Location handler which uses the HTML5 History API to push and replace URLs + */ +var HistoryLocation = { + + type: 'history', + onChange: null, + + init: function(onChange) { + if (window.addEventListener) { + window.addEventListener('popstate', onChange, false); + } else { + window.attachEvent('popstate', onChange); + } + this.onChange = onChange; + onChange(); + }, + + destroy: function() { + if (window.removeEventListener) { + window.removeEventListener('popstate', this.onChange, false); + } else { + window.detachEvent('popstate', this.onChange); + } + }, + + getCurrentPath: function() { + return getWindowPath(); + }, + + push: function(path) { + window.history.pushState({ path: path }, '', path); + this.onChange(); + }, + + replace: function(path) { + window.history.replaceState({ path: path }, '', path); + this.onChange(); + }, + + back: function() { + window.history.back(); + } + +}; + +module.exports = HistoryLocation; + diff --git a/modules/helpers/Location.js b/modules/helpers/Location.js new file mode 100644 index 0000000000..751f822a96 --- /dev/null +++ b/modules/helpers/Location.js @@ -0,0 +1,10 @@ +/** + * Map of location type to handler. + * @see Routes#location + */ +module.exports = { + hash: require('./HashLocation'), + history: require('./HistoryLocation'), + disabled: require('./DisabledLocation'), + memory: require('./MemoryLocation') +}; \ No newline at end of file diff --git a/modules/helpers/MemoryLocation.js b/modules/helpers/MemoryLocation.js new file mode 100644 index 0000000000..ee2e0ea697 --- /dev/null +++ b/modules/helpers/MemoryLocation.js @@ -0,0 +1,53 @@ +var invariant = require('react/lib/invariant'); + +var _lastPath; +var _currentPath = '/'; + +/** + * Fake location handler that can be used outside the scope of the browser. It + * tracks the current and previous path, as given to #push() and #replace(). + */ +var MemoryLocation = { + + type: 'memory', + onChange: null, + + init: function(onChange) { + this.onChange = onChange; + }, + + destroy: function() { + this.onChange = null; + _lastPath = null; + _currentPath = '/'; + }, + + getCurrentPath: function() { + return _currentPath; + }, + + push: function(path) { + _lastPath = _currentPath; + _currentPath = path; + this.onChange(); + }, + + replace: function(path) { + _currentPath = path; + this.onChange(); + }, + + back: function() { + invariant( + _lastPath, + 'You cannot make the URL store go back more than once when it does not use the DOM' + ); + + _currentPath = _lastPath; + _lastPath = null; + this.onChange(); + } +}; + +module.exports = MemoryLocation; + diff --git a/modules/helpers/getWindowPath.js b/modules/helpers/getWindowPath.js new file mode 100644 index 0000000000..108c2285b2 --- /dev/null +++ b/modules/helpers/getWindowPath.js @@ -0,0 +1,9 @@ +/** + * Returns the current URL path from `window.location`, including query string + */ +function getWindowPath() { + return window.location.pathname + window.location.search; +} + +module.exports = getWindowPath; + diff --git a/modules/stores/URLStore.js b/modules/stores/URLStore.js index 6ec2f489c8..1b94bce327 100644 --- a/modules/stores/URLStore.js +++ b/modules/stores/URLStore.js @@ -1,25 +1,14 @@ var ExecutionEnvironment = require('react/lib/ExecutionEnvironment'); var invariant = require('react/lib/invariant'); var warning = require('react/lib/warning'); - -var _location; -var _currentPath = '/'; -var _lastPath = null; - -function getWindowChangeEvent(location) { - if (location === 'history') - return 'popstate'; - - return window.addEventListener ? 'hashchange' : 'onhashchange'; -} - -function getWindowPath() { - return window.location.pathname + window.location.search; -} +var Location = require('../helpers/Location'); var EventEmitter = require('event-emitter'); var _events = EventEmitter(); +var _location; +var _locationHandler; + function notifyChange() { _events.emit('change'); } @@ -56,13 +45,7 @@ var URLStore = { * Returns the value of the current URL path. */ getCurrentPath: function () { - if (_location === 'history' || _location === 'disabledHistory') - return getWindowPath(); - - if (_location === 'hash') - return window.location.hash.substr(1); - - return _currentPath; + return _locationHandler.getCurrentPath(); }, /** @@ -72,19 +55,7 @@ var URLStore = { if (path === this.getCurrentPath()) return; - if (_location === 'disabledHistory') - return window.location = path; - - if (_location === 'history') { - window.history.pushState({ path: path }, '', path); - notifyChange(); - } else if (_location === 'hash') { - window.location.hash = path; - } else { - _lastPath = _currentPath; - _currentPath = path; - notifyChange(); - } + _locationHandler.push(path); }, /** @@ -92,35 +63,14 @@ var URLStore = { * to the browser's history. */ replace: function (path) { - if (_location === 'disabledHistory') { - window.location.replace(path); - } else if (_location === 'history') { - window.history.replaceState({ path: path }, '', path); - notifyChange(); - } else if (_location === 'hash') { - window.location.replace(getWindowPath() + '#' + path); - } else { - _currentPath = path; - notifyChange(); - } + _locationHandler.replace(path); }, /** * Reverts the URL to whatever it was before the last update. */ back: function () { - if (_location != null) { - window.history.back(); - } else { - invariant( - _lastPath, - 'You cannot make the URL store go back more than once when it does not use the DOM' - ); - - _currentPath = _lastPath; - _lastPath = null; - notifyChange(); - } + _locationHandler.back(); }, /** @@ -151,30 +101,19 @@ var URLStore = { } if (location === 'history' && !supportsHistory()) { - _location = 'disabledHistory'; - return; + location = 'disabled'; } - var changeEvent = getWindowChangeEvent(location); + _location = location; + _locationHandler = Location[location]; invariant( - changeEvent || location === 'disabledHistory', + _locationHandler, 'The URL store location "' + location + '" is not valid. ' + - 'It must be either "hash" or "history"' + 'It must be any of: ' + Object.keys(Location) ); - _location = location; - - if (location === 'hash' && window.location.hash === '') - URLStore.replace('/'); - - if (window.addEventListener) { - window.addEventListener(changeEvent, notifyChange, false); - } else { - window.attachEvent(changeEvent, notifyChange); - } - - notifyChange(); + _locationHandler.init(notifyChange); }, /** @@ -184,16 +123,9 @@ var URLStore = { if (_location == null) return; - var changeEvent = getWindowChangeEvent(_location); - - if (window.removeEventListener) { - window.removeEventListener(changeEvent, notifyChange, false); - } else { - window.detachEvent(changeEvent, notifyChange); - } - + _locationHandler.destroy(); _location = null; - _currentPath = '/'; + _locationHandler = null; } }; diff --git a/specs/URLStore.spec.js b/specs/URLStore.spec.js index 0225c72e4e..7d0e71eb41 100644 --- a/specs/URLStore.spec.js +++ b/specs/URLStore.spec.js @@ -1,81 +1,101 @@ require('./helper'); var URLStore = require('../modules/stores/URLStore'); -describe('when a new path is pushed to the URL', function () { - beforeEach(function () { - URLStore.push('/a/b/c'); +describe('URLStore', function() { + + beforeEach(function() { + URLStore.setup("hash"); }); - afterEach(function () { + afterEach(function() { URLStore.teardown(); }); - it('has the correct path', function () { - expect(URLStore.getCurrentPath()).toEqual('/a/b/c'); - }); -}); + describe('when a new path is pushed to the URL', function() { + beforeEach(function() { + URLStore.push('/a/b/c'); + }); -describe('when a new path is used to replace the URL', function () { - beforeEach(function () { - URLStore.replace('/a/b/c'); + it('has the correct path', function() { + expect(URLStore.getCurrentPath()).toEqual('/a/b/c'); + }); }); - afterEach(function () { - URLStore.teardown(); - }); + describe('when a new path is used to replace the URL', function() { + beforeEach(function() { + URLStore.replace('/a/b/c'); + }); - it('has the correct path', function () { - expect(URLStore.getCurrentPath()).toEqual('/a/b/c'); + it('has the correct path', function() { + expect(URLStore.getCurrentPath()).toEqual('/a/b/c'); + }); }); -}); -describe('when going back in history', function () { - afterEach(function () { - URLStore.teardown(); + describe('when going back in history', function() { + it('has the correct path', function() { + URLStore.push('/a/b/c'); + expect(URLStore.getCurrentPath()).toEqual('/a/b/c'); + + URLStore.push('/d/e/f'); + expect(URLStore.getCurrentPath()).toEqual('/d/e/f'); + + URLStore.back(); + expect(URLStore.getCurrentPath()).toEqual('/a/b/c'); + }); }); - it('has the correct path', function () { - URLStore.push('/a/b/c'); - expect(URLStore.getCurrentPath()).toEqual('/a/b/c'); + describe('when navigating back to the root', function() { + beforeEach(function() { + URLStore.teardown(); - URLStore.push('/d/e/f'); - expect(URLStore.getCurrentPath()).toEqual('/d/e/f'); + // simulating that the browser opens a page with #/dashboard + window.location.hash = '/dashboard'; + URLStore.setup('hash'); + }); - URLStore.back(); - expect(URLStore.getCurrentPath()).toEqual('/a/b/c'); + it('should have the correct path', function() { + URLStore.push('/'); + expect(window.location.hash).toEqual('#/'); + }); }); - it('should not go back before recorded history', function () { - var error = false; - try { - URLStore.back(); - } catch (e) { - error = true; - } + describe('when using history location handler', function() { + itShouldManagePathsForLocation('history'); + }); - expect(error).toEqual(true); + describe('when using memory location handler', function() { + itShouldManagePathsForLocation('memory'); }); -}); -describe('when navigating back to the root', function() { - beforeEach(function () { - // not all tests are constructing and tearing down the URLStore. - // Let's set it up correctly once and then tear it down to ensure that all - // variables in the URLStore module are reset. - URLStore.setup('hash'); - URLStore.teardown(); + function itShouldManagePathsForLocation(location) { + var origPath; - // simulating that the browser opens a page with #/dashboard - window.location.hash = '/dashboard'; - URLStore.setup('hash'); - }); + beforeEach(function() { + URLStore.teardown(); + URLStore.setup(location); + origPath = URLStore.getCurrentPath(); + }); - afterEach(function () { - URLStore.teardown(); - }); + afterEach(function() { + URLStore.push(origPath); + expect(URLStore.getCurrentPath()).toEqual(origPath); + }); + + it('should manage the path correctly', function() { + URLStore.push('/test'); + expect(URLStore.getCurrentPath()).toEqual('/test'); + + URLStore.push('/test/123'); + expect(URLStore.getCurrentPath()).toEqual('/test/123'); + + URLStore.replace('/test/replaced'); + expect(URLStore.getCurrentPath()).toEqual('/test/replaced'); + + URLStore.back(); + expect(URLStore.getCurrentPath()).toEqual('/test'); + + }); + } - it('should have the correct path', function () { - URLStore.push('/'); - expect(window.location.hash).toEqual('#/'); - }); }); +