From f2bf4bd709336fe29dffa501199bf8bcf9126009 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Sat, 27 Sep 2014 13:14:17 -0700 Subject: [PATCH] [removed] RouteStore [added] Router.PathState for keeping track of the current URL path [added] Router.RouteLookup for looking up routes [added] Router.Transitions for transitioning to other routes [added] Pluggable scroll behaviors [changed] => [removed] [removed] Router.transitionTo, Router.replaceWith, Router.goBack --- modules/actions/LocationActions.js | 58 +--- modules/components/Link.js | 12 +- modules/components/Routes.js | 431 +-------------------------- modules/index.js | 9 +- modules/locations/DefaultLocation.js | 5 - modules/locations/HashLocation.js | 64 ++-- modules/locations/HistoryLocation.js | 64 +++- modules/locations/MemoryLocation.js | 43 ++- modules/locations/RefreshLocation.js | 10 +- modules/mixins/ActiveDelegate.js | 17 +- modules/mixins/ActiveState.js | 14 +- modules/mixins/PathDelegate.js | 115 +++++++ modules/mixins/PathListener.js | 73 ----- modules/mixins/PathState.js | 116 +++++++ modules/mixins/RouteContainer.js | 164 ++++++++++ modules/mixins/RouteLookup.js | 44 +++ modules/mixins/ScrollDelegate.js | 39 +++ modules/mixins/ScrollState.js | 103 +++++++ modules/mixins/TransitionHandler.js | 418 ++++++++++++++++++++++++++ modules/mixins/Transitions.js | 35 +++ modules/stores/PathStore.js | 101 +------ modules/stores/RouteStore.js | 156 ---------- modules/utils/Transition.js | 6 +- modules/utils/getWindowPath.js | 1 - modules/utils/isAbsoluteURL.js | 11 - modules/utils/makeHref.js | 18 -- modules/utils/makePath.js | 28 -- modules/utils/reversedArray.js | 5 + 28 files changed, 1222 insertions(+), 938 deletions(-) delete mode 100644 modules/locations/DefaultLocation.js create mode 100644 modules/mixins/PathDelegate.js delete mode 100644 modules/mixins/PathListener.js create mode 100644 modules/mixins/PathState.js create mode 100644 modules/mixins/RouteContainer.js create mode 100644 modules/mixins/RouteLookup.js create mode 100644 modules/mixins/ScrollDelegate.js create mode 100644 modules/mixins/ScrollState.js create mode 100644 modules/mixins/TransitionHandler.js create mode 100644 modules/mixins/Transitions.js delete mode 100644 modules/stores/RouteStore.js delete mode 100644 modules/utils/isAbsoluteURL.js delete mode 100644 modules/utils/makeHref.js delete mode 100644 modules/utils/makePath.js create mode 100644 modules/utils/reversedArray.js diff --git a/modules/actions/LocationActions.js b/modules/actions/LocationActions.js index 0cf19f75bc..46a1be6634 100644 --- a/modules/actions/LocationActions.js +++ b/modules/actions/LocationActions.js @@ -1,69 +1,27 @@ -var LocationDispatcher = require('../dispatchers/LocationDispatcher'); -var isAbsoluteURL = require('../utils/isAbsoluteURL'); -var makePath = require('../utils/makePath'); - -function loadURL(url) { - window.location = url; -} - /** * 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. + * Indicates a location is being setup for the first time. */ - transitionTo: function (to, params, query) { - if (isAbsoluteURL(to)) { - loadURL(to); - } else { - LocationDispatcher.handleViewAction({ - type: LocationActions.PUSH, - path: makePath(to, params, query) - }); - } - }, + SETUP: 'setup', /** - * Transitions to the URL specified in the arguments by replacing - * the current URL in the history stack. + * Indicates a new location is being pushed to the history stack. */ - replaceWith: function (to, params, query) { - if (isAbsoluteURL(to)) { - loadURL(to); - } else { - LocationDispatcher.handleViewAction({ - type: LocationActions.REPLACE, - path: makePath(to, params, query) - }); - } - }, + PUSH: 'push', /** - * Transitions to the previous URL. + * Indicates the current location should be replaced. */ - goBack: function () { - LocationDispatcher.handleViewAction({ - type: LocationActions.POP - }); - }, + REPLACE: 'replace', /** - * Updates the window's scroll position to the last known position - * for the current URL path. + * Indicates the most recent entry should be removed from the history stack. */ - updateScroll: function () { - LocationDispatcher.handleViewAction({ - type: LocationActions.UPDATE_SCROLL - }); - } + POP: 'pop' }; diff --git a/modules/components/Link.js b/modules/components/Link.js index fe30013958..1dd114d6d4 100644 --- a/modules/components/Link.js +++ b/modules/components/Link.js @@ -1,10 +1,10 @@ var React = require('react'); +var warning = require('react/lib/warning'); var ActiveState = require('../mixins/ActiveState'); -var transitionTo = require('../actions/LocationActions').transitionTo; +var RouteLookup = require('../mixins/RouteLookup'); +var Transitions = require('../mixins/Transitions'); var withoutProperties = require('../utils/withoutProperties'); var hasOwnProperty = require('../utils/hasOwnProperty'); -var makeHref = require('../utils/makeHref'); -var warning = require('react/lib/warning'); function isLeftClickEvent(event) { return event.button === 0; @@ -51,7 +51,7 @@ var Link = React.createClass({ displayName: 'Link', - mixins: [ ActiveState ], + mixins: [ ActiveState, RouteLookup, Transitions ], statics: { @@ -99,7 +99,7 @@ var Link = React.createClass({ * Returns the value of the "href" attribute to use on the DOM element. */ getHref: function () { - return makeHref(this.props.to, Link.getParams(this.props), this.props.query); + return this.makeHref(this.props.to, Link.getParams(this.props), this.props.query); }, /** @@ -145,7 +145,7 @@ var Link = React.createClass({ event.preventDefault(); if (allowTransition) - transitionTo(this.props.to, Link.getParams(this.props), this.props.query); + this.transitionTo(this.props.to, Link.getParams(this.props), this.props.query); }, render: function () { diff --git a/modules/components/Routes.js b/modules/components/Routes.js index 3a437048a2..6296d8a747 100644 --- a/modules/components/Routes.js +++ b/modules/components/Routes.js @@ -1,60 +1,5 @@ var React = require('react'); -var warning = require('react/lib/warning'); -var copyProperties = require('react/lib/copyProperties'); -var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; -var LocationActions = require('../actions/LocationActions'); -var Route = require('../components/Route'); -var ActiveDelegate = require('../mixins/ActiveDelegate'); -var PathListener = require('../mixins/PathListener'); -var RouteStore = require('../stores/RouteStore'); -var Path = require('../utils/Path'); -var Promise = require('../utils/Promise'); -var Redirect = require('../utils/Redirect'); -var Transition = require('../utils/Transition'); - -/** - * The ref name that can be used to reference the active route component. - */ -var REF_NAME = '__activeRoute__'; - -/** - * The default handler for aborted transitions. Redirects replace - * the current URL and all others roll it back. - */ -function defaultAbortedTransitionHandler(transition) { - if (!canUseDOM) - return; - - var reason = transition.abortReason; - - if (reason instanceof Redirect) { - LocationActions.replaceWith(reason.to, reason.params, reason.query); - } else { - LocationActions.goBack(); - } -} - -/** - * The default handler for errors that were thrown asynchronously - * while transitioning. The default behavior is to re-throw the - * error so that it isn't silently swallowed. - */ -function defaultTransitionErrorHandler(error) { - throw error; // This error probably originated in a transition hook. -} - -/** - * Updates the window's scroll position given the current route. - */ -function maybeUpdateScroll(routes) { - if (!canUseDOM) - return; - - var currentRoute = routes.getCurrentRoute(); - - if (!routes.props.preserveScrollPosition && currentRoute && !currentRoute.props.preserveScrollPosition) - LocationActions.updateScroll(); -} +var TransitionHandler = require('../mixins/TransitionHandler'); /** * The component configures the route hierarchy and renders the @@ -66,379 +11,19 @@ var Routes = React.createClass({ displayName: 'Routes', - mixins: [ ActiveDelegate, PathListener ], - - propTypes: { - onAbortedTransition: React.PropTypes.func.isRequired, - onTransitionError: React.PropTypes.func.isRequired, - preserveScrollPosition: React.PropTypes.bool - }, - - getDefaultProps: function () { - return { - onAbortedTransition: defaultAbortedTransitionHandler, - onTransitionError: defaultTransitionErrorHandler, - preserveScrollPosition: false - }; - }, - - getInitialState: function () { - return { - matches: [], - routes: RouteStore.registerChildren(this.props.children, this) - }; - }, - - /** - * Gets the component that is currently active. - */ - getCurrentRoute: function () { - var rootMatch = getRootMatch(this.state.matches); - return rootMatch && rootMatch.route; - }, - - /** - * Performs a depth-first search for the first route in the tree that matches - * on the given path. Returns an array of all routes in the tree leading to - * the one that matched in the format { route, params } where params is an - * object that contains the URL parameters relevant to that route. Returns - * null if no route in the tree matches the path. - * - * React.renderComponent( - * - * - * - * - * - * - * ).match('/posts/123'); => [ { route: , params: {} }, - * { route: , params: { id: '123' } } ] - */ - match: function (path) { - return findMatches(Path.withoutQuery(path), this.state.routes, this.props.defaultRoute, this.props.notFoundRoute); - }, - - updatePath: function (path) { - var self = this; - - this.dispatch(path, function (error, transition) { - if (error) { - self.props.onTransitionError(error); - } else if (transition.isAborted) { - self.props.onAbortedTransition(transition); - } else { - self.emitChange(); - maybeUpdateScroll(self); - } - }); - }, - - /** - * Performs a transition to the given path and calls callback(error, transition) - * with the Transition object when the transition is finished and the component's - * state has been updated accordingly. - * - * In a transition, the router first determines which routes are involved by - * beginning with the current route, up the route tree to the first parent route - * that is shared with the destination route, and back down the tree to the - * destination route. The willTransitionFrom hook is invoked on all route handlers - * we're transitioning away from, in reverse nesting order. Likewise, the - * willTransitionTo hook is invoked on all route handlers we're transitioning to. - * - * Both willTransitionFrom and willTransitionTo hooks may either abort or redirect - * the transition. To resolve asynchronously, they may use transition.wait(promise). - * - * Note: This function does not update the URL in a browser's location bar. - */ - dispatch: function (path, callback) { - var transition = new Transition(path); - var self = this; - - computeNextState(this, transition, function (error, nextState) { - if (error || nextState == null) - return callback(error, transition); - - self.setState(nextState, function () { - callback(null, transition); - }); - }); - }, + mixins: [ TransitionHandler ], render: function () { - if (!this.state.path) - return null; + var match = this.state.matches[0]; - var matches = this.state.matches; - if (matches.length) { - // matches[0] corresponds to the top-most match - return matches[0].route.props.handler(computeHandlerProps(matches, this.state.activeQuery)); - } else { + if (match == null) return null; - } - } - -}); - -function findMatches(path, routes, defaultRoute, notFoundRoute) { - var matches = null, route, params; - - for (var i = 0, len = routes.length; i < len; ++i) { - route = routes[i]; - - // Check the subtree first to find the most deeply-nested match. - matches = findMatches(path, route.props.children, route.props.defaultRoute, route.props.notFoundRoute); - - if (matches != null) { - var rootParams = getRootMatch(matches).params; - - params = route.props.paramNames.reduce(function (params, paramName) { - params[paramName] = rootParams[paramName]; - return params; - }, {}); - - matches.unshift(makeMatch(route, params)); - - return matches; - } - - // No routes in the subtree matched, so check this route. - params = Path.extractParams(route.props.path, path); - - if (params) - return [ makeMatch(route, params) ]; - } - - // No routes matched, so try the default route if there is one. - if (defaultRoute && (params = Path.extractParams(defaultRoute.props.path, path))) - return [ makeMatch(defaultRoute, params) ]; - - // Last attempt: does the "not found" route match? - if (notFoundRoute && (params = Path.extractParams(notFoundRoute.props.path, path))) - return [ makeMatch(notFoundRoute, params) ]; - - return matches; -} - -function makeMatch(route, params) { - return { route: route, params: params }; -} - -function hasMatch(matches, match) { - return matches.some(function (m) { - if (m.route !== match.route) - return false; - - for (var property in m.params) { - if (m.params[property] !== match.params[property]) - return false; - } - - return true; - }); -} - -function getRootMatch(matches) { - return matches[matches.length - 1]; -} -function updateMatchComponents(matches, refs) { - var i = 0, component; - while (component = refs[REF_NAME]) { - matches[i++].component = component; - refs = component.refs; + return match.route.props.handler( + this.getHandlerProps() + ); } -} -/** - * Computes the next state for the given component and calls - * callback(error, nextState) when finished. Also runs all transition - * hooks along the way. - */ -function computeNextState(component, transition, callback) { - if (component.state.path === transition.path) - return callback(); // Nothing to do! - - var currentMatches = component.state.matches; - var nextMatches = component.match(transition.path); - - warning( - nextMatches, - 'No route matches path "' + transition.path + '". Make sure you have ' + - ' somewhere in your routes' - ); - - if (!nextMatches) - nextMatches = []; - - var fromMatches, toMatches; - if (currentMatches.length) { - updateMatchComponents(currentMatches, component.refs); - - fromMatches = currentMatches.filter(function (match) { - return !hasMatch(nextMatches, match); - }); - - toMatches = nextMatches.filter(function (match) { - return !hasMatch(currentMatches, match); - }); - } else { - fromMatches = []; - toMatches = nextMatches; - } - - var query = Path.extractQuery(transition.path) || {}; - - runTransitionFromHooks(fromMatches, transition, function (error) { - if (error || transition.isAborted) - return callback(error); - - runTransitionToHooks(toMatches, transition, query, function (error) { - if (error || transition.isAborted) - return callback(error); - - var matches = currentMatches.slice(0, -fromMatches.length).concat(toMatches); - var rootMatch = getRootMatch(matches); - var params = (rootMatch && rootMatch.params) || {}; - var routes = matches.map(function (match) { - return match.route; - }); - - callback(null, { - path: transition.path, - matches: matches, - activeRoutes: routes, - activeParams: params, - activeQuery: query - }); - }); - }); -} - -/** - * Calls the willTransitionFrom hook of all handlers in the given matches - * serially in reverse with the transition object and the current instance of - * the route's handler, so that the deepest nested handlers are called first. - * Calls callback(error) when finished. - */ -function runTransitionFromHooks(matches, transition, callback) { - var hooks = reversedArray(matches).map(function (match) { - return function () { - var handler = match.route.props.handler; - - if (!transition.isAborted && handler.willTransitionFrom) - return handler.willTransitionFrom(transition, match.component); - - var promise = transition.promise; - delete transition.promise; - - return promise; - }; - }); - - runHooks(hooks, callback); -} - -/** - * Calls the willTransitionTo hook of all handlers in the given matches - * serially with the transition object and any params that apply to that - * handler. Calls callback(error) when finished. - */ -function runTransitionToHooks(matches, transition, query, callback) { - var hooks = matches.map(function (match) { - return function () { - var handler = match.route.props.handler; - - if (!transition.isAborted && handler.willTransitionTo) - handler.willTransitionTo(transition, match.params, query); - - var promise = transition.promise; - delete transition.promise; - - return promise; - }; - }); - - runHooks(hooks, callback); -} - -/** - * Runs all hook functions serially and calls callback(error) when finished. - * A hook may return a promise if it needs to execute asynchronously. - */ -function runHooks(hooks, callback) { - try { - var promise = hooks.reduce(function (promise, hook) { - // The first hook to use transition.wait makes the rest - // of the transition async from that point forward. - return promise ? promise.then(hook) : hook(); - }, null); - } catch (error) { - return callback(error); // Sync error. - } - - if (promise) { - // Use setTimeout to break the promise chain. - promise.then(function () { - setTimeout(callback); - }, function (error) { - setTimeout(function () { - callback(error); - }); - }); - } else { - callback(); - } -} - -/** - * Given an array of matches as returned by findMatches, return a descriptor for - * the handler hierarchy specified by the route. - */ -function computeHandlerProps(matches, query) { - var props = { - ref: null, - key: null, - params: null, - query: null, - activeRouteHandler: returnNull - }; - - var childHandler; - reversedArray(matches).forEach(function (match) { - var route = match.route; - - props = Route.getUnreservedProps(route.props); - - props.ref = REF_NAME; - props.params = match.params; - props.query = query; - - if (route.props.addHandlerKey) - props.key = Path.injectParams(route.props.path, match.params); - - if (childHandler) { - props.activeRouteHandler = childHandler; - } else { - props.activeRouteHandler = returnNull; - } - - childHandler = function (props, addedProps) { - if (arguments.length > 2 && typeof arguments[2] !== 'undefined') - throw new Error('Passing children to a route handler is not supported'); - - return route.props.handler(copyProperties(props, addedProps)); - }.bind(this, props); - }); - - return props; -} - -function returnNull() { - return null; -} - -function reversedArray(array) { - return array.slice(0).reverse(); -} +}); module.exports = Routes; diff --git a/modules/index.js b/modules/index.js index 41866e1196..81813a5864 100644 --- a/modules/index.js +++ b/modules/index.js @@ -1,7 +1,3 @@ -exports.goBack = require('./actions/LocationActions').goBack; -exports.replaceWith = require('./actions/LocationActions').replaceWith; -exports.transitionTo = require('./actions/LocationActions').transitionTo; - exports.DefaultRoute = require('./components/DefaultRoute'); exports.Link = require('./components/Link'); exports.NotFoundRoute = require('./components/NotFoundRoute'); @@ -11,5 +7,6 @@ exports.Routes = require('./components/Routes'); exports.ActiveState = require('./mixins/ActiveState'); exports.AsyncState = require('./mixins/AsyncState'); - -exports.makeHref = require('./utils/makeHref'); +exports.PathState = require('./mixins/PathState'); +exports.RouteLookup = require('./mixins/RouteLookup'); +exports.Transitions = require('./mixins/Transitions'); diff --git a/modules/locations/DefaultLocation.js b/modules/locations/DefaultLocation.js deleted file mode 100644 index 8b739102c0..0000000000 --- a/modules/locations/DefaultLocation.js +++ /dev/null @@ -1,5 +0,0 @@ -var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; - -module.exports = process.env.NODE_ENV === 'test' || !canUseDOM - ? require('./MemoryLocation') - : require('./HashLocation'); diff --git a/modules/locations/HashLocation.js b/modules/locations/HashLocation.js index 3b7410d1d6..f6dc5a3d10 100644 --- a/modules/locations/HashLocation.js +++ b/modules/locations/HashLocation.js @@ -1,9 +1,11 @@ var invariant = require('react/lib/invariant'); var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; +var LocationActions = require('../actions/LocationActions'); +var LocationDispatcher = require('../dispatchers/LocationDispatcher'); var getWindowPath = require('../utils/getWindowPath'); function getHashPath() { - return window.location.hash.substr(1); + return window.location.hash.substr(1) || '/'; } function ensureSlash() { @@ -12,62 +14,84 @@ function ensureSlash() { if (path.charAt(0) === '/') return true; - HashLocation.replace('/' + path); + HashLocation.replace('/' + path, _actionSender); return false; } -var _onChange; +var _actionType, _actionSender; -function handleHashChange() { - if (ensureSlash()) - _onChange(); +function onHashChange() { + if (ensureSlash()) { + LocationDispatcher.handleViewAction({ + type: _actionType, + path: getHashPath(), + sender: _actionSender || window + }); + + _actionSender = null; + } } +var _isSetup = false; + /** * A Location that uses `window.location.hash`. */ var HashLocation = { - setup: function (onChange) { + setup: function () { + if (_isSetup) + return; + invariant( canUseDOM, 'You cannot use HashLocation in an environment with no DOM' ); - _onChange = onChange; - - ensureSlash(); - if (window.addEventListener) { - window.addEventListener('hashchange', handleHashChange, false); + window.addEventListener('hashchange', onHashChange, false); } else { - window.attachEvent('onhashchange', handleHashChange); + window.attachEvent('onhashchange', onHashChange); } + + LocationDispatcher.handleViewAction({ + type: LocationActions.SETUP, + path: getHashPath(), + sender: window + }); + + _isSetup = true; }, teardown: function () { if (window.removeEventListener) { - window.removeEventListener('hashchange', handleHashChange, false); + window.removeEventListener('hashchange', onHashChange, false); } else { - window.detachEvent('onhashchange', handleHashChange); + window.detachEvent('onhashchange', onHashChange); } + + _isSetup = false; }, - push: function (path) { + push: function (path, sender) { + _actionType = LocationActions.PUSH; + _actionSender = sender; window.location.hash = path; }, - replace: function (path) { + replace: function (path, sender) { + _actionType = LocationActions.REPLACE; + _actionSender = sender; window.location.replace(getWindowPath() + '#' + path); }, - pop: function () { + pop: function (sender) { + _actionType = LocationActions.POP; + _actionSender = sender; window.history.back(); }, - getCurrentPath: getHashPath, - toString: function () { return ''; } diff --git a/modules/locations/HistoryLocation.js b/modules/locations/HistoryLocation.js index 98a9a3b64d..90cdd9b0e1 100644 --- a/modules/locations/HistoryLocation.js +++ b/modules/locations/HistoryLocation.js @@ -1,53 +1,87 @@ var invariant = require('react/lib/invariant'); var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; +var LocationActions = require('../actions/LocationActions'); +var LocationDispatcher = require('../dispatchers/LocationDispatcher'); var getWindowPath = require('../utils/getWindowPath'); -var _onChange; +var _actionSender; + +function onPopState() { + LocationDispatcher.handleViewAction({ + type: LocationActions.POP, + path: getWindowPath(), + sender: _actionSender || window + }); + + _actionSender = null; +} + +var _isSetup = false; /** * A Location that uses HTML5 history. */ var HistoryLocation = { - setup: function (onChange) { + setup: function () { + if (_isSetup) + return; + invariant( canUseDOM, 'You cannot use HistoryLocation in an environment with no DOM' ); - _onChange = onChange; - if (window.addEventListener) { - window.addEventListener('popstate', _onChange, false); + window.addEventListener('popstate', onPopState, false); } else { - window.attachEvent('popstate', _onChange); + window.attachEvent('popstate', onPopState); } + + LocationDispatcher.handleViewAction({ + type: LocationActions.SETUP, + path: getWindowPath(), + sender: window + }); + + _isSetup = true; }, teardown: function () { if (window.removeEventListener) { - window.removeEventListener('popstate', _onChange, false); + window.removeEventListener('popstate', onPopState, false); } else { - window.detachEvent('popstate', _onChange); + window.detachEvent('popstate', onPopState); } + + _isSetup = false; }, - push: function (path) { + push: function (path, sender) { window.history.pushState({ path: path }, '', path); - _onChange(); + + LocationDispatcher.handleViewAction({ + type: LocationActions.PUSH, + path: getWindowPath(), + sender: sender + }); }, - replace: function (path) { + replace: function (path, sender) { window.history.replaceState({ path: path }, '', path); - _onChange(); + + LocationDispatcher.handleViewAction({ + type: LocationActions.REPLACE, + path: getWindowPath(), + sender: sender + }); }, - pop: function () { + pop: function (sender) { + _actionSender = sender; window.history.back(); }, - getCurrentPath: getWindowPath, - toString: function () { return ''; } diff --git a/modules/locations/MemoryLocation.js b/modules/locations/MemoryLocation.js index 57f39b099d..9908adc600 100644 --- a/modules/locations/MemoryLocation.js +++ b/modules/locations/MemoryLocation.js @@ -1,30 +1,48 @@ var warning = require('react/lib/warning'); +var LocationActions = require('../actions/LocationActions'); +var LocationDispatcher = require('../dispatchers/LocationDispatcher'); var _lastPath = null; var _currentPath = null; -var _onChange; + +function getCurrentPath() { + return _currentPath || '/'; +} /** * A Location that does not require a DOM. */ var MemoryLocation = { - setup: function (onChange) { - _onChange = onChange; + setup: function () { + LocationDispatcher.handleViewAction({ + type: LocationActions.SETUP, + path: getCurrentPath() + }); }, - push: function (path) { + push: function (path, sender) { _lastPath = _currentPath; _currentPath = path; - _onChange(); + + LocationDispatcher.handleViewAction({ + type: LocationActions.PUSH, + path: getCurrentPath(), + sender: sender + }); }, - replace: function (path) { + replace: function (path, sender) { _currentPath = path; - _onChange(); + + LocationDispatcher.handleViewAction({ + type: LocationActions.REPLACE, + path: getCurrentPath(), + sender: sender + }); }, - pop: function () { + pop: function (sender) { warning( _lastPath != null, 'You cannot use MemoryLocation to go back more than once' @@ -32,11 +50,12 @@ var MemoryLocation = { _currentPath = _lastPath; _lastPath = null; - _onChange(); - }, - getCurrentPath: function () { - return _currentPath || '/'; + LocationDispatcher.handleViewAction({ + type: LocationActions.POP, + path: getCurrentPath(), + sender: sender + }); }, toString: function () { diff --git a/modules/locations/RefreshLocation.js b/modules/locations/RefreshLocation.js index 8112e797d1..c930c6c08d 100644 --- a/modules/locations/RefreshLocation.js +++ b/modules/locations/RefreshLocation.js @@ -1,5 +1,7 @@ var invariant = require('react/lib/invariant'); var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; +var LocationActions = require('../actions/LocationActions'); +var LocationDispatcher = require('../dispatchers/LocationDispatcher'); var getWindowPath = require('../utils/getWindowPath'); /** @@ -14,6 +16,12 @@ var RefreshLocation = { canUseDOM, 'You cannot use RefreshLocation in an environment with no DOM' ); + + LocationDispatcher.handleViewAction({ + type: LocationActions.SETUP, + path: getWindowPath(), + sender: window + }); }, push: function (path) { @@ -28,8 +36,6 @@ var RefreshLocation = { window.history.back(); }, - getCurrentPath: getWindowPath, - toString: function () { return ''; } diff --git a/modules/mixins/ActiveDelegate.js b/modules/mixins/ActiveDelegate.js index 4355d28d16..d8036efa46 100644 --- a/modules/mixins/ActiveDelegate.js +++ b/modules/mixins/ActiveDelegate.js @@ -43,19 +43,24 @@ var ActiveDelegate = { }; }, + getInitialState: function () { + return { + activeRoutes: [], + activeParams: {}, + activeQuery: {} + }; + }, + /** * Returns true if the route with the given name, URL parameters, and * query are all currently active. */ isActive: function (routeName, params, query) { - var activeRoutes = this.state.activeRoutes || []; - var activeParams = this.state.activeParams || {}; - var activeQuery = this.state.activeQuery || {}; - - var isActive = routeIsActive(activeRoutes, routeName) && paramsAreActive(activeParams, params); + var isActive = routeIsActive(this.state.activeRoutes, routeName) && + paramsAreActive(this.state.activeParams, params); if (query) - return isActive && queryIsActive(activeQuery, query); + return isActive && queryIsActive(this.state.activeQuery, query); return isActive; } diff --git a/modules/mixins/ActiveState.js b/modules/mixins/ActiveState.js index e9cabce1e0..a4445561d0 100644 --- a/modules/mixins/ActiveState.js +++ b/modules/mixins/ActiveState.js @@ -11,7 +11,6 @@ var ActiveDelegate = require('./ActiveDelegate'); * 2. An `isActive` method they can use to check if a route, * params, and query are active. * - * * Example: * * var Tab = React.createClass({ @@ -38,24 +37,17 @@ var ActiveState = { activeDelegate: React.PropTypes.any.isRequired }, - /** - * Returns this component's ActiveDelegate component. - */ - getActiveDelegate: function () { - return this.context.activeDelegate; - }, - componentWillMount: function () { if (this.updateActiveState) this.updateActiveState(); }, componentDidMount: function () { - this.getActiveDelegate().addChangeListener(this.handleActiveStateChange); + this.context.activeDelegate.addChangeListener(this.handleActiveStateChange); }, componentWillUnmount: function () { - this.getActiveDelegate().removeChangeListener(this.handleActiveStateChange); + this.context.activeDelegate.removeChangeListener(this.handleActiveStateChange); }, handleActiveStateChange: function () { @@ -68,7 +60,7 @@ var ActiveState = { * query are all currently active. */ isActive: function (routeName, params, query) { - return this.getActiveDelegate().isActive(routeName, params, query); + return this.context.activeDelegate.isActive(routeName, params, query); } }; diff --git a/modules/mixins/PathDelegate.js b/modules/mixins/PathDelegate.js new file mode 100644 index 0000000000..0e31287563 --- /dev/null +++ b/modules/mixins/PathDelegate.js @@ -0,0 +1,115 @@ +var React = require('react'); +var invariant = require('react/lib/invariant'); +var PathState = require('./PathState'); +var RouteContainer = require('./RouteContainer'); +var HashLocation = require('../locations/HashLocation'); +var Path = require('../utils/Path'); + +/** + * A mixin for components that manage the current URL path. + */ +var PathDelegate = { + + mixins: [ PathState, RouteContainer ], + + childContextTypes: { + pathDelegate: React.PropTypes.any.isRequired + }, + + getChildContext: function () { + return { + pathDelegate: this + }; + }, + + /** + * Returns an absolute URL path created from the given route + * name, URL parameters, and query values. + */ + makePath: function (to, params, query) { + var path; + if (Path.isAbsolute(to)) { + path = Path.normalize(to); + } else { + var route = this.getRouteByName(to); + + invariant( + route, + 'Unable to find a route named "' + to + '". Make sure you have ' + + 'a defined somewhere in your ' + ); + + path = route.props.path; + } + + return Path.withQuery(Path.injectParams(path, params), query); + }, + + /** + * Returns a string that may safely be used as the href of a + * link to the route with the given name. + */ + makeHref: function (to, params, query) { + var path = this.makePath(to, params, query); + + if (this.getLocation() === HashLocation) + return '#' + path; + + return path; + }, + + /** + * Transitions to the URL specified in the arguments by pushing + * a new URL onto the history stack. + */ + transitionTo: function (to, params, query, sender) { + sender = sender || this; + + var path = this.makePath(to, params, query); + var location = this.getLocation(); + + // If we have a location, route the transition through it. + if (location) { + location.push(path, this); + } else if (this.updatePath) { + this.updatePath(path, this); + } + }, + + /** + * Transitions to the URL specified in the arguments by replacing + * the current URL in the history stack. + */ + replaceWith: function (to, params, query, sender) { + sender = sender || this; + + var path = this.makePath(to, params, query); + var location = this.getLocation(); + + // If we have a location, route the transition through it. + if (location) { + location.replace(path, sender); + } else if (this.updatePath) { + this.updatePath(path, sender); + } + }, + + /** + * Transitions to the previous URL. + */ + goBack: function (sender) { + sender = sender || this; + + var location = this.getLocation(); + + invariant( + location, + 'You cannot goBack without a location' + ); + + location.pop(sender); + } + +}; + +module.exports = PathDelegate; diff --git a/modules/mixins/PathListener.js b/modules/mixins/PathListener.js deleted file mode 100644 index 4e5dd937c3..0000000000 --- a/modules/mixins/PathListener.js +++ /dev/null @@ -1,73 +0,0 @@ -var React = require('react'); -var DefaultLocation = require('../locations/DefaultLocation'); -var HashLocation = require('../locations/HashLocation'); -var HistoryLocation = require('../locations/HistoryLocation'); -var RefreshLocation = require('../locations/RefreshLocation'); -var PathStore = require('../stores/PathStore'); - -/** - * A hash of { name, location } pairs. - */ -var NAMED_LOCATIONS = { - hash: HashLocation, - history: HistoryLocation, - refresh: RefreshLocation -}; - -/** - * A mixin for components that listen for changes to the current - * URL path. - */ -var PathListener = { - - propTypes: { - location: function (props, propName, componentName) { - var location = props[propName]; - - if (typeof location === 'string' && !(location in NAMED_LOCATIONS)) - return new Error('Unknown location "' + location + '", see ' + componentName); - } - }, - - getDefaultProps: function () { - return { - location: DefaultLocation - }; - }, - - /** - * Gets the location object this component uses to watch for - * changes to the current URL path. - */ - getLocation: function () { - var location = this.props.location; - - if (typeof location === 'string') - return NAMED_LOCATIONS[location]; - - return location; - }, - - componentWillMount: function () { - PathStore.setup(this.getLocation()); - - if (this.updatePath) - this.updatePath(PathStore.getCurrentPath()); - }, - - componentDidMount: function () { - PathStore.addChangeListener(this.handlePathChange); - }, - - componentWillUnmount: function () { - PathStore.removeChangeListener(this.handlePathChange); - }, - - handlePathChange: function () { - if (this.isMounted() && this.updatePath) - this.updatePath(PathStore.getCurrentPath()); - } - -}; - -module.exports = PathListener; diff --git a/modules/mixins/PathState.js b/modules/mixins/PathState.js new file mode 100644 index 0000000000..a06d735a2a --- /dev/null +++ b/modules/mixins/PathState.js @@ -0,0 +1,116 @@ +var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; +var HashLocation = require('../locations/HashLocation'); +var HistoryLocation = require('../locations/HistoryLocation'); +var RefreshLocation = require('../locations/RefreshLocation'); +var PathStore = require('../stores/PathStore'); +var supportsHistory = require('../utils/supportsHistory'); + +/** + * A hash of { name: location } pairs. + */ +var NAMED_LOCATIONS = { + hash: HashLocation, + history: HistoryLocation, + refresh: RefreshLocation +}; + +/** + * A mixin for components that need to know the current URL path. Components + * that use it may specify a `location` prop that they use to track changes + * to the URL. They also get: + * + * 1. An `updatePath` method that is called when the + * current URL path changes + * 2. A `getCurrentPath` method they can use to get + * the current URL path + * + * Example: + * + * var PathWatcher = React.createClass({ + * + * mixins: [ Router.PathState ], + * + * getInitialState: function () { + * return { + * currentPath: this.getCurrentPath() + * }; + * }, + * + * updatePath: function () { + * this.setState({ + * currentPath: this.getCurrentPath() + * }); + * } + * + * }); + */ +var PathState = { + + propTypes: { + + location: function (props, propName, componentName) { + var location = props[propName]; + + if (typeof location === 'string' && !(location in NAMED_LOCATIONS)) + return new Error('Unknown location "' + location + '", see ' + componentName); + } + + }, + + getDefaultProps: function () { + return { + location: canUseDOM ? HashLocation : null, + path: null + }; + }, + + /** + * Gets the location object this component uses to observe the URL. + */ + getLocation: function () { + var location = this.props.location; + + if (typeof location === 'string') + location = NAMED_LOCATIONS[location]; + + // Automatically fall back to full page refreshes in + // browsers that do not support HTML5 history. + if (location === HistoryLocation && !supportsHistory()) + location = RefreshLocation; + + return location; + }, + + componentWillMount: function () { + var location = this.getLocation(); + + if (location && location.setup) + location.setup(); + + if (this.updatePath) + this.updatePath(this.getCurrentPath(), this); + }, + + componentDidMount: function () { + PathStore.addChangeListener(this.handlePathChange); + }, + + componentWillUnmount: function () { + PathStore.removeChangeListener(this.handlePathChange); + }, + + handlePathChange: function (sender) { + if (this.isMounted() && this.updatePath) + this.updatePath(this.getCurrentPath(), sender); + }, + + /** + * Returns the current URL path. + */ + getCurrentPath: function () { + return PathStore.getCurrentPath(); + } + +}; + +module.exports = PathState; diff --git a/modules/mixins/RouteContainer.js b/modules/mixins/RouteContainer.js new file mode 100644 index 0000000000..6f2ee75638 --- /dev/null +++ b/modules/mixins/RouteContainer.js @@ -0,0 +1,164 @@ +var React = require('react'); +var invariant = require('react/lib/invariant'); +var Path = require('../utils/Path'); + +/** + * Performs some normalization and validation on a component and + * all of its children. + */ +function processRoute(route, container, namedRoutes) { + // Note: parentRoute may be a _or_ a . + var props = route.props; + + invariant( + React.isValidClass(props.handler), + 'The handler for the "%s" route must be a valid React class', + props.name || props.path + ); + + var parentPath = (container && container.props.path) || '/'; + + if ((props.path || props.name) && !props.isDefault && !props.catchAll) { + var path = props.path || props.name; + + // Relative paths extend their parent. + if (!Path.isAbsolute(path)) + path = Path.join(parentPath, path); + + props.path = Path.normalize(path); + } else { + props.path = parentPath; + + if (props.catchAll) + props.path += '*'; + } + + props.paramNames = Path.extractParamNames(props.path); + + // Make sure the route's path has all params its parent needs. + if (container && Array.isArray(container.props.paramNames)) { + container.props.paramNames.forEach(function (paramName) { + invariant( + props.paramNames.indexOf(paramName) !== -1, + 'The nested route path "%s" is missing the "%s" parameter of its parent path "%s"', + props.path, paramName, container.props.path + ); + }); + } + + // Make sure the route can be looked up by s. + if (props.name) { + var existingRoute = namedRoutes[props.name]; + + invariant( + !existingRoute || route === existingRoute, + 'You cannot use the name "%s" for more than one route', + props.name + ); + + namedRoutes[props.name] = route; + } + + // Handle . + if (props.catchAll) { + invariant( + container, + ' must have a parent ' + ); + + invariant( + container.props.notFoundRoute == null, + 'You may not have more than one per ' + ); + + container.props.notFoundRoute = route; + + return null; + } + + // Handle . + if (props.isDefault) { + invariant( + container, + ' must have a parent ' + ); + + invariant( + container.props.defaultRoute == null, + 'You may not have more than one per ' + ); + + container.props.defaultRoute = route; + + return null; + } + + // Make sure children is an array. + props.children = processRoutes(props.children, route, namedRoutes); + + return route; +} + +/** + * Processes many children s at once, always returning an array. + */ +function processRoutes(children, container, namedRoutes) { + var routes = []; + + React.Children.forEach(children, function (child) { + // Exclude s and s. + if (child = processRoute(child, container, namedRoutes)) + routes.push(child); + }); + + return routes; +} + +/** + * A mixin for components that have children. + */ +var RouteContainer = { + + childContextTypes: { + routeContainer: React.PropTypes.any.isRequired + }, + + getChildContext: function () { + return { + routeContainer: this + }; + }, + + getInitialState: function () { + var namedRoutes = {}; + + return { + namedRoutes: namedRoutes, + routes: processRoutes(this.props.children, this, namedRoutes) + }; + }, + + /** + * Returns an array of s in this container. + */ + getRoutes: function () { + return this.state.routes; + }, + + /** + * Returns a hash { name: route } of all named s in this container. + */ + getNamedRoutes: function () { + return this.state.namedRoutes; + }, + + /** + * Returns the with the given name, null if no such route exists. + */ + getRouteByName: function (routeName) { + return this.state.namedRoutes[routeName] || null; + } + +}; + +module.exports = RouteContainer; diff --git a/modules/mixins/RouteLookup.js b/modules/mixins/RouteLookup.js new file mode 100644 index 0000000000..00e29d5227 --- /dev/null +++ b/modules/mixins/RouteLookup.js @@ -0,0 +1,44 @@ +var React = require('react'); + +/** + * A mixin for components that need to lookup routes and/or + * build URL paths and links. + */ +var RouteLookup = { + + contextTypes: { + routeContainer: React.PropTypes.any.isRequired, + pathDelegate: React.PropTypes.any.isRequired + }, + + /** + * See RouteContainer#getRoutes. + */ + getRoutes: function () { + return this.context.routeContainer.getRoutes(); + }, + + /** + * See RouteContainer#getRouteByName. + */ + getRouteByName: function (routeName) { + return this.context.routeContainer.getRouteByName(routeName); + }, + + /** + * See PathDelegate#makePath. + */ + makePath: function (to, params, query) { + return this.context.pathDelegate.makePath(to, params, query); + }, + + /** + * See PathDelegate#makeHref. + */ + makeHref: function (to, params, query) { + return this.context.pathDelegate.makeHref(to, params, query); + } + +}; + +module.exports = RouteLookup; diff --git a/modules/mixins/ScrollDelegate.js b/modules/mixins/ScrollDelegate.js new file mode 100644 index 0000000000..d853f88eb8 --- /dev/null +++ b/modules/mixins/ScrollDelegate.js @@ -0,0 +1,39 @@ +var ScrollState = require('./ScrollState'); + +/** + * A mixin for components that manage the window's scroll position. + */ +var ScrollDelegate = { + + mixins: [ ScrollState ], + + componentWillMount: function () { + if (this.getScrollBehavior()) + this._scrollPositions = {}; + }, + + /** + * Records the current scroll position for the given path. + */ + recordScroll: function (path) { + if (this._scrollPositions) + this._scrollPositions[path] = this.getCurrentScrollPosition(); + }, + + /** + * Updates the current scroll position according to the last + * one that was recorded for the given path. + */ + updateScroll: function (path, sender) { + if (this._scrollPositions) { + var behavior = this.getScrollBehavior(); + var position = this._scrollPositions[path]; + + if (behavior && position) + behavior.updateScrollPosition(position, sender); + } + } + +}; + +module.exports = ScrollDelegate; diff --git a/modules/mixins/ScrollState.js b/modules/mixins/ScrollState.js new file mode 100644 index 0000000000..623c0154dc --- /dev/null +++ b/modules/mixins/ScrollState.js @@ -0,0 +1,103 @@ +var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; +var invariant = require('react/lib/invariant'); + +/** + * A scroll behavior that attempts to imitate the default behavior + * of modern browsers. + */ +var ImitateBrowserBehavior = { + + updateScrollPosition: function (position, sender) { + if (sender === window) { + window.scrollTo(position.x, position.y); + } else { + // Clicking on links always scrolls the window to the top. + window.scrollTo(0, 0); + } + } + +}; + +/** + * A scroll behavior that always scrolls to the top of the page + * after a transition. + */ +var ScrollToTopBehavior = { + + updateScrollPosition: function () { + window.scrollTo(0, 0); + } + +}; + +/** + * A hash of { name: scrollBehavior } pairs. + */ +var NAMED_SCROLL_BEHAVIORS = { + none: null, + imitateBrowser: ImitateBrowserBehavior, + scrollToTop: ScrollToTopBehavior +}; + +/** + * A mixin for components that need to know the current scroll position. + */ +var ScrollState = { + + propTypes: { + + scrollBehavior: function (props, propName, componentName) { + var behavior = props[propName]; + + if (typeof behavior === 'string' && !(behavior in NAMED_SCROLL_BEHAVIORS)) + return new Error('Unknown scroll behavior "' + behavior + '", see ' + componentName); + } + + }, + + getDefaultProps: function () { + return { + scrollBehavior: canUseDOM ? ImitateBrowserBehavior : null + }; + }, + + /** + * Gets the scroll behavior object this component uses to observe + * the current scroll position. + */ + getScrollBehavior: function () { + var behavior = this.props.scrollBehavior; + + if (typeof behavior === 'string') + behavior = NAMED_SCROLL_STRATEGIES[behavior]; + + return behavior; + }, + + componentWillMount: function () { + var behavior = this.getScrollBehavior(); + + invariant( + behavior == null || canUseDOM, + 'Cannot use scroll behavior without a DOM' + ); + }, + + /** + * Returns the current scroll position as { x, y }. + */ + getCurrentScrollPosition: function () { + invariant( + canUseDOM, + 'Cannot get current scroll position without a DOM' + ); + + return { + x: window.scrollX, + y: window.scrollY + }; + } + +}; + +module.exports = ScrollState; diff --git a/modules/mixins/TransitionHandler.js b/modules/mixins/TransitionHandler.js new file mode 100644 index 0000000000..4514d9cc5e --- /dev/null +++ b/modules/mixins/TransitionHandler.js @@ -0,0 +1,418 @@ +var React = require('react'); +var warning = require('react/lib/warning'); +var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; +var copyProperties = require('react/lib/copyProperties'); +var Route = require('../components/Route'); +var ActiveDelegate = require('./ActiveDelegate'); +var PathDelegate = require('./PathDelegate'); +var ScrollDelegate = require('./ScrollDelegate'); +var reversedArray = require('../utils/reversedArray'); +var Transition = require('../utils/Transition'); +var Redirect = require('../utils/Redirect'); +var Path = require('../utils/Path'); + +function makeMatch(route, params) { + return { route: route, params: params }; +} + +function getRootMatch(matches) { + return matches[matches.length - 1]; +} + +function findMatches(path, routes, defaultRoute, notFoundRoute) { + var matches = null, route, params; + + for (var i = 0, len = routes.length; i < len; ++i) { + route = routes[i]; + + // Check the subtree first to find the most deeply-nested match. + matches = findMatches(path, route.props.children, route.props.defaultRoute, route.props.notFoundRoute); + + if (matches != null) { + var rootParams = getRootMatch(matches).params; + + params = route.props.paramNames.reduce(function (params, paramName) { + params[paramName] = rootParams[paramName]; + return params; + }, {}); + + matches.unshift(makeMatch(route, params)); + + return matches; + } + + // No routes in the subtree matched, so check this route. + params = Path.extractParams(route.props.path, path); + + if (params) + return [ makeMatch(route, params) ]; + } + + // No routes matched, so try the default route if there is one. + if (defaultRoute && (params = Path.extractParams(defaultRoute.props.path, path))) + return [ makeMatch(defaultRoute, params) ]; + + // Last attempt: does the "not found" route match? + if (notFoundRoute && (params = Path.extractParams(notFoundRoute.props.path, path))) + return [ makeMatch(notFoundRoute, params) ]; + + return matches; +} + +function hasMatch(matches, match) { + return matches.some(function (m) { + if (m.route !== match.route) + return false; + + for (var property in m.params) + if (m.params[property] !== match.params[property]) + return false; + + return true; + }); +} + +function updateMatchComponents(matches, refs) { + var i = 0, component; + while (component = refs.__activeRoute__) { + matches[i++].component = component; + refs = component.refs; + } +} + +/** + * Computes the next state for the given component and calls + * callback(error, nextState) when finished. Also runs all + * transition hooks along the way. + */ +function computeNextState(component, transition, callback) { + if (component.state.path === transition.path) + return callback(); // Nothing to do! + + var currentMatches = component.state.matches; + var nextMatches = component.match(transition.path); + + warning( + nextMatches, + 'No route matches path "' + transition.path + '". Make sure you have ' + + ' somewhere in your routes' + ); + + if (!nextMatches) + nextMatches = []; + + var fromMatches, toMatches; + if (currentMatches.length) { + updateMatchComponents(currentMatches, component.refs); + + fromMatches = currentMatches.filter(function (match) { + return !hasMatch(nextMatches, match); + }); + + toMatches = nextMatches.filter(function (match) { + return !hasMatch(currentMatches, match); + }); + } else { + fromMatches = []; + toMatches = nextMatches; + } + + var query = Path.extractQuery(transition.path) || {}; + + runTransitionFromHooks(fromMatches, transition, function (error) { + if (error || transition.isAborted) + return callback(error); + + runTransitionToHooks(toMatches, transition, query, function (error) { + if (error || transition.isAborted) + return callback(error); + + var matches = currentMatches.slice(0, -fromMatches.length).concat(toMatches); + var rootMatch = getRootMatch(matches); + var params = (rootMatch && rootMatch.params) || {}; + var routes = matches.map(function (match) { + return match.route; + }); + + callback(null, { + path: transition.path, + matches: matches, + activeRoutes: routes, + activeParams: params, + activeQuery: query + }); + }); + }); +} + +/** + * Calls the willTransitionFrom hook of all handlers in the given matches + * serially in reverse with the transition object and the current instance of + * the route's handler, so that the deepest nested handlers are called first. + * Calls callback(error) when finished. + */ +function runTransitionFromHooks(matches, transition, callback) { + var hooks = reversedArray(matches).map(function (match) { + return function () { + var handler = match.route.props.handler; + + if (!transition.isAborted && handler.willTransitionFrom) + return handler.willTransitionFrom(transition, match.component); + + var promise = transition.promise; + delete transition.promise; + + return promise; + }; + }); + + runHooks(hooks, callback); +} + +/** + * Calls the willTransitionTo hook of all handlers in the given matches + * serially with the transition object and any params that apply to that + * handler. Calls callback(error) when finished. + */ +function runTransitionToHooks(matches, transition, query, callback) { + var hooks = matches.map(function (match) { + return function () { + var handler = match.route.props.handler; + + if (!transition.isAborted && handler.willTransitionTo) + handler.willTransitionTo(transition, match.params, query); + + var promise = transition.promise; + delete transition.promise; + + return promise; + }; + }); + + runHooks(hooks, callback); +} + +/** + * Runs all hook functions serially and calls callback(error) when finished. + * A hook may return a promise if it needs to execute asynchronously. + */ +function runHooks(hooks, callback) { + try { + var promise = hooks.reduce(function (promise, hook) { + // The first hook to use transition.wait makes the rest + // of the transition async from that point forward. + return promise ? promise.then(hook) : hook(); + }, null); + } catch (error) { + return callback(error); // Sync error. + } + + if (promise) { + // Use setTimeout to break the promise chain. + promise.then(function () { + setTimeout(callback); + }, function (error) { + setTimeout(function () { + callback(error); + }); + }); + } else { + callback(); + } +} + +function returnNull() { + return null; +} + +function computeHandlerProps(matches, query) { + var handler = returnNull; + var props = { + ref: null, + params: null, + query: null, + activeRouteHandler: handler, + key: null + }; + + reversedArray(matches).forEach(function (match) { + var route = match.route; + + props = Route.getUnreservedProps(route.props); + + props.ref = '__activeRoute__'; + props.params = match.params; + props.query = query; + props.activeRouteHandler = handler; + + // TODO: Can we remove addHandlerKey? + if (route.props.addHandlerKey) + props.key = Path.injectParams(route.props.path, match.params); + + handler = function (props, addedProps) { + if (arguments.length > 2 && typeof arguments[2] !== 'undefined') + throw new Error('Passing children to a route handler is not supported'); + + return route.props.handler( + copyProperties(props, addedProps) + ); + }.bind(this, props); + }); + + return props; +} + +var BrowserTransitionHandling = { + + /** + * Handles errors that were thrown asynchronously while transitioning. The + * default behavior is to re-throw the error so it isn't swallowed silently. + */ + handleTransitionError: function (error) { + throw error; // This error probably originated in a transition hook. + }, + + /** + * Handles aborted transitions. + */ + handleAbortedTransition: function (transition) { + var reason = transition.abortReason; + + if (reason instanceof Redirect) { + this.replaceWith(reason.to, reason.params, reason.query); + } else { + this.goBack(); + } + } + +}; + +var ServerTransitionHandling = { + + handleTransitionError: function (error) { + // TODO + }, + + handleAbortedTransition: function (transition) { + var reason = transition.abortReason; + + if (reason instanceof Redirect) { + // TODO + } else { + // TODO + } + } + +}; + +var TransitionHandling = canUseDOM ? BrowserTransitionHandling : ServerTransitionHandling; + +/** + * A mixin for components that handle transitions. + */ +var TransitionHandler = { + + mixins: [ ActiveDelegate, PathDelegate, ScrollDelegate ], + + propTypes: { + onTransitionError: React.PropTypes.func.isRequired, + onAbortedTransition: React.PropTypes.func.isRequired + }, + + getDefaultProps: function () { + return { + onTransitionError: TransitionHandling.handleTransitionError, + onAbortedTransition: TransitionHandling.handleAbortedTransition + }; + }, + + getInitialState: function () { + return { + matches: [] + }; + }, + + /** + * See PathState. + */ + updatePath: function (path, sender) { + if (this.state.path === path) + return; // Nothing to do! + + if (this.state.path) + this.recordScroll(this.state.path); + + var self = this; + + this.dispatch(path, function (error, transition) { + if (error) { + self.props.onTransitionError.call(self, error); + } else if (transition.isAborted) { + self.props.onAbortedTransition.call(self, transition); + } else { + self.emitChange(); + self.updateScroll(path, sender); + } + }); + }, + + /** + * Performs a depth-first search for the first route in the tree that matches on + * the given path. Returns an array of all routes in the tree leading to the one + * that matched in the format { route, params } where params is an object that + * contains the URL parameters relevant to that route. Returns null if no route + * in the tree matches the path. + * + * React.renderComponent( + * + * + * + * + * + * + * ).match('/posts/123'); => [ { route: , params: {} }, + * { route: , params: { id: '123' } } ] + */ + match: function (path) { + return findMatches(Path.withoutQuery(path), this.getRoutes(), this.props.defaultRoute, this.props.notFoundRoute); + }, + + /** + * Performs a transition to the given path and calls callback(error, transition) + * with the Transition object when the transition is finished and the component's + * state has been updated accordingly. + * + * In a transition, the router first determines which routes are involved by + * beginning with the current route, up the route tree to the first parent route + * that is shared with the destination route, and back down the tree to the + * destination route. The willTransitionFrom hook is invoked on all route handlers + * we're transitioning away from, in reverse nesting order. Likewise, the + * willTransitionTo hook is invoked on all route handlers we're transitioning to. + * + * Both willTransitionFrom and willTransitionTo hooks may either abort or redirect + * the transition. To resolve asynchronously, they may use transition.wait(promise). + */ + dispatch: function (path, callback) { + var transition = new Transition(this, path); + var self = this; + + computeNextState(this, transition, function (error, nextState) { + if (error || nextState == null) + return callback(error, transition); + + self.setState(nextState, function () { + callback(null, transition); + }); + }); + }, + + /** + * Returns the props that should be used for the top-level route handler. + */ + getHandlerProps: function () { + return computeHandlerProps(this.state.matches, this.state.activeQuery); + } + +}; + +module.exports = TransitionHandler; diff --git a/modules/mixins/Transitions.js b/modules/mixins/Transitions.js new file mode 100644 index 0000000000..0018b3e9a5 --- /dev/null +++ b/modules/mixins/Transitions.js @@ -0,0 +1,35 @@ +var React = require('react'); + +/** + * A mixin for components that need to initiate transitions to other routes. + */ +var Transitions = { + + contextTypes: { + pathDelegate: React.PropTypes.any.isRequired + }, + + /** + * See PathDelegate#transitionTo. + */ + transitionTo: function (to, params, query) { + return this.context.pathDelegate.transitionTo(to, params, query, this); + }, + + /** + * See PathDelegate#replaceWith. + */ + replaceWith: function (to, params, query) { + return this.context.pathDelegate.replaceWith(to, params, query, this); + }, + + /** + * See PathDelegate#goBack. + */ + goBack: function () { + return this.context.pathDelegate.goBack(this); + } + +}; + +module.exports = Transitions; diff --git a/modules/stores/PathStore.js b/modules/stores/PathStore.js index ff73858a7e..c5f502a9bb 100644 --- a/modules/stores/PathStore.js +++ b/modules/stores/PathStore.js @@ -1,129 +1,46 @@ -var warning = require('react/lib/warning'); var EventEmitter = require('events').EventEmitter; var LocationActions = require('../actions/LocationActions'); var LocationDispatcher = require('../dispatchers/LocationDispatcher'); -var supportsHistory = require('../utils/supportsHistory'); -var HistoryLocation = require('../locations/HistoryLocation'); -var RefreshLocation = require('../locations/RefreshLocation'); var CHANGE_EVENT = 'change'; var _events = new EventEmitter; -function notifyChange() { - _events.emit(CHANGE_EVENT); +function notifyChange(sender) { + _events.emit(CHANGE_EVENT, sender); } -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; +var _currentPath; /** - * The PathStore keeps track of the current URL path and manages - * the location strategy that is used to update the URL. + * The PathStore keeps track of the current URL path. */ var PathStore = { addChangeListener: function (listener) { - _events.on(CHANGE_EVENT, listener); + _events.addListener(CHANGE_EVENT, listener); }, 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) { var action = payload.action; - var currentPath = _location.getCurrentPath(); switch (action.type) { + case LocationActions.SETUP: 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); - break; + _currentPath = action.path; + notifyChange(action.sender); } }) diff --git a/modules/stores/RouteStore.js b/modules/stores/RouteStore.js deleted file mode 100644 index a287f393e6..0000000000 --- a/modules/stores/RouteStore.js +++ /dev/null @@ -1,156 +0,0 @@ -var React = require('react'); -var invariant = require('react/lib/invariant'); -var warning = require('react/lib/warning'); -var Path = require('../utils/Path'); - -var _namedRoutes = {}; - -/** - * The RouteStore contains a directory of all s in the system. It is - * used primarily for looking up routes by name so that s can use a - * route name in the "to" prop and users can use route names in `Router.transitionTo` - * and other high-level utility methods. - */ -var RouteStore = { - - /** - * Removes all references to s from the store. Should only ever - * really be used in tests to clear the store between test runs. - */ - unregisterAllRoutes: function () { - _namedRoutes = {}; - }, - - /** - * Removes the reference to the given and all of its children - * from the store. - */ - unregisterRoute: function (route) { - var props = route.props; - - if (props.name) - delete _namedRoutes[props.name]; - - React.Children.forEach(props.children, RouteStore.unregisterRoute); - }, - - /** - * Registers a and all of its children with the store. Also, - * does some normalization and validation on route props. - */ - registerRoute: function (route, parentRoute) { - // Note: parentRoute may be a _or_ a . - var props = route.props; - - invariant( - React.isValidClass(props.handler), - 'The handler for the "%s" route must be a valid React class', - props.name || props.path - ); - - var parentPath = (parentRoute && parentRoute.props.path) || '/'; - - if ((props.path || props.name) && !props.isDefault && !props.catchAll) { - var path = props.path || props.name; - - // Relative paths extend their parent. - if (!Path.isAbsolute(path)) - path = Path.join(parentPath, path); - - props.path = Path.normalize(path); - } else { - props.path = parentPath; - - if (props.catchAll) - props.path += '*'; - } - - props.paramNames = Path.extractParamNames(props.path); - - // Make sure the route's path has all params its parent needs. - if (parentRoute && Array.isArray(parentRoute.props.paramNames)) { - parentRoute.props.paramNames.forEach(function (paramName) { - invariant( - props.paramNames.indexOf(paramName) !== -1, - 'The nested route path "%s" is missing the "%s" parameter of its parent path "%s"', - props.path, paramName, parentRoute.props.path - ); - }); - } - - // Make sure the route can be looked up by s. - if (props.name) { - var existingRoute = _namedRoutes[props.name]; - - invariant( - !existingRoute || route === existingRoute, - 'You cannot use the name "%s" for more than one route', - props.name - ); - - _namedRoutes[props.name] = route; - } - - if (props.catchAll) { - invariant( - parentRoute, - ' must have a parent ' - ); - - invariant( - parentRoute.props.notFoundRoute == null, - 'You may not have more than one per ' - ); - - parentRoute.props.notFoundRoute = route; - - return null; - } - - if (props.isDefault) { - invariant( - parentRoute, - ' must have a parent ' - ); - - invariant( - parentRoute.props.defaultRoute == null, - 'You may not have more than one per ' - ); - - parentRoute.props.defaultRoute = route; - - return null; - } - - // Make sure children is an array. - props.children = RouteStore.registerChildren(props.children, route); - - return route; - }, - - /** - * Registers many children routes at once, always returning an array. - */ - registerChildren: function (children, parentRoute) { - var routes = []; - - React.Children.forEach(children, function (child) { - // Exclude s. - if (child = RouteStore.registerRoute(child, parentRoute)) - routes.push(child); - }); - - return routes; - }, - - /** - * Returns the Route object with the given name, if one exists. - */ - getRouteByName: function (routeName) { - return _namedRoutes[routeName] || null; - } - -}; - -module.exports = RouteStore; diff --git a/modules/utils/Transition.js b/modules/utils/Transition.js index 77f0c21369..354ef611c1 100644 --- a/modules/utils/Transition.js +++ b/modules/utils/Transition.js @@ -1,7 +1,6 @@ var mixInto = require('react/lib/mixInto'); var Promise = require('./Promise'); var Redirect = require('./Redirect'); -var replaceWith = require('../actions/LocationActions').replaceWith; /** * Encapsulates a transition to a given path. @@ -9,7 +8,8 @@ var replaceWith = require('../actions/LocationActions').replaceWith; * The willTransitionTo and willTransitionFrom handlers receive * an instance of this class as their first argument. */ -function Transition(path) { +function Transition(pathDelegate, path) { + this.pathDelegate = pathDelegate; this.path = path; this.abortReason = null; this.isAborted = false; @@ -31,7 +31,7 @@ mixInto(Transition, { }, retry: function () { - replaceWith(this.path); + this.pathDelegate.replaceWith(this.path); } }); diff --git a/modules/utils/getWindowPath.js b/modules/utils/getWindowPath.js index 108c2285b2..2a2dd065ff 100644 --- a/modules/utils/getWindowPath.js +++ b/modules/utils/getWindowPath.js @@ -6,4 +6,3 @@ function getWindowPath() { } module.exports = getWindowPath; - diff --git a/modules/utils/isAbsoluteURL.js b/modules/utils/isAbsoluteURL.js deleted file mode 100644 index bdcd806583..0000000000 --- a/modules/utils/isAbsoluteURL.js +++ /dev/null @@ -1,11 +0,0 @@ -var ABSOLUTE_URL_FORMAT = /^https?:\/\//; - -/** - * Returns true if the given string contains an absolute URL - * according to http://tools.ietf.org/html/rfc3986#page-27. - */ -function isAbsoluteURL(string) { - return typeof string === 'string' && ABSOLUTE_URL_FORMAT.test(string); -} - -module.exports = isAbsoluteURL; diff --git a/modules/utils/makeHref.js b/modules/utils/makeHref.js deleted file mode 100644 index 919d65a76c..0000000000 --- a/modules/utils/makeHref.js +++ /dev/null @@ -1,18 +0,0 @@ -var HashLocation = require('../locations/HashLocation'); -var PathStore = require('../stores/PathStore'); -var makePath = require('./makePath'); - -/** - * Returns a string that may safely be used as the href of a - * link to the route with the given name. - */ -function makeHref(to, params, query) { - var path = makePath(to, params, query); - - if (PathStore.getLocation() === HashLocation) - return '#' + path; - - return path; -} - -module.exports = makeHref; diff --git a/modules/utils/makePath.js b/modules/utils/makePath.js deleted file mode 100644 index dd7785e0cf..0000000000 --- a/modules/utils/makePath.js +++ /dev/null @@ -1,28 +0,0 @@ -var invariant = require('react/lib/invariant'); -var RouteStore = require('../stores/RouteStore'); -var Path = require('./Path'); - -/** - * Returns an absolute URL path created from the given route name, URL - * parameters, and query values. - */ -function makePath(to, params, query) { - var path; - if (Path.isAbsolute(to)) { - path = Path.normalize(to); - } else { - var route = RouteStore.getRouteByName(to); - - invariant( - route, - 'Unable to find a route named "' + to + '". Make sure you have ' + - 'a defined somewhere in your routes' - ); - - path = route.props.path; - } - - return Path.withQuery(Path.injectParams(path, params), query); -} - -module.exports = makePath; diff --git a/modules/utils/reversedArray.js b/modules/utils/reversedArray.js new file mode 100644 index 0000000000..5433b6c69e --- /dev/null +++ b/modules/utils/reversedArray.js @@ -0,0 +1,5 @@ +function reversedArray(array) { + return array.slice(0).reverse(); +} + +module.exports = reversedArray;