diff --git a/NotFoundRoute.js b/NotFoundRoute.js
new file mode 100644
index 0000000000..76f2d6950c
--- /dev/null
+++ b/NotFoundRoute.js
@@ -0,0 +1 @@
+module.exports = require('./modules/components/NotFoundRoute');
diff --git a/examples/master-detail/app.js b/examples/master-detail/app.js
index e6f1cbc289..b5df96dfe6 100644
--- a/examples/master-detail/app.js
+++ b/examples/master-detail/app.js
@@ -5,6 +5,7 @@ var Route = Router.Route;
var DefaultRoute = Router.DefaultRoute;
var Routes = Router.Routes;
var Link = Router.Link;
+var NotFoundRoute = Router.NotFoundRoute;
var api = 'http://addressbook-api.herokuapp.com/contacts';
var _contacts = {};
@@ -114,6 +115,7 @@ var App = React.createClass({
+ Invalid Link (not found)
{this.props.activeRouteHandler()}
@@ -247,8 +249,8 @@ var routes = (
-
+
);
diff --git a/index.js b/index.js
index c2bb0391bd..b6a70293e2 100644
--- a/index.js
+++ b/index.js
@@ -2,10 +2,11 @@ exports.ActiveState = require('./ActiveState');
exports.AsyncState = require('./AsyncState');
exports.DefaultRoute = require('./DefaultRoute');
exports.Link = require('./Link');
+exports.NotFoundRoute = require('./NotFoundRoute');
exports.Redirect = require('./Redirect');
exports.Route = require('./Route');
exports.Routes = require('./Routes');
exports.goBack = require('./goBack');
+exports.makeHref = require('./makeHref');
exports.replaceWith = require('./replaceWith');
exports.transitionTo = require('./transitionTo');
-exports.makeHref = require('./makeHref');
diff --git a/modules/components/NotFoundRoute.js b/modules/components/NotFoundRoute.js
new file mode 100644
index 0000000000..1da81ff389
--- /dev/null
+++ b/modules/components/NotFoundRoute.js
@@ -0,0 +1,13 @@
+var merge = require('react/lib/merge');
+var Route = require('./Route');
+
+function NotFoundRoute(props) {
+ return Route(
+ merge(props, {
+ path: null,
+ catchAll: true
+ })
+ );
+}
+
+module.exports = NotFoundRoute;
diff --git a/modules/components/Routes.js b/modules/components/Routes.js
index 44d1188587..2361c40d45 100644
--- a/modules/components/Routes.js
+++ b/modules/components/Routes.js
@@ -143,7 +143,7 @@ var Routes = React.createClass({
* { route: , params: { id: '123' } } ]
*/
match: function (path) {
- return findMatches(Path.withoutQuery(path), this.state.routes, this.props.defaultRoute);
+ return findMatches(Path.withoutQuery(path), this.state.routes, this.props.defaultRoute, this.props.notFoundRoute);
},
/**
@@ -218,14 +218,14 @@ var Routes = React.createClass({
});
-function findMatches(path, routes, defaultRoute) {
+function findMatches(path, routes, defaultRoute, notFoundRoute) {
var matches = null, route, params;
for (var i = 0, len = routes.length; i < len; ++i) {
route = routes[i];
// Check the subtree first to find the most deeply-nested match.
- matches = findMatches(path, route.props.children, route.props.defaultRoute);
+ matches = findMatches(path, route.props.children, route.props.defaultRoute, route.props.notFoundRoute);
if (matches != null) {
var rootParams = getRootMatch(matches).params;
@@ -248,11 +248,13 @@ function findMatches(path, routes, defaultRoute) {
}
// No routes matched, so try the default route if there is one.
- params = defaultRoute && Path.extractParams(defaultRoute.props.path, path);
-
- if (params)
+ if (defaultRoute && (params = Path.extractParams(defaultRoute.props.path, path)))
return [ makeMatch(defaultRoute, params) ];
+ // Last attempt: does the "not found" route match?
+ if (notFoundRoute && (params = Path.extractParams(notFoundRoute.props.path, path)))
+ return [ makeMatch(notFoundRoute, params) ];
+
return matches;
}
diff --git a/modules/stores/RouteStore.js b/modules/stores/RouteStore.js
index 26dae5bd94..15ba3b289b 100644
--- a/modules/stores/RouteStore.js
+++ b/modules/stores/RouteStore.js
@@ -48,13 +48,17 @@ var RouteStore = {
props.name || props.path
);
- // Default routes have no name, path, or children.
- var isDefault = !(props.path || props.name || props.children);
-
- if (props.path || props.name) {
+ if ((props.path || props.name) && !props.catchAll) {
props.path = Path.normalize(props.path || props.name);
- } else if (parentRoute && parentRoute.props.path) {
- props.path = parentRoute.props.path;
+ } else if (parentRoute) {
+ // have no path prop.
+ props.path = parentRoute.props.path || '/';
+
+ if (props.catchAll) {
+ props.path += '*';
+ } else if (!props.children) {
+ props.isDefault = true;
+ }
} else {
props.path = '/';
}
@@ -85,7 +89,28 @@ var RouteStore = {
_namedRoutes[props.name] = route;
}
- if (parentRoute && isDefault) {
+ if (props.catchAll) {
+ invariant(
+ parentRoute,
+ ' must have a parent '
+ );
+
+ invariant(
+ parentRoute.props.notFoundRoute == null,
+ 'You may not have more than one per '
+ );
+
+ parentRoute.props.notFoundRoute = route;
+
+ return null;
+ }
+
+ if (props.isDefault) {
+ invariant(
+ parentRoute,
+ ' must have a parent '
+ );
+
invariant(
parentRoute.props.defaultRoute == null,
'You may not have more than one per '
diff --git a/specs/NotFoundRoute.spec.js b/specs/NotFoundRoute.spec.js
new file mode 100644
index 0000000000..41f8bf7817
--- /dev/null
+++ b/specs/NotFoundRoute.spec.js
@@ -0,0 +1,64 @@
+require('./helper');
+var RouteStore = require('../modules/stores/RouteStore');
+var NotFoundRoute = require('../modules/components/NotFoundRoute');
+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 NotFoundRoute', function () {
+ describe('nested inside a Route component', function () {
+ it('becomes that Route\'s notFoundRoute', function () {
+ var notFoundRoute;
+ var route = Route({ handler: App },
+ notFoundRoute = NotFoundRoute({ handler: App })
+ );
+
+ RouteStore.registerRoute(route);
+ expect(route.props.notFoundRoute).toBe(notFoundRoute);
+ RouteStore.unregisterRoute(route);
+ });
+ });
+
+ describe('nested inside a Routes component', function () {
+ it('becomes that Routes\' notFoundRoute', function () {
+ var notFoundRoute;
+ var routes = Routes({ handler: App },
+ notFoundRoute = NotFoundRoute({ handler: App })
+ );
+
+ RouteStore.registerRoute(notFoundRoute, routes);
+ expect(routes.props.notFoundRoute).toBe(notFoundRoute);
+ RouteStore.unregisterRoute(notFoundRoute);
+ });
+ });
+});
+
+describe('when no child routes match a URL, but the beginning of the parent\'s path matches', function () {
+ it('matches the default route', function () {
+ var notFoundRoute;
+ var routes = ReactTestUtils.renderIntoDocument(
+ 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.
+ notFoundRoute = NotFoundRoute({ handler: App }),
+ Route({ name: 'news', path: '/users/:id/news', handler: App })
+ )
+ )
+ );
+
+ var matches = routes.match('/users/5/not-found');
+ assert(matches);
+ expect(matches.length).toEqual(2);
+
+ expect(matches[1].route).toBe(notFoundRoute);
+
+ expect(matches[0].route.props.name).toEqual('user');
+ });
+});
diff --git a/specs/main.js b/specs/main.js
index 4c6c5cb003..29fa8639a1 100644
--- a/specs/main.js
+++ b/specs/main.js
@@ -3,6 +3,7 @@
require('./ActiveStore.spec.js');
require('./AsyncState.spec.js');
require('./DefaultRoute.spec.js');
+require('./NotFoundRoute.spec.js');
require('./Path.spec.js');
require('./PathStore.spec.js');
require('./Route.spec.js');