From 468bf3b20aa88413a03b3f94d518b3d70a437ffe Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Sat, 21 Jun 2014 02:16:19 -0700 Subject: [PATCH] [changed] Deprecate Router interface The Router interface is deprecated in favor of using components directly with React.renderComponent. --- examples/dynamic-segments/app.js | 9 +- examples/query-params/app.js | 13 +- examples/transitions/app.js | 16 +- modules/Route.js | 102 ---- modules/Router.js | 522 +-------------------- modules/components/Link.js | 25 +- modules/components/Route.js | 364 +++++++++++++- modules/{ => helpers}/Path.js | 0 modules/helpers/getComponentDisplayName.js | 5 - modules/helpers/goBack.js | 7 + modules/helpers/makeHref.js | 17 + modules/helpers/makePath.js | 28 ++ modules/helpers/replaceWith.js | 12 + modules/helpers/reversedArray.js | 5 - modules/helpers/transitionTo.js | 12 + modules/main.js | 11 +- modules/stores/ActiveStore.js | 8 +- modules/stores/RouteStore.js | 88 ++++ modules/stores/URLStore.js | 5 +- 19 files changed, 583 insertions(+), 666 deletions(-) delete mode 100644 modules/Route.js rename modules/{ => helpers}/Path.js (100%) delete mode 100644 modules/helpers/getComponentDisplayName.js create mode 100644 modules/helpers/goBack.js create mode 100644 modules/helpers/makeHref.js create mode 100644 modules/helpers/makePath.js create mode 100644 modules/helpers/replaceWith.js delete mode 100644 modules/helpers/reversedArray.js create mode 100644 modules/helpers/transitionTo.js create mode 100644 modules/stores/RouteStore.js diff --git a/examples/dynamic-segments/app.js b/examples/dynamic-segments/app.js index d087727ba4..a1dd0cedb4 100644 --- a/examples/dynamic-segments/app.js +++ b/examples/dynamic-segments/app.js @@ -45,11 +45,12 @@ var Task = React.createClass({ } }); -Router( +var router = Router({}, - - + + -).renderComponent(document.body); +); +React.renderComponent(router, document.body); diff --git a/examples/query-params/app.js b/examples/query-params/app.js index 882d12ff67..ba54d23987 100644 --- a/examples/query-params/app.js +++ b/examples/query-params/app.js @@ -32,9 +32,12 @@ var User = React.createClass({ } }); -Router( - - - -).renderComponent(document.body); +var router = ( + + + + + +); +React.renderComponent(router, document.body); diff --git a/examples/transitions/app.js b/examples/transitions/app.js index 04cd737131..ad922dd504 100644 --- a/examples/transitions/app.js +++ b/examples/transitions/app.js @@ -40,7 +40,7 @@ var Form = React.createClass({ handleSubmit: function(event) { event.preventDefault(); this.refs.userInput.getDOMNode().value = ''; - Router.transitionTo('/'); + ReactRouter.transitionTo('/'); }, render: function() { @@ -56,10 +56,12 @@ var Form = React.createClass({ } }); -Router( - - - - -).renderComponent(document.body); +var router = ( + + + + +); + +React.renderComponent(router, document.body); diff --git a/modules/Route.js b/modules/Route.js deleted file mode 100644 index 67b7c5385d..0000000000 --- a/modules/Route.js +++ /dev/null @@ -1,102 +0,0 @@ -var React = require('react'); -var invariant = require('react/lib/invariant'); -var getComponentDisplayName = require('./helpers/getComponentDisplayName'); -var mergeProperties = require('./helpers/mergeProperties'); -var RouteComponent = require('./components/Route'); -var Path = require('./Path'); - -var _namedRoutes = {}; - -function Route(options, _parentRoute) { - options = options || {}; - - this.name = options.name; - this.path = Path.normalize(options.path || options.name || '/'); - this.handler = options.handler; - this.staticProps = options.staticProps; - - // Make sure the route has a valid handler. - invariant( - React.isValidComponent(this.handler), - 'The handler for Route "' + (this.name || this.path) + '" must be a valid React component' - ); - - this.displayName = getComponentDisplayName(this.handler) + 'Route'; - this.paramNames = Path.extractParamNames(this.path); - - // Make sure the route's path contains all params of its parent. - if (_parentRoute) { - _parentRoute.paramNames.forEach(function (paramName) { - invariant( - this.paramNames.indexOf(paramName) !== -1, - 'The nested route path "' + this.path + '" is missing the "' + paramName + '" ' + - 'parameter of its parent path "' + _parentRoute.path + '"' - ); - }, this); - } - - // If the route has a name, store it for lookup by components. - if (this.name) { - invariant( - !_namedRoutes[this.name], - 'You cannot use the name "' + this.name + '" for more than one route' - ); - - _namedRoutes[this.name] = this; - } -} - -mergeProperties(Route.prototype, { - - toString: function () { - return '<' + this.displayName + '>'; - } - -}); - -mergeProperties(Route, { - - /** - * Creates and returns a Route object from a component. - */ - fromComponent: function (component, _parentRoute) { - invariant( - React.isValidComponent(component) && component.type === RouteComponent.type, - 'The Router may only contain components' - ); - - var route = new Route({ - name: component.props.name, - path: component.props.path, - handler: component.props.handler, - staticProps: RouteComponent.getUnreservedProps(component.props) - }, _parentRoute); - - if (component.props.children) { - var childRoutes = route.childRoutes = []; - - React.Children.forEach(component.props.children, function (child) { - childRoutes.push(Route.fromComponent(child, route)); - }); - } - - return route; - }, - - /** - * Returns the Route object with the given name, if one exists. - */ - getByName: function (routeName) { - return _namedRoutes[routeName]; - }, - - /** - * Removes references to all named Routes. Useful in tests. - */ - clearNamedRoutes: function () { - _namedRoutes = {}; - } - -}); - -module.exports = Route; diff --git a/modules/Router.js b/modules/Router.js index 3bf15d641e..66e56189db 100644 --- a/modules/Router.js +++ b/modules/Router.js @@ -1,522 +1,18 @@ var React = require('react'); -var ExecutionEnvironment = require('react/lib/ExecutionEnvironment'); -var invariant = require('react/lib/invariant'); var warning = require('react/lib/warning'); -var Promise = require('es6-promise').Promise; -var getComponentDisplayName = require('./helpers/getComponentDisplayName'); -var mergeProperties = require('./helpers/mergeProperties'); -var reversedArray = require('./helpers/reversedArray'); -var ActiveStore = require('./stores/ActiveStore'); -var URLStore = require('./stores/URLStore'); -var Path = require('./Path'); -var Route = require('./Route'); -/** - * A Router specifies components that are rendered to the page when the URL - * matches a given pattern. - * - * Routes are arranged in a nested tree structure. When a new URL is requested, - * the tree is searched depth-first to find a route whose path matches the URL. - * When one is found, all routes in the tree that lead to it are considered - * "active" and their components are rendered into the DOM, nested in the same - * order as they are in the tree. - * - * Unlike Ember, a nested route's path does not build upon that of its parents. - * This may seem like it creates more work up front in specifying URLs, but it - * has the nice benefit of decoupling nested UI from "nested" URLs. - * - * The preferred way to configure a router is using JSX. The XML-like syntax is - * a great way to visualize how routes are laid out in an application. - * - * Router( - * - * - * - * - * - * ).renderComponent(document.body); - * - * If you don't use JSX, you can also assemble a Router programmatically using - * the standard React component JavaScript API. - * - * Router( - * Route({ handler: App }, - * Route({ name: 'login', handler: Login }), - * Route({ name: 'logout', handler: Logout }), - * Route({ name: 'about', handler: About }) - * ) - * ).renderComponent(document.body); - * - * Handlers for Route components that contain children can render their active - * child route using the activeRoute prop. - * - * var App = React.createClass({ - * render: function () { - * return ( - *
- * {this.props.activeRoute} - *
- * ); - * } - * }); - */ -function Router(routeComponent) { - if (!(this instanceof Router)) - return new Router(routeComponent); +function Router(route) { + warning( + false, + 'The Router().renderComponent(container) interface is deprecated and ' + + 'will be removed soon. Use React.renderComponent(, container) instead' + ); - this.route = Route.fromComponent(routeComponent); - this.state = {}; - this.components = []; - - this.displayName = getComponentDisplayName(this.route.handler) + 'Router'; -} - -mergeProperties(Router.prototype, { - - toString: function () { - return '<' + this.displayName + '>'; - }, - - /** - * 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. - * - * Router( - * - * - * - * - * - * - * ).match('/posts/123'); => [ { route: , params: {} }, - * { route: , params: {} }, - * { route: , params: { id: '123' } } ] - */ - match: function (path) { - return findMatches(Path.withoutQuery(path), this.route); - }, - - /** - * 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 router = this; - var matches = router.match(path); - - warning( - matches, - 'No route matches path "' + path + '". Make sure you have ' + - 'a somewhere in the Router' - ); - - if (!matches) - matches = []; - - var transition = new Transition(path); - - return syncRouterState(router.state, matches, transition).then(function (newState) { - if (transition.isCancelled) { - var reason = transition.cancelReason; - - if (reason instanceof Redirect) { - Router.replaceWith(reason.to, reason.params, reason.query); - } else if (reason instanceof Abort) { - Router.goBack(); - } - } else if (newState) { - router.setComponentProps(newState.props); - ActiveStore.update(newState); - } - - return transition; - }).then(undefined, function (error) { - if (returnRejectedPromise) - throw error; - - // Use setTimeout to break the promise chain. - setTimeout(function () { - router.handleAsyncError(error); - }); - }); - }, - - /** - * Updates the props of all components that have been rendered by this Router. - */ - setComponentProps: function (props) { - this.components.forEach(function (component) { - component.setProps(props); - }); - }, - - /** - * Handles errors that occur while transitioning. By default this function - * simply throws so that errors are not swallowed silently. You should - * override this function if you want to handle the error. - */ - handleAsyncError: function (error) { - throw error; // This error probably originated in a transition hook. - }, - - /** - * Renders this Router's component into the given container DOM element and - * returns a reference to the component (i.e. same as React.renderComponent). - */ - renderComponent: function (container, callback) { - var route = this.route; - var state = this.state; - var components = this.components; - - if (!URLStore.isSetup() && ExecutionEnvironment.canUseDOM) - URLStore.setup('hash'); - - if (!components.length) - URLStore.addChangeListener(this.handleRouteChange.bind(this)); - - var component = React.renderComponent(route.handler(state.props), container, callback); - - if (components.indexOf(component) === -1) - components.push(component); - - if (!state.props) - this.dispatch(URLStore.getCurrentPath()); // Bootstrap! - - return component; - }, - - /** - * Renders this Router's component as a string. Since this may be an asynchronous - * process, this returns a Promise. - */ - renderComponentToString: function(path) { - invariant( - !this.state.props, - 'You may only call renderComponentToString() on a new Router' - ); - - return this.dispatch(path).then(function() { - var route = this.route; - var state = this.state; - var descriptor = route.handler(state.props); - var markup = React.renderComponentToString(descriptor); - return markup; - }.bind(this)); - }, - - handleRouteChange: function () { - this.dispatch(URLStore.getCurrentPath()); - } - -}); - -mergeProperties(Router, { - - /** - * Tells the router to use the HTML5 history API for modifying URLs instead of - * hashes, which is the default. This is opt-in because it requires the server - * to be configured to serve the same HTML page regardless of the URL. - */ - useHistory: function () { - URLStore.setup('history'); - }, - - /** - * Returns a string that may safely be used as the href of a link to the route - * with the given name. See Router.makePath. - */ - makeHref: function (routeName, params, query) { - var path = Router.makePath(routeName, params, query); - - if (URLStore.getLocation() === 'hash') - return '#' + path; - - return path; - }, - - /** - * Returns an absolute URL path created from the given route name, URL - * parameters, and query values. - */ - makePath: function (to, params, query) { - var path; - if (to.charAt(0) === '/') { - path = Path.normalize(to); // Absolute path. - } else { - var route = Route.getByName(to); - - invariant( - route, - 'Unable to find a route named "' + to + '". Make sure you have ' + - 'a somewhere in the Router' - ); - - path = route.path; + return { + renderComponent: function (container, callback) { + return React.renderComponent(route, container, callback); } - - return Path.withQuery(Path.injectParams(path, params), query); - }, - - /** - * Transitions to the URL specified in the arguments by pushing a new URL onto - * the history stack. See Router.makePath. - */ - transitionTo: function (to, params, query) { - URLStore.push(Router.makePath(to, params, query)); - }, - - /** - * Transitions to the URL specified in the arguments by replacing the current - * URL in the history stack. See Router.makePath. - */ - replaceWith: function (to, params, query) { - URLStore.replace(Router.makePath(to, params, query)); - }, - - /** - * Transitions to the previous URL by removing the last entry from the history - * stack. - */ - goBack: function () { - URLStore.back(); - } - -}); - -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 () { - Router.transitionTo(this.path); - } - -}); - -function Abort() {} - -function Redirect(to, params, query) { - this.to = to; - this.params = params; - this.query = query; -} - -function findMatches(path, route) { - var childRoutes = route.childRoutes; - - if (childRoutes) { - // Search the subtree first to find the most deeply-nested route. - var matches; - for (var i = 0, length = childRoutes.length; i < length; ++i) { - matches = findMatches(path, childRoutes[i]); - - if (matches) { - var rootParams = matches[matches.length - 1].params; - var params = {}; - - route.paramNames.forEach(function (paramName) { - params[paramName] = rootParams[paramName]; - }); - - matches.unshift(makeMatch(route, params)); - - return matches; - } - } - } - - // No routes in the subtree matched, so check this route. - var params = Path.extractParams(route.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; - }); -} - -/** - * 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 was updated, or undefined if not. - */ -function syncRouterState(state, nextMatches, transition) { - if (state.path === transition.path) - return Promise.resolve(); // Nothing to do! - - var currentMatches = state.matches; - - var fromMatches, toMatches; - if (currentMatches) { - 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 = nextMatches[nextMatches.length - 1]; - - state.path = transition.path; - state.params = (rootMatch && rootMatch.params) || {}; - state.query = Path.extractQuery(state.path) || {}; - state.props = computeProps(nextMatches, state.query); - state.matches = nextMatches; - state.routes = nextMatches.map(function (match) { - return match.route; - }); - - 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.handler; - - if (!transition.isCancelled && handler.willTransitionFrom) - return handler.willTransitionFrom(transition, match.handlerInstance); - }); - }); - - 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.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 computeProps(matches, query) { - var props = { - key: null, - params: null, - query: null, - activeRoute: null }; - - var previousMatch; - reversedArray(matches).forEach(function (match) { - var route = match.route; - - props = {}; - - if (route.staticProps) - mergeProperties(props, route.staticProps); - - props.key = Path.injectParams(route.path, match.params); - props.params = match.params; - props.query = query; - - if (previousMatch) { - props.activeRoute = previousMatch.handlerInstance; - } else { - props.activeRoute = null; - } - - match.handlerInstance = route.handler(props); - - previousMatch = match; - }); - - return props; } module.exports = Router; diff --git a/modules/components/Link.js b/modules/components/Link.js index f396716db2..08d67eb81b 100644 --- a/modules/components/Link.js +++ b/modules/components/Link.js @@ -1,7 +1,8 @@ var React = require('react'); -var withoutProperties = require('../helpers/withoutProperties'); var ActiveStore = require('../stores/ActiveStore'); -var Router = require('../Router'); +var withoutProperties = require('../helpers/withoutProperties'); +var transitionTo = require('../helpers/transitionTo'); +var makeHref = require('../helpers/makeHref'); var RESERVED_PROPS = { to: true, @@ -11,20 +12,20 @@ var RESERVED_PROPS = { }; /** - * A Link component is used to create an element that links to a route. + * components are used to create an element that links to a route. * When that route is active, the link gets an "active" class name (or the - * value of its activeClassName prop). + * value of its `activeClassName` prop). * * For example, assuming you have the following route: * * * - * You could use the following link to transition to that route: + * You could use the following component to link to that route: * * * * In addition to params, links may pass along query string parameters - * using the query prop. + * using the `query` prop. * * */ @@ -67,7 +68,7 @@ var Link = React.createClass({ * Returns the value of the "href" attribute to use on the DOM element. */ getHref: function () { - return Router.makeHref(this.props.to, this.getParams(), this.props.query); + return makeHref(this.props.to, this.getParams(), this.props.query); }, /** @@ -107,10 +108,12 @@ var Link = React.createClass({ }, handleClick: function (event) { - if (!isModifiedEvent(event)) { - event.preventDefault(); - Router.transitionTo(this.props.to, this.getParams(), this.props.query); - } + if (isModifiedEvent(event)) + return; + + event.preventDefault(); + + transitionTo(this.props.to, this.getParams(), this.props.query); }, render: function () { diff --git a/modules/components/Route.js b/modules/components/Route.js index a293c79b46..18e83bcd08 100644 --- a/modules/components/Route.js +++ b/modules/components/Route.js @@ -1,7 +1,19 @@ var React = require('react'); +var warning = require('react/lib/warning'); +var invariant = require('react/lib/invariant'); +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 RESERVED_PROPS = { + location: true, handler: true, name: true, path: true, @@ -9,12 +21,17 @@ var RESERVED_PROPS = { }; /** - * Route components are used to configure a Router (see the Router docs). + * components are used to declare routes from which Route + * objects are made. See Route.fromComponent. */ var Route = React.createClass({ statics: { + handleAsyncError: function (error) { + throw error; // This error probably originated in a transition hook. + }, + getUnreservedProps: function (props) { return withoutProperties(props, RESERVED_PROPS); } @@ -22,17 +39,354 @@ var Route = React.createClass({ }, propTypes: { + location: React.PropTypes.oneOf([ 'hash', 'history' ]).isRequired, handler: React.PropTypes.component.isRequired, + path: React.PropTypes.string, name: React.PropTypes.string, - path: 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); + + return syncWithTransition(this, transition).then(function (newState) { + if (transition.isCancelled) { + var reason = transition.cancelReason; + + if (reason instanceof Redirect) { + replaceWith(reason.to, reason.params, reason.query); + } else if (reason instanceof Abort) { + goBack(); + } + } else if (newState) { + ActiveStore.update(newState); + } + + return transition; + }).then(undefined, function (error) { + if (returnRejectedPromise) + throw error; + + // Use setTimeout to break the promise chain. + setTimeout(function () { + Route.handleAsyncError(error); + }); + }); }, render: function () { - // This component is never actually inserted into the DOM, so we don't need to - // return anything here. Instead, its props are used to configure a Router. + return this.props.handler(this.state.handlerProps); } }); -module.exports = Route; +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; + + // 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; + var 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. + var 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]; +} + +/** + * 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) { + 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, + handlerProps: computeHandlerProps(nextMatches, query), + 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.handlerInstance); + }); + }); + + 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 = { + key: null, + params: null, + query: null, + activeRoute: null + }; + + var previousMatch; + reversedArray(matches).forEach(function (match) { + var route = match.route; + + props = Route.getUnreservedProps(route.props); + + props.key = Path.injectParams(route.props.path, match.params); + props.params = match.params; + props.query = query; + + if (previousMatch) { + props.activeRoute = previousMatch.handlerInstance; + } else { + props.activeRoute = null; + } + + match.handlerInstance = route.props.handler(props); + + previousMatch = match; + }); + + return props; +} + +function reversedArray(array) { + return array.slice(0).reverse(); +} + +module.exports = Route; diff --git a/modules/Path.js b/modules/helpers/Path.js similarity index 100% rename from modules/Path.js rename to modules/helpers/Path.js diff --git a/modules/helpers/getComponentDisplayName.js b/modules/helpers/getComponentDisplayName.js deleted file mode 100644 index aa82269f7c..0000000000 --- a/modules/helpers/getComponentDisplayName.js +++ /dev/null @@ -1,5 +0,0 @@ -function getComponentDisplayName(component) { - return component.type.displayName || 'UnnamedComponent'; -} - -module.exports = getComponentDisplayName; diff --git a/modules/helpers/goBack.js b/modules/helpers/goBack.js new file mode 100644 index 0000000000..7cf066dc0e --- /dev/null +++ b/modules/helpers/goBack.js @@ -0,0 +1,7 @@ +var URLStore = require('../stores/URLStore'); + +function goBack() { + URLStore.back(); +} + +module.exports = goBack; diff --git a/modules/helpers/makeHref.js b/modules/helpers/makeHref.js new file mode 100644 index 0000000000..4193086d7e --- /dev/null +++ b/modules/helpers/makeHref.js @@ -0,0 +1,17 @@ +var URLStore = require('../stores/URLStore'); +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(routeName, params, query) { + var path = makePath(routeName, params, query); + + if (URLStore.getLocation() === 'hash') + return '#' + path; + + return path; +} + +module.exports = makeHref; diff --git a/modules/helpers/makePath.js b/modules/helpers/makePath.js new file mode 100644 index 0000000000..e29b1ccd69 --- /dev/null +++ b/modules/helpers/makePath.js @@ -0,0 +1,28 @@ +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 (to.charAt(0) === '/') { + path = Path.normalize(to); // Absolute path. + } 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/helpers/replaceWith.js b/modules/helpers/replaceWith.js new file mode 100644 index 0000000000..ecdb03e732 --- /dev/null +++ b/modules/helpers/replaceWith.js @@ -0,0 +1,12 @@ +var URLStore = require('../stores/URLStore'); +var makePath = require('./makePath'); + +/** + * Transitions to the URL specified in the arguments by replacing + * the current URL in the history stack. + */ +function replaceWith(to, params, query) { + URLStore.replace(makePath(to, params, query)); +} + +module.exports = replaceWith; diff --git a/modules/helpers/reversedArray.js b/modules/helpers/reversedArray.js deleted file mode 100644 index 5433b6c69e..0000000000 --- a/modules/helpers/reversedArray.js +++ /dev/null @@ -1,5 +0,0 @@ -function reversedArray(array) { - return array.slice(0).reverse(); -} - -module.exports = reversedArray; diff --git a/modules/helpers/transitionTo.js b/modules/helpers/transitionTo.js new file mode 100644 index 0000000000..ee9fafb0c7 --- /dev/null +++ b/modules/helpers/transitionTo.js @@ -0,0 +1,12 @@ +var URLStore = require('../stores/URLStore'); +var makePath = require('./makePath'); + +/** + * Transitions to the URL specified in the arguments by pushing + * a new URL onto the history stack. + */ +function transitionTo(to, params, query) { + URLStore.push(makePath(to, params, query)); +} + +module.exports = transitionTo; diff --git a/modules/main.js b/modules/main.js index 23504edeb2..d8b4c3587b 100644 --- a/modules/main.js +++ b/modules/main.js @@ -1,3 +1,10 @@ -exports.Router = require('./Router'); -exports.Route = require('./components/Route'); exports.Link = require('./components/Link'); +exports.Route = require('./components/Route'); + +exports.goBack = require('./helpers/goBack'); +exports.replaceWith = require('./helpers/replaceWith'); +exports.transitionTo = require('./helpers/transitionTo'); + +// Backwards compat with 0.1. We should +// remove this when we ship 1.0. +exports.Router = require('./Router'); diff --git a/modules/stores/ActiveStore.js b/modules/stores/ActiveStore.js index b2709b43cc..92eb3228fe 100644 --- a/modules/stores/ActiveStore.js +++ b/modules/stores/ActiveStore.js @@ -2,7 +2,7 @@ var _activeRoutes = []; function routeIsActive(routeName) { return _activeRoutes.some(function (route) { - return route.name === routeName; + return route.props.name === routeName; }); } @@ -44,9 +44,9 @@ var ActiveStore = { update: function (state) { state = state || {}; - _activeRoutes = state.routes || []; - _activeParams = state.params || {}; - _activeQuery = state.query || {}; + _activeRoutes = state.activeRoutes || []; + _activeParams = state.activeParams || {}; + _activeQuery = state.activeQuery || {}; notifyChange(); }, diff --git a/modules/stores/RouteStore.js b/modules/stores/RouteStore.js new file mode 100644 index 0000000000..1462091007 --- /dev/null +++ b/modules/stores/RouteStore.js @@ -0,0 +1,88 @@ +var React = require('react'); +var invariant = require('react/lib/invariant'); +var Path = require('../helpers/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 = { + + /** + * Registers a and all of its children with the RouteStore. Also, + * does some normalization and validation on route props. + */ + registerRoute: function (route, _parentRoute) { + // Make sure the 's path begins with a slash. Default to its name. + // We can't do this in getDefaultProps because it may not be called on + // s that are never actually mounted. + if (route.props.path || route.props.name) { + route.props.path = Path.normalize(route.props.path || route.props.name); + } else { + route.props.path = '/'; + } + + // Make sure the has a valid React component for a handler. + invariant( + React.isValidComponent(route.props.handler), + 'The handler for Route "' + (route.props.name || route.props.path) + '" ' + + 'must be a valid React component' + ); + + // Make sure the has all params that its parent needs. + if (_parentRoute) { + var paramNames = Path.extractParamNames(route.props.path); + + Path.extractParamNames(_parentRoute.props.path).forEach(function (paramName) { + invariant( + paramNames.indexOf(paramName) !== -1, + 'The nested route path "' + route.props.path + '" is missing the "' + paramName + '" ' + + 'parameter of its parent path "' + _parentRoute.props.path + '"' + ); + }); + } + + // Make sure the can be looked up by s. + if (route.props.name) { + var existingRoute = _namedRoutes[route.props.name]; + + invariant( + !existingRoute || route === existingRoute, + 'You cannot use the name "' + route.props.name + '" for more than one route' + ); + + _namedRoutes[route.props.name] = route; + } + + React.Children.forEach(route.props.children, function (child) { + RouteStore.registerRoute(child, route); + }); + }, + + /** + * Removes the reference to the given and all of its children from + * the RouteStore. + */ + unregisterRoute: function (route) { + if (route.props.name) + delete _namedRoutes[route.props.name]; + + React.Children.forEach(route.props.children, function (child) { + RouteStore.unregisterRoute(route); + }); + }, + + /** + * Returns the Route object with the given name, if one exists. + */ + getRouteByName: function (routeName) { + return _namedRoutes[routeName]; + } + +}; + +module.exports = RouteStore; diff --git a/modules/stores/URLStore.js b/modules/stores/URLStore.js index 179e2c8ea5..df6f3b6d4d 100644 --- a/modules/stores/URLStore.js +++ b/modules/stores/URLStore.js @@ -1,7 +1,7 @@ var invariant = require('react/lib/invariant'); var warning = require('react/lib/warning'); var ExecutionEnvironment = require('react/lib/ExecutionEnvironment'); -var normalizePath = require('../Path').normalize; +var normalizePath = require('../helpers/Path').normalize; var CHANGE_EVENTS = { hash: 'hashchange', @@ -26,8 +26,7 @@ function getWindowPath() { /** * The URLStore keeps track of the current URL. In DOM environments, it may be * attached to window.location to automatically sync with the URL in a browser's - * location bar. The Router subscribes to the URLStore to know when the URL - * changes. + * location bar. s subscribe to the URLStore to know when the URL changes. */ var URLStore = {