From b7e21bb5cf7afe58d60ed1a92a09e0c5f8d77cf6 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Fri, 29 Aug 2014 01:48:27 -0700 Subject: [PATCH] [fixed] Window scrolling The router now remembers the last window scroll position at various paths and automatically scrolls the window to match after transitions complete unless preserveScrollPosition=true is used. This commit also introduces a flux-style architecture to the high-level transitionTo/replaceWith/goBack methods. Fixes #189 Fixes #186 --- goBack.js | 2 +- modules/actions/LocationActions.js | 57 +++++++++++++++++++ modules/components/Link.js | 2 +- modules/components/Routes.js | 22 +++----- modules/dispatchers/LocationDispatcher.js | 18 ++++++ modules/helpers/Transition.js | 2 +- modules/helpers/goBack.js | 10 ---- modules/helpers/replaceWith.js | 12 ---- modules/helpers/transitionTo.js | 12 ---- modules/stores/PathStore.js | 67 ++++++++++++++++++----- package.json | 3 +- replaceWith.js | 2 +- specs/PathStore.spec.js | 28 +++++----- specs/helper.js | 3 +- transitionTo.js | 2 +- 15 files changed, 162 insertions(+), 80 deletions(-) create mode 100644 modules/actions/LocationActions.js create mode 100644 modules/dispatchers/LocationDispatcher.js delete mode 100644 modules/helpers/goBack.js delete mode 100644 modules/helpers/replaceWith.js delete mode 100644 modules/helpers/transitionTo.js diff --git a/goBack.js b/goBack.js index 29dcf19584..2fa8595299 100644 --- a/goBack.js +++ b/goBack.js @@ -1 +1 @@ -module.exports = require('./modules/helpers/goBack'); +module.exports = require('./modules/actions/LocationActions').goBack; diff --git a/modules/actions/LocationActions.js b/modules/actions/LocationActions.js new file mode 100644 index 0000000000..5646ecb6cd --- /dev/null +++ b/modules/actions/LocationActions.js @@ -0,0 +1,57 @@ +var LocationDispatcher = require('../dispatchers/LocationDispatcher'); +var makePath = require('../helpers/makePath'); + +/** + * Actions that modify the URL. + */ +var LocationActions = { + + PUSH: 'push', + REPLACE: 'replace', + POP: 'pop', + UPDATE_SCROLL: 'update-scroll', + + /** + * Transitions to the URL specified in the arguments by pushing + * a new URL onto the history stack. + */ + transitionTo: function (to, params, query) { + LocationDispatcher.handleViewAction({ + type: LocationActions.PUSH, + path: makePath(to, params, query) + }); + }, + + /** + * Transitions to the URL specified in the arguments by replacing + * the current URL in the history stack. + */ + replaceWith: function (to, params, query) { + LocationDispatcher.handleViewAction({ + type: LocationActions.REPLACE, + path: makePath(to, params, query) + }); + }, + + /** + * Transitions to the previous URL. + */ + goBack: function () { + LocationDispatcher.handleViewAction({ + type: LocationActions.POP + }); + }, + + /** + * Updates the window's scroll position to the last known position + * for the current URL path. + */ + updateScroll: function () { + LocationDispatcher.handleViewAction({ + type: LocationActions.UPDATE_SCROLL + }); + } + +}; + +module.exports = LocationActions; diff --git a/modules/components/Link.js b/modules/components/Link.js index 9bba486cbd..1e13d5ebf5 100644 --- a/modules/components/Link.js +++ b/modules/components/Link.js @@ -1,7 +1,7 @@ var React = require('react'); var ActiveState = require('../mixins/ActiveState'); +var transitionTo = require('../actions/LocationActions').transitionTo; var withoutProperties = require('../helpers/withoutProperties'); -var transitionTo = require('../helpers/transitionTo'); var hasOwnProperty = require('../helpers/hasOwnProperty'); var makeHref = require('../helpers/makeHref'); var warning = require('react/lib/warning'); diff --git a/modules/components/Routes.js b/modules/components/Routes.js index 2361c40d45..8f12677772 100644 --- a/modules/components/Routes.js +++ b/modules/components/Routes.js @@ -2,9 +2,8 @@ var React = require('react'); var warning = require('react/lib/warning'); var copyProperties = require('react/lib/copyProperties'); var Promise = require('es6-promise').Promise; +var LocationActions = require('../actions/LocationActions'); var Route = require('../components/Route'); -var goBack = require('../helpers/goBack'); -var replaceWith = require('../helpers/replaceWith'); var Path = require('../helpers/Path'); var Redirect = require('../helpers/Redirect'); var Transition = require('../helpers/Transition'); @@ -37,9 +36,9 @@ function defaultAbortedTransitionHandler(transition) { var reason = transition.abortReason; if (reason instanceof Redirect) { - replaceWith(reason.to, reason.params, reason.query); + LocationActions.replaceWith(reason.to, reason.params, reason.query); } else { - goBack(); + LocationActions.goBack(); } } @@ -59,6 +58,11 @@ function defaultTransitionErrorHandler(error) { throw error; // This error probably originated in a transition hook. } +function maybeUpdateScroll(routes, rootRoute) { + if (!routes.props.preserveScrollPosition && !rootRoute.props.preserveScrollPosition) + LocationActions.updateScroll(); +} + /** * The component configures the route hierarchy and renders the * route matching the current location when rendered into a document. @@ -98,7 +102,6 @@ var Routes = React.createClass({ }; }, - getLocation: function () { var location = this.props.location; @@ -185,7 +188,7 @@ var Routes = React.createClass({ var rootMatch = getRootMatch(nextState.matches); if (rootMatch) - maybeScrollWindow(routes, rootMatch.route); + maybeUpdateScroll(routes, rootMatch.route); } return transition; @@ -443,11 +446,4 @@ function reversedArray(array) { return array.slice(0).reverse(); } -function maybeScrollWindow(routes, rootRoute) { - if (routes.props.preserveScrollPosition || rootRoute.props.preserveScrollPosition) - return; - - window.scrollTo(0, 0); -} - module.exports = Routes; diff --git a/modules/dispatchers/LocationDispatcher.js b/modules/dispatchers/LocationDispatcher.js new file mode 100644 index 0000000000..702da731c1 --- /dev/null +++ b/modules/dispatchers/LocationDispatcher.js @@ -0,0 +1,18 @@ +var copyProperties = require('react/lib/copyProperties'); +var Dispatcher = require('react-dispatcher'); + +/** + * Dispatches actions that modify the URL. + */ +var LocationDispatcher = copyProperties(new Dispatcher, { + + handleViewAction: function (action) { + this.dispatch({ + source: 'VIEW_ACTION', + action: action + }); + } + +}); + +module.exports = LocationDispatcher; diff --git a/modules/helpers/Transition.js b/modules/helpers/Transition.js index 1104536089..cb96762f37 100644 --- a/modules/helpers/Transition.js +++ b/modules/helpers/Transition.js @@ -1,5 +1,5 @@ var mixInto = require('react/lib/mixInto'); -var transitionTo = require('./transitionTo'); +var transitionTo = require('../actions/LocationActions').transitionTo; var Redirect = require('./Redirect'); /** diff --git a/modules/helpers/goBack.js b/modules/helpers/goBack.js deleted file mode 100644 index 971eaf963d..0000000000 --- a/modules/helpers/goBack.js +++ /dev/null @@ -1,10 +0,0 @@ -var PathStore = require('../stores/PathStore'); - -/** - * Transitions to the previous URL. - */ -function goBack() { - PathStore.pop(); -} - -module.exports = goBack; diff --git a/modules/helpers/replaceWith.js b/modules/helpers/replaceWith.js deleted file mode 100644 index 8018adfd60..0000000000 --- a/modules/helpers/replaceWith.js +++ /dev/null @@ -1,12 +0,0 @@ -var PathStore = require('../stores/PathStore'); -var makePath = require('./makePath'); - -/** - * Transitions to the URL specified in the arguments by replacing - * the current URL in the history stack. - */ -function replaceWith(to, params, query) { - PathStore.replace(makePath(to, params, query)); -} - -module.exports = replaceWith; diff --git a/modules/helpers/transitionTo.js b/modules/helpers/transitionTo.js deleted file mode 100644 index 67603c18ab..0000000000 --- a/modules/helpers/transitionTo.js +++ /dev/null @@ -1,12 +0,0 @@ -var PathStore = require('../stores/PathStore'); -var makePath = require('./makePath'); - -/** - * Transitions to the URL specified in the arguments by pushing - * a new URL onto the history stack. - */ -function transitionTo(to, params, query) { - PathStore.push(makePath(to, params, query)); -} - -module.exports = transitionTo; diff --git a/modules/stores/PathStore.js b/modules/stores/PathStore.js index 8da0ac080e..06beb88204 100644 --- a/modules/stores/PathStore.js +++ b/modules/stores/PathStore.js @@ -1,5 +1,7 @@ var warning = require('react/lib/warning'); var EventEmitter = require('events').EventEmitter; +var LocationActions = require('../actions/LocationActions'); +var LocationDispatcher = require('../dispatchers/LocationDispatcher'); var supportsHistory = require('../helpers/supportsHistory'); var HistoryLocation = require('../locations/HistoryLocation'); var RefreshLocation = require('../locations/RefreshLocation'); @@ -11,6 +13,15 @@ function notifyChange() { _events.emit(CHANGE_EVENT); } +var _scrollPositions = {}; + +function recordScrollPosition(path) { + _scrollPositions[path] = { + x: window.scrollX, + y: window.scrollY + }; +} + var _location; /** @@ -58,27 +69,57 @@ var PathStore = { _location = null; }, + /** + * Returns the location object currently in use. + */ getLocation: function () { return _location; }, - push: function (path) { - if (_location.getCurrentPath() !== path) - _location.push(path); - }, - - replace: function (path) { - if (_location.getCurrentPath() !== path) - _location.replace(path); + /** + * Returns the current URL path. + */ + getCurrentPath: function () { + return _location.getCurrentPath(); }, - pop: function () { - _location.pop(); + /** + * Returns the last known scroll position for the given path. + */ + getScrollPosition: function (path) { + return _scrollPositions[path] || { x: 0, y: 0 }; }, - getCurrentPath: function () { - return _location.getCurrentPath(); - } + dispatchToken: LocationDispatcher.register(function (payload) { + var action = payload.action; + var currentPath = _location.getCurrentPath(); + + switch (action.type) { + case LocationActions.PUSH: + if (currentPath !== action.path) { + recordScrollPosition(currentPath); + _location.push(action.path); + } + break; + + case LocationActions.REPLACE: + if (currentPath !== action.path) { + recordScrollPosition(currentPath); + _location.replace(action.path); + } + break; + + case LocationActions.POP: + recordScrollPosition(currentPath); + _location.pop(); + break; + + case LocationActions.UPDATE_SCROLL: + var p = PathStore.getScrollPosition(currentPath); + window.scrollTo(p.x, p.y); + break; + } + }) }; diff --git a/package.json b/package.json index fbaca1a1ac..015ebd711b 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,8 @@ "dependencies": { "es6-promise": "^1.0.0", "events": "^1.0.1", - "qs": "^1.2.2" + "qs": "^1.2.2", + "react-dispatcher": "^0.2.1" }, "keywords": [ "react", diff --git a/replaceWith.js b/replaceWith.js index b68cd041a5..6b5e81b444 100644 --- a/replaceWith.js +++ b/replaceWith.js @@ -1 +1 @@ -module.exports = require('./modules/helpers/replaceWith'); +module.exports = require('./modules/actions/LocationActions').replaceWith; diff --git a/specs/PathStore.spec.js b/specs/PathStore.spec.js index cad4230dd0..1b199d1031 100644 --- a/specs/PathStore.spec.js +++ b/specs/PathStore.spec.js @@ -1,52 +1,54 @@ require('./helper'); -var MemoryLocation = require('../modules/locations/MemoryLocation'); -var PathStore = require('../modules/stores/PathStore'); +var transitionTo = require('../modules/actions/LocationActions').transitionTo; +var replaceWith = require('../modules/actions/LocationActions').replaceWith; +var goBack = require('../modules/actions/LocationActions').goBack; +var getCurrentPath = require('../modules/stores/PathStore').getCurrentPath; describe('PathStore', function () { beforeEach(function () { - PathStore.push('/one'); + transitionTo('/one'); }); describe('when a new path is pushed to the URL', function () { beforeEach(function () { - PathStore.push('/two'); + transitionTo('/two'); }); it('has the correct path', function () { - expect(PathStore.getCurrentPath()).toEqual('/two'); + expect(getCurrentPath()).toEqual('/two'); }); }); describe('when a new path is used to replace the URL', function () { beforeEach(function () { - PathStore.push('/two'); - PathStore.replace('/three'); + transitionTo('/two'); + replaceWith('/three'); }); it('has the correct path', function () { - expect(PathStore.getCurrentPath()).toEqual('/three'); + expect(getCurrentPath()).toEqual('/three'); }); describe('going back in history', function () { beforeEach(function () { - PathStore.pop(); + goBack(); }); it('has the path before the one that was replaced', function () { - expect(PathStore.getCurrentPath()).toEqual('/one'); + expect(getCurrentPath()).toEqual('/one'); }); }); }); describe('when going back in history', function () { beforeEach(function () { - PathStore.push('/two'); - PathStore.pop(); + transitionTo('/two'); + goBack(); }); it('has the correct path', function () { - expect(PathStore.getCurrentPath()).toEqual('/one'); + expect(getCurrentPath()).toEqual('/one'); }); }); diff --git a/specs/helper.js b/specs/helper.js index af01f3882a..d27ae13320 100644 --- a/specs/helper.js +++ b/specs/helper.js @@ -13,12 +13,13 @@ beforeEach(function () { RouteStore.unregisterAllRoutes(); }); +var transitionTo = require('../modules/actions/LocationActions').transitionTo; var MemoryLocation = require('../modules/locations/MemoryLocation'); var PathStore = require('../modules/stores/PathStore'); beforeEach(function () { PathStore.setup(MemoryLocation); - PathStore.push('/'); + transitionTo('/'); }); afterEach(function () { diff --git a/transitionTo.js b/transitionTo.js index 899b2e7efc..c74ed9b037 100644 --- a/transitionTo.js +++ b/transitionTo.js @@ -1 +1 @@ -module.exports = require('./modules/helpers/transitionTo'); +module.exports = require('./modules/actions/LocationActions').transitionTo;