diff --git a/README.md b/README.md index 02efc5e044..12e3c38570 100644 --- a/README.md +++ b/README.md @@ -66,12 +66,14 @@ Usage var Route = require('react-router').Route; React.renderComponent(( - - - - + + + + + + - + ), document.body); ``` @@ -79,19 +81,23 @@ Or if JSX isn't your jam: ```js React.renderComponent(( - Route({handler: App}, - Route({name: "about", handler: About}), - Route({name: "users", handler: Users}, - Route({name: "user", path: "/user/:userId", handler: User}) + Routes({}, + Route({handler: App}, + Route({name: "about", handler: About}), + Route({name: "users", handler: Users}, + Route({name: "user", path: "/user/:userId", handler: User}) + ) ) ) ), document.body); ``` -- Urls will be matched to the deepest route, and then all the routes up +- URLs will be matched to the deepest route, and then all the routes up the hierarchy are activated and their "handlers" (normal React components) will be rendered. +- Paths are assumed from names unless specified. + - Each handler will receive a `params` property containing the matched parameters form the url, like `:userId`. @@ -194,24 +200,33 @@ Related Modules API --- +### Routes (component) + +Configuration component for your router, all ``s must be +children of a ``. It is the component you provide to +`React.renderComponent(routes, el)`. + +#### Props + +**location** - `"hash"` or `"history"`, defaults to `"hash"`. Configures +what type of url you want, hash includes `#/` in the url and works +without a server, if you use `history` your server will need to support +it. + ### Route (component) Configuration component to declare your application's routes and view hierarchy. #### Props -**location** - The method to use for page navigation when initializing the router. -May be either "hash" to use URLs with hashes in them and the `hashchange` event or -"history" to use the HTML5 history API. This prop is only ever used on the root -route that is rendered into the page. The default is "hash". - **name** - The name of the route, used in the `Link` component and the router's transition methods. **path** - The path used in the URL, supporting dynamic segments. If -left undefined, the path will be defined by the `name`. This path is always -absolute from the URL "root", even if the leading slash is left off. Nested -routes do not inherit the path of their parent. +left undefined, the path will be defined by the `name`, and if there is +no name, will default to `/`. This path is always absolute from the URL +"root", even if the leading slash is left off. Nested routes do not +inherit the path of their parent. **handler** - The component to be rendered when the route matches. @@ -225,14 +240,17 @@ passing in any additional props as needed. #### Examples ```xml - - - - - - + + + + + + + + + - + ``` Or w/o JSX: diff --git a/examples/auth-flow/app.js b/examples/auth-flow/app.js index 30b070b080..8d710887e7 100644 --- a/examples/auth-flow/app.js +++ b/examples/auth-flow/app.js @@ -2,6 +2,7 @@ var React = require('react'); var Router = require('../../modules/main'); var Route = Router.Route; +var Routes = Router.Routes; var Link = Router.Link; var App = React.createClass({ @@ -179,12 +180,14 @@ function pretendRequest(email, pass, cb) { var routes = ( - - - - - - + + + + + + + + ); React.renderComponent(routes, document.body); diff --git a/examples/data-flow/app.js b/examples/data-flow/app.js index d87ebdb955..0ed89ae647 100644 --- a/examples/data-flow/app.js +++ b/examples/data-flow/app.js @@ -2,6 +2,7 @@ var React = require('react'); var Router = require('../../modules/main'); var Route = Router.Route; +var Routes = Router.Routes; var Link = Router.Link; var App = React.createClass({ @@ -64,9 +65,11 @@ var Taco = React.createClass({ }); var routes = ( - - - + + + + + ); React.renderComponent(routes, document.body); diff --git a/examples/dynamic-segments/app.js b/examples/dynamic-segments/app.js index 5906e82b66..f7456e316e 100644 --- a/examples/dynamic-segments/app.js +++ b/examples/dynamic-segments/app.js @@ -2,6 +2,7 @@ var React = require('react'); var Router = require('../../modules/main'); var Route = Router.Route; +var Routes = Router.Routes; var Link = Router.Link; var App = React.createClass({ @@ -45,11 +46,13 @@ var Task = React.createClass({ }); var routes = ( - - - + + + + + - + ); React.renderComponent(routes, document.body); diff --git a/examples/master-detail/app.js b/examples/master-detail/app.js index 15d2a302dc..f7459feccf 100644 --- a/examples/master-detail/app.js +++ b/examples/master-detail/app.js @@ -2,6 +2,7 @@ var React = require('react'); var Router = require('../../modules/main'); var Route = Router.Route; +var Routes = Router.Routes; var Link = Router.Link; var api = 'http://addressbook-api.herokuapp.com/contacts'; @@ -203,11 +204,13 @@ var NotFound = React.createClass({ }); var routes = ( - - - - - + + + + + + + ); React.renderComponent(routes, document.body); diff --git a/examples/partial-app-loading/app.js b/examples/partial-app-loading/app.js index c8051c8ea7..58cda22251 100644 --- a/examples/partial-app-loading/app.js +++ b/examples/partial-app-loading/app.js @@ -2,6 +2,7 @@ var React = require('react'); var Router = require('../../modules/main'); var Route = Router.Route; +var Routes = Router.Routes; var Link = Router.Link; var AsyncReactComponent = { @@ -59,11 +60,13 @@ var App = React.createClass({ }); var routes = ( - - - + + + + + - + ); React.renderComponent(routes, document.body); diff --git a/examples/query-params/app.js b/examples/query-params/app.js index 353c1c1498..fafae223c9 100644 --- a/examples/query-params/app.js +++ b/examples/query-params/app.js @@ -2,6 +2,7 @@ var React = require('react'); var Router = require('../../modules/main'); var Route = Router.Route; +var Routes = Router.Routes; var Link = Router.Link; var App = React.createClass({ @@ -32,9 +33,11 @@ var User = React.createClass({ }); var routes = ( - - - + + + + + ); React.renderComponent(routes, document.body); diff --git a/examples/shared-root/app.js b/examples/shared-root/app.js index 38451321ba..2e4513a918 100644 --- a/examples/shared-root/app.js +++ b/examples/shared-root/app.js @@ -2,6 +2,7 @@ var React = require('react'); var Router = require('../../modules/main'); var Route = Router.Route; +var Routes = Router.Routes; var Link = Router.Link; var App = React.createClass({ @@ -66,15 +67,17 @@ var ForgotPassword = React.createClass({ }); var routes = ( - - - - + + + + + + + + + - - - - + ); React.renderComponent(routes, document.body); diff --git a/examples/shared-root/index.html b/examples/shared-root/index.html index 649c3a6112..d5521e2bf0 100644 --- a/examples/shared-root/index.html +++ b/examples/shared-root/index.html @@ -1,4 +1,5 @@ Shared Root Example + diff --git a/examples/simple-master-detail/app.js b/examples/simple-master-detail/app.js index d320afe4dc..6a439afadf 100644 --- a/examples/simple-master-detail/app.js +++ b/examples/simple-master-detail/app.js @@ -2,6 +2,7 @@ var React = require('react'); var Router = require('../../modules/main'); var Route = Router.Route; +var Routes = Router.Routes; var Link = Router.Link; var App = React.createClass({ @@ -50,9 +51,11 @@ var State = React.createClass({ }); var routes = ( - - - + + + + + ); React.renderComponent(routes, document.body); diff --git a/examples/transitions/app.js b/examples/transitions/app.js index c14c7b1cfb..f8c80177ac 100644 --- a/examples/transitions/app.js +++ b/examples/transitions/app.js @@ -2,6 +2,7 @@ var React = require('react'); var Router = require('../../modules/main'); var Route = Router.Route; +var Routes = Router.Routes; var Link = Router.Link; var App = React.createClass({ @@ -55,10 +56,12 @@ var Form = React.createClass({ }); var routes = ( - - - - + + + + + + ); React.renderComponent(routes, document.body); diff --git a/modules/components/Route.js b/modules/components/Route.js index aa7016a9ad..b195fb9e8f 100644 --- a/modules/components/Route.js +++ b/modules/components/Route.js @@ -1,16 +1,5 @@ var React = require('react'); -var warning = require('react/lib/warning'); -var ExecutionEnvironment = require('react/lib/ExecutionEnvironment'); -var mergeProperties = require('../helpers/mergeProperties'); -var goBack = require('../helpers/goBack'); -var replaceWith = require('../helpers/replaceWith'); -var transitionTo = require('../helpers/transitionTo'); var withoutProperties = require('../helpers/withoutProperties'); -var Path = require('../helpers/Path'); -var ActiveStore = require('../stores/ActiveStore'); -var RouteStore = require('../stores/RouteStore'); -var URLStore = require('../stores/URLStore'); -var Promise = require('es6-promise').Promise; /** * A map of component props that are reserved for use by the @@ -18,18 +7,12 @@ var Promise = require('es6-promise').Promise; * are passed through to the route handler. */ var RESERVED_PROPS = { - location: true, handler: true, name: true, path: true, children: true // ReactChildren }; -/** - * The ref name that can be used to reference the active route component. - */ -var REF_NAME = '__activeRoute__'; - /** * components specify components that are rendered to the page when the * URL matches a given pattern. @@ -48,18 +31,18 @@ var REF_NAME = '__activeRoute__'; * a great way to visualize how routes are laid out in an application. * * React.renderComponent(( - * + * * * * - * + * * ), document.body); * * If you don't use JSX, you can also assemble a Router programmatically using * the standard React component JavaScript API. * * React.renderComponent(( - * Route({ handler: App }, + * Routes({ handler: App }, * Route({ name: 'login', handler: Login }), * Route({ name: 'logout', handler: Logout }), * Route({ name: 'about', handler: About }) @@ -88,399 +71,20 @@ var Route = React.createClass({ return withoutProperties(props, RESERVED_PROPS); }, - /** - * Handles errors that were thrown asynchronously. By default, the - * error is re-thrown so we don't swallow them silently. - */ - handleAsyncError: function (error, route) { - throw error; // This error probably originated in a transition hook. - }, - - /** - * Handles cancelled transitions. By default, redirects replace the - * current URL and aborts roll it back. - */ - handleCancelledTransition: function (transition, route) { - var reason = transition.cancelReason; - - if (reason instanceof Redirect) { - replaceWith(reason.to, reason.params, reason.query); - } else if (reason instanceof Abort) { - goBack(); - } - } - }, propTypes: { - location: React.PropTypes.oneOf([ 'hash', 'history' ]).isRequired, handler: React.PropTypes.any.isRequired, path: React.PropTypes.string, name: React.PropTypes.string }, - getDefaultProps: function () { - return { - location: 'hash' - }; - }, - - getInitialState: function () { - return {}; - }, - - componentWillMount: function () { - RouteStore.registerRoute(this); - - if (!URLStore.isSetup() && ExecutionEnvironment.canUseDOM) - URLStore.setup(this.props.location); - - URLStore.addChangeListener(this.handleRouteChange); - }, - - componentDidMount: function () { - this.dispatch(URLStore.getCurrentPath()); - }, - - componentWillUnmount: function () { - URLStore.removeChangeListener(this.handleRouteChange); - }, - - handleRouteChange: function () { - this.dispatch(URLStore.getCurrentPath()); - }, - - /** - * 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. - * - * ( - * - * - * - * - * - * ).match('/posts/123'); => [ { route: , params: {} }, - * { route: , params: {} }, - * { route: , params: { id: '123' } } ] - */ - match: function (path) { - return findMatches(Path.withoutQuery(path), this); - }, - - /** - * Performs a transition to the given path and returns a promise for the - * Transition object that was used. - * - * In order to do this, the router first determines which routes are involved - * in the transition 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 static - * method is invoked on all route handlers we're transitioning away from, in - * reverse nesting order. Likewise, the willTransitionTo static method - * is invoked on all route handlers we're transitioning to. - * - * Both willTransitionFrom and willTransitionTo hooks may either abort or - * redirect the transition. If they need to resolve asynchronously, they may - * return a promise. - * - * Any error that occurs asynchronously during the transition is re-thrown in - * the top-level scope unless returnRejectedPromise is true, in which case a - * rejected promise is returned so the caller may handle the error. - * - * Note: This function does not update the URL in a browser's location bar. - * If you want to keep the URL in sync with transitions, use Router.transitionTo, - * Router.replaceWith, or Router.goBack instead. - */ - dispatch: function (path, returnRejectedPromise) { - var transition = new Transition(path); - var route = this; - - var promise = syncWithTransition(route, transition).then(function (newState) { - if (transition.isCancelled) { - Route.handleCancelledTransition(transition, route); - } else if (newState) { - ActiveStore.updateState(newState); - } - - return transition; - }); - - if (!returnRejectedPromise) { - promise = promise.then(undefined, function (error) { - // Use setTimeout to break the promise chain. - setTimeout(function () { - Route.handleAsyncError(error, route); - }); - }); - } - - return promise; - }, - render: function () { - if (!this.state.path) - return null; - - return this.props.handler(computeHandlerProps(this.state.matches || [], this.state.activeQuery)); + throw new Error( + 'The component should not be rendered directly. You may be ' + + 'missing a wrapper around your list of routes.'); } }); -function Transition(path) { - this.path = path; - this.cancelReason = null; - this.isCancelled = false; -} - -mergeProperties(Transition.prototype, { - - abort: function () { - this.cancelReason = new Abort(); - this.isCancelled = true; - }, - - redirect: function (to, params, query) { - this.cancelReason = new Redirect(to, params, query); - this.isCancelled = true; - }, - - retry: function () { - transitionTo(this.path); - } - -}); - -function Abort() {} - -function Redirect(to, params, query) { - this.to = to; - this.params = params; - this.query = query; -} - -function findMatches(path, route) { - var children = route.props.children, matches; - var params; - - // Check the subtree first to find the most deeply-nested match. - if (Array.isArray(children)) { - for (var i = 0, len = children.length; matches == null && i < len; ++i) { - matches = findMatches(path, children[i]); - } - } else if (children) { - matches = findMatches(path, children); - } - - if (matches) { - var rootParams = getRootMatch(matches).params; - params = {}; - - Path.extractParamNames(route.props.path).forEach(function (paramName) { - params[paramName] = rootParams[paramName]; - }); - - 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) ]; - - return null; -} - -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; - } -} - -/** - * Runs all transition hooks that are required to get from the current state - * to the state specified by the given transition and updates the current state - * if they all pass successfully. Returns a promise that resolves to the new - * state if it needs to be updated, or undefined if not. - */ -function syncWithTransition(route, transition) { - if (route.state.path === transition.path) - return Promise.resolve(); // Nothing to do! - - var currentMatches = route.state.matches; - var nextMatches = route.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) { - updateMatchComponents(currentMatches, route.refs); - - fromMatches = currentMatches.filter(function (match) { - return !hasMatch(nextMatches, match); - }); - - toMatches = nextMatches.filter(function (match) { - return !hasMatch(currentMatches, match); - }); - } else { - fromMatches = []; - toMatches = nextMatches; - } - - return checkTransitionFromHooks(fromMatches, transition).then(function () { - if (transition.isCancelled) - return; // No need to continue. - - return checkTransitionToHooks(toMatches, transition).then(function () { - if (transition.isCancelled) - return; // No need to continue. - - var rootMatch = getRootMatch(nextMatches); - var params = (rootMatch && rootMatch.params) || {}; - var query = Path.extractQuery(transition.path) || {}; - var state = { - path: transition.path, - matches: nextMatches, - activeParams: params, - activeQuery: query, - activeRoutes: nextMatches.map(function (match) { - return match.route; - }) - }; - - route.setState(state); - - return state; - }); - }); -} - -/** - * 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. - * Returns a promise that resolves after the last handler. - */ -function checkTransitionFromHooks(matches, transition) { - var promise = Promise.resolve(); - - reversedArray(matches).forEach(function (match) { - promise = promise.then(function () { - var handler = match.route.props.handler; - - if (!transition.isCancelled && handler.willTransitionFrom) - return handler.willTransitionFrom(transition, match.component); - }); - }); - - return promise; -} - -/** - * Calls the willTransitionTo hook of all handlers in the given matches serially - * with the transition object and any params that apply to that handler. Returns - * a promise that resolves after the last handler. - */ -function checkTransitionToHooks(matches, transition) { - var promise = Promise.resolve(); - - matches.forEach(function (match, index) { - promise = promise.then(function () { - var handler = match.route.props.handler; - - if (!transition.isCancelled && handler.willTransitionTo) - return handler.willTransitionTo(transition, match.params); - }); - }); - - return promise; -} - -/** - * Returns a props object for a component that renders the routes in the - * given matches. - */ -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.key = Path.injectParams(route.props.path, match.params); - props.params = match.params; - props.query = query; - - 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(mergeProperties(props, addedProps)); - }.bind(this, props); - }); - - return props; -} - -function returnNull() { - return null; -} - -function reversedArray(array) { - return array.slice(0).reverse(); -} - module.exports = Route; diff --git a/modules/components/Routes.js b/modules/components/Routes.js new file mode 100644 index 0000000000..6b97e66280 --- /dev/null +++ b/modules/components/Routes.js @@ -0,0 +1,444 @@ +var React = require('react'); +var warning = require('react/lib/warning'); +var ExecutionEnvironment = require('react/lib/ExecutionEnvironment'); +var mergeProperties = require('../helpers/mergeProperties'); +var goBack = require('../helpers/goBack'); +var replaceWith = require('../helpers/replaceWith'); +var transitionTo = require('../helpers/transitionTo'); +var withoutProperties = require('../helpers/withoutProperties'); +var Route = require('../components/Route'); +var Path = require('../helpers/Path'); +var ActiveStore = require('../stores/ActiveStore'); +var RouteStore = require('../stores/RouteStore'); +var URLStore = require('../stores/URLStore'); +var Promise = require('es6-promise').Promise; + +/** + * The ref name that can be used to reference the active route component. + */ +var REF_NAME = '__activeRoute__'; + +/** + * The component configures the route hierarchy and renders the + * route matching the current location when rendered into a document. + * + * See the component for more details. + */ +var Routes = React.createClass({ + displayName: 'Routes', + + statics: { + + getUnreservedProps: function (props) { + return withoutProperties(props, RESERVED_PROPS); + }, + + /** + * Handles errors that were thrown asynchronously. By default, the + * error is re-thrown so we don't swallow them silently. + */ + handleAsyncError: function (error, route) { + throw error; // This error probably originated in a transition hook. + }, + + /** + * Handles cancelled transitions. By default, redirects replace the + * current URL and aborts roll it back. + */ + handleCancelledTransition: function (transition, routes) { + var reason = transition.cancelReason; + + if (reason instanceof Redirect) { + replaceWith(reason.to, reason.params, reason.query); + } else if (reason instanceof Abort) { + goBack(); + } + } + + }, + + propTypes: { + location: React.PropTypes.oneOf([ 'hash', 'history' ]).isRequired, + }, + + getDefaultProps: function () { + return { + location: 'hash' + }; + }, + + getInitialState: function () { + return {}; + }, + + componentWillMount: function () { + React.Children.forEach(this.props.children, function (child) { + RouteStore.registerRoute(child); + }); + + if (!URLStore.isSetup() && ExecutionEnvironment.canUseDOM) + URLStore.setup(this.props.location); + + URLStore.addChangeListener(this.handleRouteChange); + }, + + componentDidMount: function () { + this.dispatch(URLStore.getCurrentPath()); + }, + + componentWillUnmount: function () { + URLStore.removeChangeListener(this.handleRouteChange); + }, + + handleRouteChange: function () { + this.dispatch(URLStore.getCurrentPath()); + }, + + /** + * 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. + * + * ( + * + * + * + * + * + * ).match('/posts/123'); => [ { route: , params: {} }, + * { route: , params: {} }, + * { route: , params: { id: '123' } } ] + */ + match: function (path) { + var rootRoutes = this.props.children; + if (!Array.isArray(rootRoutes)) { + rootRoutes = [rootRoutes]; + } + var matches = null; + for (var i = 0; matches == null && i < rootRoutes.length; i++) { + matches = findMatches(Path.withoutQuery(path), rootRoutes[i]); + } + return matches; + }, + + /** + * Performs a transition to the given path and returns a promise for the + * Transition object that was used. + * + * In order to do this, the router first determines which routes are involved + * in the transition 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 static + * method is invoked on all route handlers we're transitioning away from, in + * reverse nesting order. Likewise, the willTransitionTo static method + * is invoked on all route handlers we're transitioning to. + * + * Both willTransitionFrom and willTransitionTo hooks may either abort or + * redirect the transition. If they need to resolve asynchronously, they may + * return a promise. + * + * Any error that occurs asynchronously during the transition is re-thrown in + * the top-level scope unless returnRejectedPromise is true, in which case a + * rejected promise is returned so the caller may handle the error. + * + * Note: This function does not update the URL in a browser's location bar. + * If you want to keep the URL in sync with transitions, use Router.transitionTo, + * Router.replaceWith, or Router.goBack instead. + */ + dispatch: function (path, returnRejectedPromise) { + var transition = new Transition(path); + var routes = this; + + var promise = syncWithTransition(routes, transition).then(function (newState) { + if (transition.isCancelled) { + Routes.handleCancelledTransition(transition, routes); + } else if (newState) { + ActiveStore.updateState(newState); + } + + return transition; + }); + + if (!returnRejectedPromise) { + promise = promise.then(undefined, function (error) { + // Use setTimeout to break the promise chain. + setTimeout(function () { + Routes.handleAsyncError(error, routes); + }); + }); + } + + return promise; + }, + + render: function () { + if (!this.state.path) + return null; + + 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 { + return null; + } + } + +}); + +function Transition(path) { + this.path = path; + this.cancelReason = null; + this.isCancelled = false; +} + +mergeProperties(Transition.prototype, { + + abort: function () { + this.cancelReason = new Abort(); + this.isCancelled = true; + }, + + redirect: function (to, params, query) { + this.cancelReason = new Redirect(to, params, query); + this.isCancelled = true; + }, + + retry: function () { + transitionTo(this.path); + } + +}); + +function Abort() {} + +function Redirect(to, params, query) { + this.to = to; + this.params = params; + this.query = query; +} + +function findMatches(path, route) { + var children = route.props.children, matches; + var params; + + // Check the subtree first to find the most deeply-nested match. + if (Array.isArray(children)) { + for (var i = 0, len = children.length; matches == null && i < len; ++i) { + matches = findMatches(path, children[i]); + } + } else if (children) { + matches = findMatches(path, children); + } + + if (matches) { + var rootParams = getRootMatch(matches).params; + params = {}; + + Path.extractParamNames(route.props.path).forEach(function (paramName) { + params[paramName] = rootParams[paramName]; + }); + + 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) ]; + + return null; +} + +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; + } +} + +/** + * Runs all transition hooks that are required to get from the current state + * to the state specified by the given transition and updates the current state + * if they all pass successfully. Returns a promise that resolves to the new + * state if it needs to be updated, or undefined if not. + */ +function syncWithTransition(routes, transition) { + if (routes.state.path === transition.path) + return Promise.resolve(); // Nothing to do! + + var currentMatches = routes.state.matches; + var nextMatches = routes.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) { + updateMatchComponents(currentMatches, routes.refs); + + fromMatches = currentMatches.filter(function (match) { + return !hasMatch(nextMatches, match); + }); + + toMatches = nextMatches.filter(function (match) { + return !hasMatch(currentMatches, match); + }); + } else { + fromMatches = []; + toMatches = nextMatches; + } + + return checkTransitionFromHooks(fromMatches, transition).then(function () { + if (transition.isCancelled) + return; // No need to continue. + + return checkTransitionToHooks(toMatches, transition).then(function () { + if (transition.isCancelled) + return; // No need to continue. + + var rootMatch = getRootMatch(nextMatches); + var params = (rootMatch && rootMatch.params) || {}; + var query = Path.extractQuery(transition.path) || {}; + var state = { + path: transition.path, + matches: nextMatches, + activeParams: params, + activeQuery: query, + activeRoutes: nextMatches.map(function (match) { + return match.route; + }) + }; + + routes.setState(state); + + return state; + }); + }); +} + +/** + * 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. + * Returns a promise that resolves after the last handler. + */ +function checkTransitionFromHooks(matches, transition) { + var promise = Promise.resolve(); + + reversedArray(matches).forEach(function (match) { + promise = promise.then(function () { + var handler = match.route.props.handler; + + if (!transition.isCancelled && handler.willTransitionFrom) + return handler.willTransitionFrom(transition, match.component); + }); + }); + + return promise; +} + +/** + * Calls the willTransitionTo hook of all handlers in the given matches serially + * with the transition object and any params that apply to that handler. Returns + * a promise that resolves after the last handler. + */ +function checkTransitionToHooks(matches, transition) { + var promise = Promise.resolve(); + + matches.forEach(function (match, index) { + promise = promise.then(function () { + var handler = match.route.props.handler; + + if (!transition.isCancelled && handler.willTransitionTo) + return handler.willTransitionTo(transition, match.params); + }); + }); + + return promise; +} + +/** + * 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.key = Path.injectParams(route.props.path, match.params); + props.params = match.params; + props.query = query; + + 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(mergeProperties(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/helpers/Path.js b/modules/helpers/Path.js index a48411c3ca..919ef8c15c 100644 --- a/modules/helpers/Path.js +++ b/modules/helpers/Path.js @@ -41,6 +41,9 @@ var Path = { * pattern does not match the given path. */ extractParams: function (pattern, path) { + if (!pattern) + return null; + if (!isDynamicPattern(pattern)) { if (pattern === URL.decode(path)) return {}; // No dynamic segments, but the paths match. @@ -67,6 +70,8 @@ var Path = { * Returns an array of the names of all parameters in the given pattern. */ extractParamNames: function (pattern) { + if (!pattern) + return []; return compilePattern(pattern).paramNames; }, @@ -75,6 +80,9 @@ var Path = { * if there is a dynamic segment of the route path for which there is no param. */ injectParams: function (pattern, params) { + if (!pattern) + return null; + if (!isDynamicPattern(pattern)) return pattern; diff --git a/modules/main.js b/modules/main.js index 71110da104..c840e54685 100644 --- a/modules/main.js +++ b/modules/main.js @@ -1,5 +1,6 @@ exports.Link = require('./components/Link'); exports.Route = require('./components/Route'); +exports.Routes = require('./components/Routes'); exports.goBack = require('./helpers/goBack'); exports.replaceWith = require('./helpers/replaceWith'); diff --git a/modules/stores/RouteStore.js b/modules/stores/RouteStore.js index 486cb882b5..2ffc0a8cd9 100644 --- a/modules/stores/RouteStore.js +++ b/modules/stores/RouteStore.js @@ -1,5 +1,6 @@ var React = require('react'); var invariant = require('react/lib/invariant'); +var warning = require('react/lib/warning'); var Path = require('../helpers/Path'); var _namedRoutes = {}; diff --git a/specs/Route.spec.js b/specs/Route.spec.js index 354947c5dc..c7f3cdba56 100644 --- a/specs/Route.spec.js +++ b/specs/Route.spec.js @@ -1,5 +1,6 @@ require('./helper'); var Route = require('../modules/components/Route'); +var Routes = require('../modules/components/Routes'); var App = React.createClass({ displayName: 'App', @@ -10,13 +11,15 @@ var App = React.createClass({ describe('a Route that matches the URL', function () { it('returns an array', function () { - var route = TestUtils.renderIntoDocument( - Route({ handler: App }, - Route({ path: '/a/b/c', handler: App }) + var routes = TestUtils.renderIntoDocument( + Routes(null, + Route({ handler: App }, + Route({ path: '/a/b/c', handler: App }) + ) ) ); - var matches = route.match('/a/b/c'); + var matches = routes.match('/a/b/c'); assert(matches); expect(matches.length).toEqual(2); @@ -26,13 +29,15 @@ describe('a Route that matches the URL', function () { describe('that contains dynamic segments', function () { it('returns an array with the correct params', function () { - var route = TestUtils.renderIntoDocument( - Route({ handler: App }, - Route({ path: '/posts/:id/edit', handler: App }) + var routes = TestUtils.renderIntoDocument( + Routes(null, + Route({ handler: App }, + Route({ path: '/posts/:id/edit', handler: App }) + ) ) ); - var matches = route.match('/posts/abc/edit'); + var matches = routes.match('/posts/abc/edit'); assert(matches); expect(matches.length).toEqual(2); @@ -44,27 +49,31 @@ describe('a Route that matches the URL', function () { describe('a Route that does not match the URL', function () { it('returns null', function () { - var route = TestUtils.renderIntoDocument( - Route({ handler: App }, - Route({ path: '/a/b/c', handler: App }) + var routes = TestUtils.renderIntoDocument( + Routes(null, + Route({ handler: App }, + Route({ path: '/a/b/c', handler: App }) + ) ) ); - expect(route.match('/not-found')).toBe(null); + expect(routes.match('/not-found')).toBe(null); }); }); describe('a nested Route that matches the URL', function () { it('returns the appropriate params for each match', function () { - var route = TestUtils.renderIntoDocument( - Route({ handler: App }, - Route({ name: 'posts', path: '/posts/:id', handler: App }, - Route({ name: 'comment', path: '/posts/:id/comments/:commentId', handler: App }) + var routes = TestUtils.renderIntoDocument( + Routes(null, + Route({ handler: App }, + Route({ name: 'posts', path: '/posts/:id', handler: App }, + Route({ name: 'comment', path: '/posts/:id/comments/:commentId', handler: App }) + ) ) ) ); - var matches = route.match('/posts/abc/comments/123'); + var matches = routes.match('/posts/abc/comments/123'); assert(matches); expect(matches.length).toEqual(3); @@ -80,16 +89,18 @@ describe('a nested Route that matches the URL', function () { describe('multiple nested Router that match the URL', function () { it('returns the first one in the subtree, depth-first', function () { - var route = TestUtils.renderIntoDocument( - Route({ handler: App }, - Route({ path: '/a', handler: App }, - Route({ path: '/a/b', name: 'expected', handler: App }) - ), - Route({ path: '/a/b', handler: App }) + var routes = TestUtils.renderIntoDocument( + Routes(null, + Route({ handler: App }, + Route({ path: '/a', handler: App }, + Route({ path: '/a/b', name: 'expected', handler: App }) + ), + Route({ path: '/a/b', handler: App }) + ) ) ); - var matches = route.match('/a/b'); + var matches = routes.match('/a/b'); assert(matches); expect(matches.length).toEqual(3); @@ -98,20 +109,6 @@ describe('multiple nested Router that match the URL', function () { }); }); -describe('a Route with custom props', function() { - it('receives props', function (done) { - var route = TestUtils.renderIntoDocument( - Route({ handler: App, customProp: 'prop' }) - ); - - route.dispatch('/').then(function () { - assert(route.props.customProp); - expect(route.props.customProp).toEqual('prop'); - done(); - }); - }); -}); - describe('a route handler', function () { it('may not receive children', function (done) { var InvalidHandler = React.createClass({ @@ -129,13 +126,15 @@ describe('a route handler', function () { } }); - var route = TestUtils.renderIntoDocument( - Route({ handler: InvalidHandler }, - Route({ path: '/home', handler: App }) + var routes = TestUtils.renderIntoDocument( + Routes(null, + Route({ handler: InvalidHandler }, + Route({ path: '/home', handler: App }) + ) ) ); - route.dispatch('/home'); + routes.dispatch('/home'); }); }); @@ -182,7 +181,7 @@ describe('a child route', function() { // }); // var router = Router( -// RouteComponent({ path: '/', handler: Layout}, +// RouteComponent({ handler: Layout}, // RouteComponent({ path: '/a', handler: AsyncApp })) // );