diff --git a/DefaultRoute.js b/DefaultRoute.js new file mode 100644 index 0000000000..8bfa55a793 --- /dev/null +++ b/DefaultRoute.js @@ -0,0 +1 @@ +module.exports = require('./modules/components/DefaultRoute'); diff --git a/examples/dynamic-segments/app.js b/examples/dynamic-segments/app.js index 6a94d5ed3d..9792ce0e91 100644 --- a/examples/dynamic-segments/app.js +++ b/examples/dynamic-segments/app.js @@ -47,14 +47,15 @@ var Task = React.createClass({ }); var routes = ( - - - - - - + + + + - + ); -React.renderComponent(routes, document.getElementById('example')); +React.renderComponent( + , + document.getElementById('example') +); diff --git a/examples/master-detail/app.js b/examples/master-detail/app.js index 46ba1c4d75..272e489b10 100644 --- a/examples/master-detail/app.js +++ b/examples/master-detail/app.js @@ -2,6 +2,7 @@ var React = require('react'); var Router = require('../../index'); var Route = Router.Route; +var DefaultRoute = Router.DefaultRoute; var Routes = Router.Routes; var Link = Router.Link; @@ -85,12 +86,10 @@ var App = React.createClass({ }, componentDidMount: function() { - console.log('componentDidMount') ContactStore.addChangeListener(this.updateContacts); }, componentWillUnmount: function () { - console.log('componentWillUnmount') ContactStore.removeChangeListener(this.updateContacts); }, @@ -104,10 +103,6 @@ var App = React.createClass({ }); }, - indexTemplate: function() { - return

Address Book

; - }, - render: function() { var contacts = this.state.contacts.map(function(contact) { return
  • {contact.first}
  • @@ -121,13 +116,19 @@ var App = React.createClass({
    - {this.props.activeRouteHandler() || this.indexTemplate()} + {this.props.activeRouteHandler()}
    ); } }); +var Index = React.createClass({ + render: function() { + return

    Address Book

    ; + } +}); + var Contact = React.createClass({ getInitialState: function() { return { @@ -204,16 +205,18 @@ var NotFound = React.createClass({ }); var routes = ( - - - - - - - + + + + + + ); -React.renderComponent(routes, document.getElementById('example')); +React.renderComponent( + , + document.getElementById('example') +); // Request utils. diff --git a/index.js b/index.js index 436d957f0e..c2bb0391bd 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,6 @@ exports.ActiveState = require('./ActiveState'); exports.AsyncState = require('./AsyncState'); +exports.DefaultRoute = require('./DefaultRoute'); exports.Link = require('./Link'); exports.Redirect = require('./Redirect'); exports.Route = require('./Route'); diff --git a/modules/components/DefaultRoute.js b/modules/components/DefaultRoute.js new file mode 100644 index 0000000000..ef281bcc6f --- /dev/null +++ b/modules/components/DefaultRoute.js @@ -0,0 +1,19 @@ +var copyProperties = require('react/lib/copyProperties'); +var Route = require('./Route'); + +/** + * A component is a special kind of that + * renders when its parent matches but none of its siblings do. + * Only one such route may be used at any given level in the + * route hierarchy. + */ +function DefaultRoute(props) { + return Route( + copyProperties(props, { + name: null, + path: null + }) + ); +} + +module.exports = DefaultRoute; diff --git a/modules/components/Route.js b/modules/components/Route.js index 14c6679ca3..407e6cf7c7 100644 --- a/modules/components/Route.js +++ b/modules/components/Route.js @@ -9,6 +9,8 @@ var withoutProperties = require('../helpers/withoutProperties'); var RESERVED_PROPS = { handler: true, path: true, + defaultRoute: true, + paramNames: true, children: true // ReactChildren }; diff --git a/modules/components/Routes.js b/modules/components/Routes.js index bde17afcba..c279a4fc9d 100644 --- a/modules/components/Routes.js +++ b/modules/components/Routes.js @@ -94,7 +94,20 @@ var Routes = React.createClass({ }, getInitialState: function () { - return {}; + return { + routes: this.getRoutes() + }; + }, + + getRoutes: function () { + var routes = []; + + React.Children.forEach(this.props.children, function (child) { + if (child = RouteStore.registerRoute(child, this)) + routes.push(child); + }, this); + + return routes; }, getLocation: function () { @@ -107,12 +120,7 @@ var Routes = React.createClass({ }, componentWillMount: function () { - React.Children.forEach(this.props.children, function (child) { - RouteStore.registerRoute(child); - }); - PathStore.setup(this.getLocation()); - PathStore.addChangeListener(this.handlePathChange); }, @@ -146,15 +154,7 @@ var Routes = React.createClass({ * { 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; + return findMatches(Path.withoutQuery(path), this.state.routes, this.props.defaultRoute); }, /** @@ -229,53 +229,42 @@ var Routes = React.createClass({ }); -function findMatches(path,route){ - var matches = null; +function findMatches(path, routes, defaultRoute) { + var matches = null, route, params; - if (Array.isArray(route)) { - for (var i = 0, len = route.length; matches == null && i < len; ++i) { - matches = findMatches(path, route[i]); - } - } else { - matches = findMatchesForRoute(path,route); - } - - return matches; -} + for (var i = 0, len = routes.length; i < len; ++i) { + route = routes[i]; -function findMatchesForRoute(path, route) { - var children = route.props.children, matches; - var params; + // Check the subtree first to find the most deeply-nested match. + matches = findMatches(path, route.props.children, route.props.defaultRoute); - // 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 != null) { + var rootParams = getRootMatch(matches).params; + + params = route.props.paramNames.reduce(function (params, paramName) { + params[paramName] = rootParams[paramName]; + return params; + }, {}); - if (matches) { - var rootParams = getRootMatch(matches).params; - params = {}; + matches.unshift(makeMatch(route, params)); - Path.extractParamNames(route.props.path).forEach(function (paramName) { - params[paramName] = rootParams[paramName]; - }); + return matches; + } - matches.unshift(makeMatch(route, params)); + // No routes in the subtree matched, so check this route. + params = Path.extractParams(route.props.path, path); - return matches; + if (params) + return [ makeMatch(route, params) ]; } - // No routes in the subtree matched, so check this route. - params = Path.extractParams(route.props.path, path); + // No routes matched, so try the default route if there is one. + params = defaultRoute && Path.extractParams(defaultRoute.props.path, path); if (params) - return [ makeMatch(route, params) ]; + return [ makeMatch(defaultRoute, params) ]; - return null; + return matches; } function makeMatch(route, params) { diff --git a/modules/stores/RouteStore.js b/modules/stores/RouteStore.js index 16ba3105a8..7525ed5304 100644 --- a/modules/stores/RouteStore.js +++ b/modules/stores/RouteStore.js @@ -26,12 +26,12 @@ var RouteStore = { * from the store. */ unregisterRoute: function (route) { - if (route.props.name) - delete _namedRoutes[route.props.name]; + var props = route.props; - React.Children.forEach(route.props.children, function (child) { - RouteStore.unregisterRoute(child); - }); + if (props.name) + delete _namedRoutes[props.name]; + + React.Children.forEach(props.children, RouteStore.unregisterRoute); }, /** @@ -39,50 +39,76 @@ var RouteStore = { * 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 = '/'; - } + // Note: When route is a top-level route, _parentRoute + // is actually a , not a . We do this so + // can get a defaultRoute like does. + var props = route.props; - // Make sure the has a valid React component for a handler. invariant( - React.isValidClass(route.props.handler), - 'The handler for Route "' + (route.props.name || route.props.path) + '" ' + - 'must be a valid React component' + React.isValidClass(props.handler), + 'The handler for the "%s" route must be a valid React class', + props.name || props.path ); - // Make sure the has all params that its parent needs. - if (_parentRoute) { - var paramNames = Path.extractParamNames(route.props.path); + // Default routes have no name, path, or children. + var isDefault = !(props.path || props.name || props.children); - Path.extractParamNames(_parentRoute.props.path).forEach(function (paramName) { + if (props.path || props.name) { + props.path = Path.normalize(props.path || props.name); + } else if (_parentRoute && _parentRoute.props.path) { + props.path = _parentRoute.props.path; + } else { + 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( - paramNames.indexOf(paramName) !== -1, - 'The nested route path "' + route.props.path + '" is missing the "' + paramName + '" ' + - 'parameter of its parent path "' + _parentRoute.props.path + '"' + 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 can be looked up by s. - if (route.props.name) { - var existingRoute = _namedRoutes[route.props.name]; + // 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 "' + route.props.name + '" for more than one route' + 'You cannot use the name "%s" for more than one route', + props.name ); - _namedRoutes[route.props.name] = route; + _namedRoutes[props.name] = route; } - React.Children.forEach(route.props.children, function (child) { - RouteStore.registerRoute(child, route); + if (_parentRoute && isDefault) { + 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, excluding s. + var children = []; + + React.Children.forEach(props.children, function (child) { + if (child = RouteStore.registerRoute(child, route)) + children.push(child); }); + + props.children = children; + + return route; }, /** diff --git a/specs/DefaultRoute.spec.js b/specs/DefaultRoute.spec.js new file mode 100644 index 0000000000..e7455924f4 --- /dev/null +++ b/specs/DefaultRoute.spec.js @@ -0,0 +1,66 @@ +require('./helper'); +var RouteStore = require('../modules/stores/RouteStore'); +var DefaultRoute = require('../modules/components/DefaultRoute'); +var Route = require('../modules/components/Route'); +var Routes = require('../modules/components/Routes'); + +var App = React.createClass({ + displayName: 'App', + render: function () { + return React.DOM.div(); + } +}); + +describe('when registering a DefaultRoute', function () { + describe('nested inside a Route component', function () { + it('becomes that Route\'s defaultRoute', function () { + var defaultRoute; + var route = Route({ handler: App }, + defaultRoute = DefaultRoute({ handler: App }) + ); + + RouteStore.registerRoute(route); + expect(route.props.defaultRoute).toBe(defaultRoute); + RouteStore.unregisterRoute(route); + }); + }); + + describe('nested inside a Routes component', function () { + it('becomes that Routes\' defaultRoute', function () { + var defaultRoute; + var routes = Routes({ handler: App }, + defaultRoute = DefaultRoute({ handler: App }) + ); + + RouteStore.registerRoute(defaultRoute, routes); + expect(routes.props.defaultRoute).toBe(defaultRoute); + RouteStore.unregisterRoute(defaultRoute); + }); + }); +}); + +describe('when no child routes match a URL, but the parent matches', function () { + it('matches the default route', function () { + var defaultRoute; + var routes = renderComponent( + Routes(null, + Route({ name: 'user', path: '/users/:id', handler: App }, + Route({ name: 'home', path: '/users/:id/home', handler: App }), + // Make it the middle sibling to test order independence. + defaultRoute = DefaultRoute({ handler: App }), + Route({ name: 'news', path: '/users/:id/news', handler: App }) + ) + ) + ); + + var matches = routes.match('/users/5'); + assert(matches); + expect(matches.length).toEqual(2); + + expect(matches[1].route).toBe(defaultRoute); + + expect(matches[0].route.props.name).toEqual('user'); + + removeComponent(routes); + }); +}); diff --git a/specs/Route.spec.js b/specs/Route.spec.js index f255b63b25..61361a2d8b 100644 --- a/specs/Route.spec.js +++ b/specs/Route.spec.js @@ -71,7 +71,7 @@ describe('a nested Route that matches the URL', function () { Routes(null, Route({ handler: App }, Route({ name: 'posts', path: '/posts/:id', handler: App }, - Route({ name: 'comment', path: '/posts/:id/comments/:commentId', handler: App }) + Route({ name: 'comment', path: '/posts/:id/comments/:commentID', handler: App }) ) ) ) @@ -83,7 +83,7 @@ describe('a nested Route that matches the URL', function () { var rootMatch = getRootMatch(matches); expect(rootMatch.route.props.name).toEqual('comment'); - expect(rootMatch.params).toEqual({ id: 'abc', commentId: '123' }); + expect(rootMatch.params).toEqual({ id: 'abc', commentID: '123' }); var postsMatch = matches[1]; expect(postsMatch.route.props.name).toEqual('posts'); diff --git a/specs/RouteStore.spec.js b/specs/RouteStore.spec.js index 614fc7b656..ded71e5b14 100644 --- a/specs/RouteStore.spec.js +++ b/specs/RouteStore.spec.js @@ -43,6 +43,19 @@ describe('when registering a route', function () { expect(route.props.path).toEqual('/'); RouteStore.unregisterRoute(route); }); + + describe('that is nested inside another route', function () { + it('uses the parent\'s path', function () { + var child; + var route = Route({ name: 'home', handler: App }, + child = Route({ handler: App }) + ); + + RouteStore.registerRoute(route); + expect(child.props.path).toEqual(route.props.path); + RouteStore.unregisterRoute(route); + }); + }); }); describe('with a name but no path', function () { diff --git a/specs/main.js b/specs/main.js index d7f80e3d62..4c6c5cb003 100644 --- a/specs/main.js +++ b/specs/main.js @@ -2,6 +2,7 @@ // for every spec file, there must be some sort of config but I can't find it ... require('./ActiveStore.spec.js'); require('./AsyncState.spec.js'); +require('./DefaultRoute.spec.js'); require('./Path.spec.js'); require('./PathStore.spec.js'); require('./Route.spec.js');