From 47f05990a1e1fce06681b7159b50d3947426faea Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Mon, 29 Sep 2014 02:44:47 +0400 Subject: [PATCH] [changed] Remove preserveScrollPosition, add scrollStrategy This removes support for `preserveScrollPosition` due to its unforunate naming. Instead, we introduce three scrolling strategies: * `none`: Router doesn't scroll the window when routes change * `scrollToTop` (default): Router always scrolls to top when routes change * `imitateBrowser`: Router tries to act like browser acts with server-rendered pages: it scrolls to top when clicking on links, but tries to restore position when navigating back and forward You can only specify these on . Per-route overrides are not supported, but you can supply a custom strategy object. This also fixes #252. Migration path: The default changed from what corresponded to `imitateBrowser`, to `scrollToTop`. If router's server-rendered scrolling imitation worked well for you, you must now specify it explicitly: ``` // before // after ``` If you wish router to not try to manage scrolling, you must opt out: ``` // before // after ``` Also, as a third option, you may now use the simple `scrollToTop` strategy. --- modules/.DS_Store | Bin 0 -> 6148 bytes modules/actions/LocationActions.js | 110 +++++++++++++++---- modules/components/Routes.js | 7 +- modules/constants/ActionTypes.js | 10 ++ modules/locations/HistoryLocation.js | 2 - modules/locations/MemoryLocation.js | 8 -- modules/mixins/PathListener.js | 45 +++++++- modules/stores/PathStore.js | 106 +++--------------- modules/stores/ScrollStore.js | 65 +++++++++++ modules/strategies/ImitateBrowserStrategy.js | 55 ++++++++++ modules/strategies/NoneStrategy.js | 13 +++ modules/strategies/ScrollToTopStrategy.js | 13 +++ modules/utils/makeHref.js | 4 +- specs/helper.js | 13 ++- 14 files changed, 315 insertions(+), 136 deletions(-) create mode 100644 modules/.DS_Store create mode 100644 modules/constants/ActionTypes.js create mode 100644 modules/stores/ScrollStore.js create mode 100644 modules/strategies/ImitateBrowserStrategy.js create mode 100644 modules/strategies/NoneStrategy.js create mode 100644 modules/strategies/ScrollToTopStrategy.js diff --git a/modules/.DS_Store b/modules/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..2602fd628f0b9a9053cd4cd717fb2a0ca3b58c8b GIT binary patch literal 6148 zcmeHKOKQVF43%0hOtZE5fHD@V)IhtgnY6X>Qqf%Il% zJrDC1mL($EefKMorHHg}LpfQPnmsq4*h6L%2**2)@*$t{g*Z&A=M%<#LiJG&^7@75GuWz7GX%SQE!U|8yYu2mox5 zcEj3d31G1VuqKXy$iOtHz@Tc57#eisOV-uIF)-+&IeciItT~~mpN{tzFBh$Wj8uRM zTr2Px+okpY4gALZe@)_!3Q&Q6rGPf;?YhI2vbRnyXT7$-U*J}AhMQsS6a;U_KySy` gSUZ09qNppj#`Bsu209&irvv#jV7kz#z+WqH0lPaDrT_o{ literal 0 HcmV?d00001 diff --git a/modules/actions/LocationActions.js b/modules/actions/LocationActions.js index 0cf19f75bc..4ce25a145b 100644 --- a/modules/actions/LocationActions.js +++ b/modules/actions/LocationActions.js @@ -1,4 +1,9 @@ +var supportsHistory = require('../utils/supportsHistory'); +var HistoryLocation = require('../locations/HistoryLocation'); +var RefreshLocation = require('../locations/RefreshLocation'); var LocationDispatcher = require('../dispatchers/LocationDispatcher'); +var ActionTypes = require('../constants/ActionTypes'); +var warning = require('react/lib/warning'); var isAbsoluteURL = require('../utils/isAbsoluteURL'); var makePath = require('../utils/makePath'); @@ -6,15 +11,86 @@ function loadURL(url) { window.location = url; } +var _location = null; +var _isDispatching = false; +var _previousPath = null; + +function dispatchAction(actionType, operation) { + if (_isDispatching) + throw new Error('Cannot handle ' + actionType + ' in the middle of another action.'); + + _isDispatching = true; + + var scrollPosition = { + x: window.scrollX, + y: window.scrollY + }; + + if (typeof operation === 'function') + operation(_location); + + var path = _location.getCurrentPath(); + LocationDispatcher.handleViewAction({ + type: actionType, + path: path, + scrollPosition: scrollPosition + }); + + _isDispatching = false; + _previousPath = path; +} + +function handleChange() { + var path = _location.getCurrentPath(); + + // Ignore changes inside or caused by dispatchAction + if (!_isDispatching && path !== _previousPath) { + dispatchAction(ActionTypes.POP); + } +} + /** * Actions that modify the URL. */ var LocationActions = { - PUSH: 'push', - REPLACE: 'replace', - POP: 'pop', - UPDATE_SCROLL: 'update-scroll', + getLocation: function () { + return _location; + }, + + setup: function (location) { + // When using HistoryLocation, automatically fallback + // to RefreshLocation in browsers that do not support + // the HTML5 history API. + if (location === HistoryLocation && !supportsHistory()) + location = RefreshLocation; + + if (_location !== null) { + warning( + _location === location, + 'Cannot use location %s, already using %s', location, _location + ); + return; + } + + _location = location; + + if (_location !== null) { + dispatchAction(ActionTypes.SETUP, function (location) { + if (typeof location.setup === 'function') + location.setup(handleChange); + }); + } + }, + + teardown: function () { + if (_location !== null) { + if (typeof _location.teardown === 'function') + _location.teardown(); + + _location = null; + } + }, /** * Transitions to the URL specified in the arguments by pushing @@ -24,9 +100,9 @@ var LocationActions = { if (isAbsoluteURL(to)) { loadURL(to); } else { - LocationDispatcher.handleViewAction({ - type: LocationActions.PUSH, - path: makePath(to, params, query) + dispatchAction(ActionTypes.PUSH, function (location) { + var path = makePath(to, params, query); + location.push(path); }); } }, @@ -39,9 +115,9 @@ var LocationActions = { if (isAbsoluteURL(to)) { loadURL(to); } else { - LocationDispatcher.handleViewAction({ - type: LocationActions.REPLACE, - path: makePath(to, params, query) + dispatchAction(ActionTypes.REPLACE, function (location) { + var path = makePath(to, params, query); + location.replace(path); }); } }, @@ -50,18 +126,8 @@ var LocationActions = { * 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 + dispatchAction(ActionTypes.POP, function (location) { + location.pop(); }); } diff --git a/modules/components/Routes.js b/modules/components/Routes.js index 3a437048a2..9530440da1 100644 --- a/modules/components/Routes.js +++ b/modules/components/Routes.js @@ -7,6 +7,7 @@ var Route = require('../components/Route'); var ActiveDelegate = require('../mixins/ActiveDelegate'); var PathListener = require('../mixins/PathListener'); var RouteStore = require('../stores/RouteStore'); +var ScrollStore = require('../stores/ScrollStore'); var Path = require('../utils/Path'); var Promise = require('../utils/Promise'); var Redirect = require('../utils/Redirect'); @@ -51,9 +52,11 @@ function maybeUpdateScroll(routes) { return; var currentRoute = routes.getCurrentRoute(); + var scrollPosition = ScrollStore.getScrollPosition(); - if (!routes.props.preserveScrollPosition && currentRoute && !currentRoute.props.preserveScrollPosition) - LocationActions.updateScroll(); + if (currentRoute && scrollPosition) { + window.scrollTo(scrollPosition.x, scrollPosition.y); + } } /** diff --git a/modules/constants/ActionTypes.js b/modules/constants/ActionTypes.js new file mode 100644 index 0000000000..087e59b4ba --- /dev/null +++ b/modules/constants/ActionTypes.js @@ -0,0 +1,10 @@ +var keyMirror = require('react/lib/keyMirror'); + +var ActionTypes = keyMirror({ + SETUP: null, + PUSH: null, + REPLACE: null, + POP: null +}); + +module.exports = ActionTypes; diff --git a/modules/locations/HistoryLocation.js b/modules/locations/HistoryLocation.js index 98a9a3b64d..c573f0da2a 100644 --- a/modules/locations/HistoryLocation.js +++ b/modules/locations/HistoryLocation.js @@ -34,12 +34,10 @@ var HistoryLocation = { push: function (path) { window.history.pushState({ path: path }, '', path); - _onChange(); }, replace: function (path) { window.history.replaceState({ path: path }, '', path); - _onChange(); }, pop: function () { diff --git a/modules/locations/MemoryLocation.js b/modules/locations/MemoryLocation.js index 57f39b099d..5af42f2d24 100644 --- a/modules/locations/MemoryLocation.js +++ b/modules/locations/MemoryLocation.js @@ -2,26 +2,19 @@ var warning = require('react/lib/warning'); var _lastPath = null; var _currentPath = null; -var _onChange; /** * A Location that does not require a DOM. */ var MemoryLocation = { - setup: function (onChange) { - _onChange = onChange; - }, - push: function (path) { _lastPath = _currentPath; _currentPath = path; - _onChange(); }, replace: function (path) { _currentPath = path; - _onChange(); }, pop: function () { @@ -32,7 +25,6 @@ var MemoryLocation = { _currentPath = _lastPath; _lastPath = null; - _onChange(); }, getCurrentPath: function () { diff --git a/modules/mixins/PathListener.js b/modules/mixins/PathListener.js index 4e5dd937c3..c52c83e53b 100644 --- a/modules/mixins/PathListener.js +++ b/modules/mixins/PathListener.js @@ -1,9 +1,13 @@ -var React = require('react'); +var LocationActions = require('../actions/LocationActions'); var DefaultLocation = require('../locations/DefaultLocation'); var HashLocation = require('../locations/HashLocation'); var HistoryLocation = require('../locations/HistoryLocation'); var RefreshLocation = require('../locations/RefreshLocation'); +var NoneStrategy = require('../strategies/NoneStrategy'); +var ScrollToTopStrategy = require('../strategies/ScrollToTopStrategy'); +var ImitateBrowserStrategy = require('../strategies/ImitateBrowserStrategy'); var PathStore = require('../stores/PathStore'); +var ScrollStore = require('../stores/ScrollStore'); /** * A hash of { name, location } pairs. @@ -14,6 +18,15 @@ var NAMED_LOCATIONS = { refresh: RefreshLocation }; +/** + * A hash of { name, scrollStrategy } pairs. + */ +var NAMED_SCROLL_STRATEGIES = { + none: NoneStrategy, + scrollToTop: ScrollToTopStrategy, + imitateBrowser: ImitateBrowserStrategy +}; + /** * A mixin for components that listen for changes to the current * URL path. @@ -26,12 +39,20 @@ var PathListener = { if (typeof location === 'string' && !(location in NAMED_LOCATIONS)) return new Error('Unknown location "' + location + '", see ' + componentName); - } + }, + + scrollStrategy: function (props, propName, componentName) { + var scrollStrategy = props[propName]; + + if (typeof scrollStrategy === 'string' && !(scrollStrategy in NAMED_SCROLL_STRATEGIES)) + return new Error('Unknown scrollStrategy "' + scrollStrategy + '", see ' + componentName); + }, }, getDefaultProps: function () { return { - location: DefaultLocation + location: DefaultLocation, + scrollStrategy: ScrollToTopStrategy }; }, @@ -48,8 +69,22 @@ var PathListener = { return location; }, + /** + * Gets the scroll strategy object this component uses to + * restore scroll position when the path changes. + */ + getScrollStrategy: function () { + var scrollStrategy = this.props.scrollStrategy; + + if (typeof scrollStrategy === 'string') + return NAMED_SCROLL_STRATEGIES[scrollStrategy]; + + return scrollStrategy; + }, + componentWillMount: function () { - PathStore.setup(this.getLocation()); + ScrollStore.setup(this.getScrollStrategy()); + LocationActions.setup(this.getLocation()); if (this.updatePath) this.updatePath(PathStore.getCurrentPath()); @@ -61,6 +96,8 @@ var PathListener = { componentWillUnmount: function () { PathStore.removeChangeListener(this.handlePathChange); + ScrollStore.teardown(); + LocationActions.teardown(); }, handlePathChange: function () { diff --git a/modules/stores/PathStore.js b/modules/stores/PathStore.js index ff73858a7e..b4474ab964 100644 --- a/modules/stores/PathStore.js +++ b/modules/stores/PathStore.js @@ -1,34 +1,16 @@ -var warning = require('react/lib/warning'); var EventEmitter = require('events').EventEmitter; -var LocationActions = require('../actions/LocationActions'); +var ActionTypes = require('../constants/ActionTypes'); var LocationDispatcher = require('../dispatchers/LocationDispatcher'); -var supportsHistory = require('../utils/supportsHistory'); -var HistoryLocation = require('../locations/HistoryLocation'); -var RefreshLocation = require('../locations/RefreshLocation'); +var ScrollStore = require('./ScrollStore'); var CHANGE_EVENT = 'change'; var _events = new EventEmitter; +var _currentPath = null; function notifyChange() { _events.emit(CHANGE_EVENT); } -var _scrollPositions = {}; - -function recordScrollPosition(path) { - _scrollPositions[path] = { - x: window.scrollX, - y: window.scrollY - }; -} - -function updateScrollPosition(path) { - var p = PathStore.getScrollPosition(path); - window.scrollTo(p.x, p.y); -} - -var _location; - /** * The PathStore keeps track of the current URL path and manages * the location strategy that is used to update the URL. @@ -41,88 +23,30 @@ var PathStore = { removeChangeListener: function (listener) { _events.removeListener(CHANGE_EVENT, listener); - - // Automatically teardown when the last listener is removed. - if (EventEmitter.listenerCount(_events, CHANGE_EVENT) === 0) - PathStore.teardown(); - }, - - setup: function (location) { - // When using HistoryLocation, automatically fallback - // to RefreshLocation in browsers that do not support - // the HTML5 history API. - if (location === HistoryLocation && !supportsHistory()) - location = RefreshLocation; - - if (_location == null) { - _location = location; - - if (_location && typeof _location.setup === 'function') - _location.setup(notifyChange); - } else { - warning( - _location === location, - 'Cannot use location %s, already using %s', location, _location - ); - } - }, - - teardown: function () { - _events.removeAllListeners(CHANGE_EVENT); - - if (_location && typeof _location.teardown === 'function') - _location.teardown(); - - _location = null; - }, - - /** - * Returns the location object currently in use. - */ - getLocation: function () { - return _location; }, /** * Returns the current URL path. */ getCurrentPath: function () { - return _location.getCurrentPath(); - }, - - /** - * Returns the last known scroll position for the given path. - */ - getScrollPosition: function (path) { - return _scrollPositions[path] || { x: 0, y: 0 }; + return _currentPath; }, dispatchToken: LocationDispatcher.register(function (payload) { + LocationDispatcher.waitFor([ScrollStore.dispatchToken]); + var action = payload.action; - var currentPath = _location.getCurrentPath(); + if (_currentPath === action.path) { + return; + } 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: - updateScrollPosition(currentPath); + case ActionTypes.SETUP: + case ActionTypes.PUSH: + case ActionTypes.REPLACE: + case ActionTypes.POP: + _currentPath = action.path; + notifyChange(); break; } }) diff --git a/modules/stores/ScrollStore.js b/modules/stores/ScrollStore.js new file mode 100644 index 0000000000..1195aa0138 --- /dev/null +++ b/modules/stores/ScrollStore.js @@ -0,0 +1,65 @@ +var LocationDispatcher = require('../dispatchers/LocationDispatcher'); +var ActionTypes = require('../constants/ActionTypes'); +var warning = require('react/lib/warning'); + +var _scrollStrategy = null; +var _scrollPosition = null; + +/** + * The ScrollStore keeps track of the scrolling position + * that needs to be set after the path change, and + * the scrolling strategy that is used to determine it. + */ +var ScrollStore = { + setup: function (scrollStrategy) { + if (_scrollStrategy !== null) { + warning( + _scrollStrategy === scrollStrategy, + 'Cannot use strategy %s, already using %s', scrollStrategy, _scrollStrategy + ); + return; + } + + _scrollPosition = null; + _scrollStrategy = scrollStrategy; + + if (typeof _scrollStrategy.setup === 'function') + _scrollStrategy.setup(); + }, + + teardown: function () { + if (_scrollStrategy !== null) { + if (typeof _scrollStrategy.teardown === 'function') + _scrollStrategy.teardown(); + + _scrollStrategy = null; + _scrollPosition = null; + } + }, + + /** + * Returns the scroll position for the current path + * according to the strategy specified in . + * + * When falsy, the router won't attempt to restore scroll position. + */ + getScrollPosition: function () { + return _scrollPosition; + }, + + dispatchToken: LocationDispatcher.register(function (payload) { + var action = payload.action; + + switch (action.type) { + case ActionTypes.SETUP: + case ActionTypes.PUSH: + case ActionTypes.REPLACE: + case ActionTypes.POP: + _scrollPosition = _scrollStrategy.getScrollPosition(payload.action); + break; + } + }) + +}; + +module.exports = ScrollStore; \ No newline at end of file diff --git a/modules/strategies/ImitateBrowserStrategy.js b/modules/strategies/ImitateBrowserStrategy.js new file mode 100644 index 0000000000..477665de14 --- /dev/null +++ b/modules/strategies/ImitateBrowserStrategy.js @@ -0,0 +1,55 @@ +var ActionTypes = require('../constants/ActionTypes'); + +var _currentPath = null; +var _scrollPositions = {}; + +function getScrollPosition(path) { + return _scrollPositions[path] || { x: 0, y: 0 }; +} + +function resetScrollPosition(path) { + _scrollPositions[path] = { x: 0, y: 0 }; + return getScrollPosition(path); +} + +var ImitateBrowserStrategy = { + + getScrollPosition: function (action) { + if (action.path === _currentPath) { + return; + } + + // Record scroll position before the action + if (_currentPath) { + _scrollPositions[_currentPath] = action.scrollPosition; + } + _currentPath = action.path; + + switch (action.type) { + case ActionTypes.SETUP: + // For the initial load, let browser scroll where it wants to + return null; + + case ActionTypes.PUSH: + case ActionTypes.REPLACE: + // Reset stored position on manually initiated transitions + return resetScrollPosition(action.path); + + case ActionTypes.POP: + // Try to restore saved position + return getScrollPosition(action.path); + } + }, + + teardown: function () { + _currentPath = null; + _scrollPositions = {}; + }, + + toString: function () { + return ''; + } + +}; + +module.exports = ImitateBrowserStrategy; \ No newline at end of file diff --git a/modules/strategies/NoneStrategy.js b/modules/strategies/NoneStrategy.js new file mode 100644 index 0000000000..a8c0d23e58 --- /dev/null +++ b/modules/strategies/NoneStrategy.js @@ -0,0 +1,13 @@ +var NoneStrategy = { + + getScrollPosition: function () { + return null; + }, + + toString: function () { + return ''; + } + +}; + +module.exports = NoneStrategy; \ No newline at end of file diff --git a/modules/strategies/ScrollToTopStrategy.js b/modules/strategies/ScrollToTopStrategy.js new file mode 100644 index 0000000000..d081dc21fe --- /dev/null +++ b/modules/strategies/ScrollToTopStrategy.js @@ -0,0 +1,13 @@ +var ScrollToTopStrategy = { + + getScrollPosition: function () { + return { x: 0, y: 0 }; + }, + + toString: function () { + return ''; + } + +}; + +module.exports = ScrollToTopStrategy; \ No newline at end of file diff --git a/modules/utils/makeHref.js b/modules/utils/makeHref.js index 919d65a76c..d6d8b034aa 100644 --- a/modules/utils/makeHref.js +++ b/modules/utils/makeHref.js @@ -1,5 +1,5 @@ var HashLocation = require('../locations/HashLocation'); -var PathStore = require('../stores/PathStore'); +var LocationActions = require('../actions/LocationActions'); var makePath = require('./makePath'); /** @@ -9,7 +9,7 @@ var makePath = require('./makePath'); function makeHref(to, params, query) { var path = makePath(to, params, query); - if (PathStore.getLocation() === HashLocation) + if (LocationActions.getLocation() === HashLocation) return '#' + path; return path; diff --git a/specs/helper.js b/specs/helper.js index d27ae13320..6b35efd789 100644 --- a/specs/helper.js +++ b/specs/helper.js @@ -13,15 +13,18 @@ beforeEach(function () { RouteStore.unregisterAllRoutes(); }); -var transitionTo = require('../modules/actions/LocationActions').transitionTo; var MemoryLocation = require('../modules/locations/MemoryLocation'); -var PathStore = require('../modules/stores/PathStore'); +var ScrollToTopStrategy = require('../modules/strategies/ScrollToTopStrategy'); +var LocationActions = require('../modules/actions/LocationActions'); +var ScrollStore = require('../modules/stores/ScrollStore'); beforeEach(function () { - PathStore.setup(MemoryLocation); - transitionTo('/'); + ScrollStore.setup(ScrollToTopStrategy); + LocationActions.setup(MemoryLocation); + LocationActions.transitionTo('/'); }); afterEach(function () { - PathStore.teardown(); + ScrollStore.teardown(); + LocationActions.teardown(); });