diff --git a/modules/.DS_Store b/modules/.DS_Store
new file mode 100644
index 0000000000..2602fd628f
Binary files /dev/null and b/modules/.DS_Store differ
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();
});