Skip to content

Commit

Permalink
[added] Route/Match classes
Browse files Browse the repository at this point in the history
[changed] Route names are nested

This commit formalizes and enhances two of the core primitives in
the router: Route and Match.

We get a few benefits from this:

1. Routes may now be created programmatically, as well as via JSX.
   This is useful in situations where it is desirable to assemble
   the route configuration using separate modules, instead of all
   at once. For example, in ApplicationRoute.js you could have:

    module.exports = Router.createRoute();

   and in UserProfileRoute.js:

    var ApplicationRoute = require('./ApplicationRoute');

    module.exports = Router.createRoute({
      parentRoute: ApplicationRoute,
      path: 'users/:id'
    });

2. <Link to> may reference a Route object directly.

    <Link to={UserProfileRoute}>

3. Route names may be re-used at different levels of the hierarchy.
   For example, you could have two different routes named "new" but
   nested inside different parent routes.

    <Route name="users" handler={Users}>
      <DefaultRoute handler={ShowAllUsers}/>
      <Route name="new" handler={NewUser}/>
    </Route>
    <Route name="posts" handler={Posts}>
      <DefaultRoute handler={ShowAllPosts}/>
      <Route name="new" handler={NewPost}/>
    </Route>

   Using this route configuration, you could <Link to="users.new"> or
   <Link to="posts.new"> depending on which one you wanted. A side
   effect of this is that names of nested routes are no longer "global",
   so e.g. <Link to="new"> won't work because it is ambiguous, but
   <Link to="posts"> will still work.
mjackson committed Feb 16, 2015
1 parent 4a14a43 commit c5a24a5
Showing 9 changed files with 495 additions and 251 deletions.
65 changes: 65 additions & 0 deletions modules/Match.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/* jshint -W084 */

var Path = require('./utils/Path');

function Match(pathname, params, query, routes) {
this.pathname = pathname;
this.params = params;
this.query = query;
this.routes = routes;
}

function deepSearch(route, pathname, query) {
// Check the subtree first to find the most deeply-nested match.
var childRoutes = route.childRoutes;
if (childRoutes) {
var match, childRoute;
for (var i = 0, len = childRoutes.length; i < len; ++i) {
childRoute = childRoutes[i];

if (childRoute.isDefault || childRoute.isNotFound)
continue; // Check these in order later.

if (match = deepSearch(childRoute, pathname, query)) {
// A route in the subtree matched! Add this route and we're done.
match.routes.unshift(route);
return match;
}
}
}

// No child routes matched; try the default route.
var defaultRoute = route.defaultRoute;
if (defaultRoute && (params = Path.extractParams(defaultRoute.path, pathname)))
return new Match(pathname, params, query, [ route, defaultRoute ]);

// Does the "not found" route match?
var notFoundRoute = route.notFoundRoute;
if (notFoundRoute && (params = Path.extractParams(notFoundRoute.path, pathname)))
return new Match(pathname, params, query, [ route, notFoundRoute ]);

// Last attempt: check this route.
var params = Path.extractParams(route.path, pathname);
if (params)
return new Match(pathname, params, query, [ route ]);

return null;
}

/**
* Attempts to match depth-first a route in the given route's
* subtree against the given path and returns the match if it
* succeeds, null if no match can be made.
*/
Match.findMatchForPath = function (routes, path) {
var pathname = Path.withoutQuery(path);
var query = Path.extractQuery(path);
var match = null;

for (var i = 0, len = routes.length; match == null && i < len; ++i)
match = deepSearch(routes[i], pathname, query);

return match;
};

module.exports = Match;
284 changes: 284 additions & 0 deletions modules/Route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
var assign = require('react/lib/Object.assign');
var invariant = require('react/lib/invariant');
var warning = require('react/lib/warning');
var Path = require('./utils/Path');

function Route(name, path, ignoreScrollBehavior, isDefault, isNotFound, onEnter, onLeave, handler) {
this.name = name;
this.path = path;
this.paramNames = Path.extractParamNames(this.path);
this.ignoreScrollBehavior = !!ignoreScrollBehavior;
this.isDefault = !!isDefault;
this.isNotFound = !!isNotFound;
this.onEnter = onEnter;
this.onLeave = onLeave;
this.handler = handler;
}

Route.prototype.toString = function () {
var string = '<Route';

if (this.name)
string += ` name="${this.name}"`;

string += ` path="${this.path}">`;

return string;
};

/**
* Appends the given route to this route's child routes.
*/
Route.prototype.appendChildRoute = function (route) {
invariant(
route instanceof Route,
'route.appendChildRoute must use a valid Route'
);

if (!this.childRoutes)
this.childRoutes = [];

if (route.name) {
invariant(
this.childRoutes.every(function (childRoute) {
return childRoute.name !== route.name;
}),
'Route %s may not have more than one child route named "%s"',
this, route.name
);
}

this.childRoutes.push(route);
};

/**
* Allows looking up a child route using a "." delimited string, e.g.:
*
* route.appendChildRoute(
* Router.createRoute({ name: 'user' }, function () {
* Router.createRoute({ name: 'new' });
* })
* );
*
* var NewUserRoute = route.lookupChildRoute('user.new');
*
* See also Route.findRouteByName.
*/
Route.prototype.lookupChildRoute = function (names) {
if (!this.childRoutes)
return null;

return Route.findRouteByName(this.childRoutes, names);
};

/**
* Searches the given array of routes and returns the route that matches
* the given name. The name should be a . delimited string like "user.new"
* that specifies the names of nested routes. Routes in the hierarchy that
* do not have a name do not need to be specified in the search string.
*
* var routes = [
* Router.createRoute({ name: 'user' }, function () {
* Router.createRoute({ name: 'new' });
* })
* ];
*
* var NewUserRoute = Route.findRouteByName(routes, 'user.new');
*/
Route.findRouteByName = function (routes, names) {
if (typeof names === 'string')
names = names.split('.');

var route, foundRoute;
for (var i = 0, len = routes.length; i < len; ++i) {
route = routes[i];

if (route.name === names[0]) {
if (names.length === 1)
return route;

if (!route.childRoutes)
return null;

return Route.findRouteByName(route.childRoutes, names.slice(1));
} else if (route.name == null) {
// Transparently skip over unnamed routes in the tree.
foundRoute = route.lookupChildRoute(names);

if (foundRoute != null)
return foundRoute;
}
}

return null;
};

var _currentRoute;

/**
* Creates and returns a new route. Options may be a URL pathname string
* with placeholders for named params or an object with any of the following
* properties:
*
* - name The name of the route. This is used to lookup a
* route relative to its parent route and should be
* unique among all child routes of the same parent
* - path A URL pathname string with optional placeholders
* that specify the names of params to extract from
* the URL when the path matches. Defaults to `/${name}`
* when there is a name given, or the path of the parent
* route, or /
* - ignoreScrollBehavior True to make this route (and all descendants) ignore
* the scroll behavior of the router
* - isDefault True to make this route the default route among all
* its siblings
* - isNotFound True to make this route the "not found" route among
* all its siblings
* - onEnter A transition hook that will be called when the
* router is going to enter this route
* - onLeave A transition hook that will be called when the
* router is going to leave this route
* - handler A React component that will be rendered when
* this route is active
* - parentRoute The parent route to use for this route. This option
* is automatically supplied when creating routes inside
* the callback to another invocation of createRoute. You
* only ever need to use this when declaring routes
* independently of one another to manually piece together
* the route hierarchy
*
* The callback may be used to structure your route hierarchy. Any call to
* createRoute, createDefaultRoute, createNotFoundRoute, or createRedirect
* inside the callback automatically uses this route as its parent.
*/
Route.createRoute = function (options, callback) {
options = options || {};

if (typeof options === 'string')
options = { path: options };

var parentRoute = _currentRoute;

if (parentRoute) {
warning(
options.parentRoute == null || options.parentRoute === parentRoute,
'You should not use parentRoute with createRoute inside another route\'s child callback; it is ignored'
);
} else {
parentRoute = options.parentRoute;
}

var name = options.name;
var path = options.path || name;

if (path) {
if (Path.isAbsolute(path)) {
if (parentRoute) {
invariant(
parentRoute.paramNames.length === 0,
'You cannot nest path "%s" inside "%s"; the parent requires URL parameters',
path, parentRoute.path
);
}
} else if (parentRoute) {
// Relative paths extend their parent.
path = Path.join(parentRoute.path, path);
} else {
path = '/' + path;
}
} else {
path = parentRoute ? parentRoute.path : '/';
}

if (options.isNotFound && !(/\*$/).test(path))
path += '*'; // Auto-append * to the path of not found routes.

var route = new Route(
name,
path,
options.ignoreScrollBehavior,
options.isDefault,
options.isNotFound,
options.onEnter,
options.onLeave,
options.handler
);

if (parentRoute) {
if (route.isDefault) {
invariant(
parentRoute.defaultRoute == null,
'%s may not have more than one default route',
parentRoute
);

parentRoute.defaultRoute = route;
} else if (route.isNotFound) {
invariant(
parentRoute.notFoundRoute == null,
'%s may not have more than one not found route',
parentRoute
);

parentRoute.notFoundRoute = route;
}

parentRoute.appendChildRoute(route);
}

// Any routes created in the callback
// use this route as their parent.
if (typeof callback === 'function') {
var currentRoute = _currentRoute;
_currentRoute = route;
callback.call(route, route);
_currentRoute = currentRoute;
}

return route;
};

/**
* Creates and returns a route that is rendered when its parent matches
* the current URL.
*/
Route.createDefaultRoute = function (options) {
return Route.createRoute(
assign({}, options, { isDefault: true })
);
};

/**
* Creates and returns a route that is rendered when its parent matches
* the current URL but none of its siblings do.
*/
Route.createNotFoundRoute = function (options) {
return Route.createRoute(
assign({}, options, { isNotFound: true })
);
};

/**
* Creates and returns a route that automatically redirects the transition
* to another route. In addition to the normal options to createRoute, this
* function accepts the following options:
*
* - from An alias for the `path` option. Defaults to *
* - to The path/route/route name to redirect to
* - params The params to use in the redirect URL. Defaults
* to using the current params
* - query The query to use in the redirect URL. Defaults
* to using the current query
*/
Route.createRedirect = function (options) {
return Route.createRoute(
assign({}, options, {
path: options.path || options.from || '*',
onEnter: function (transition, params, query) {
transition.redirect(options.to, options.params || params, options.query || query);
}
})
);
};

module.exports = Route;
158 changes: 0 additions & 158 deletions modules/Routing.js

This file was deleted.

13 changes: 7 additions & 6 deletions modules/Transition.js
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@ var Redirect = require('./Redirect');
function Transition(path, retry) {
this.path = path;
this.abortReason = null;
// TODO: Change this to router.retryTransition(transition)
this.retry = retry.bind(this);
}

@@ -33,12 +34,12 @@ Transition.from = function (transition, routes, components, callback) {
return function (error) {
if (error || transition.abortReason) {
callback(error);
} else if (route.willTransitionFrom) {
} else if (route.onLeave) {
try {
route.willTransitionFrom(transition, components[index], callback);
route.onLeave(transition, components[index], callback);

// If there is no callback in the argument list, call it automatically.
if (route.willTransitionFrom.length < 3)
if (route.onLeave.length < 3)
callback();
} catch (e) {
callback(e);
@@ -55,12 +56,12 @@ Transition.to = function (transition, routes, params, query, callback) {
return function (error) {
if (error || transition.abortReason) {
callback(error);
} else if (route.willTransitionTo) {
} else if (route.onEnter) {
try {
route.willTransitionTo(transition, params, query, callback);
route.onEnter(transition, params, query, callback);

// If there is no callback in the argument list, call it automatically.
if (route.willTransitionTo.length < 4)
if (route.onEnter.length < 4)
callback();
} catch (e) {
callback(e);
69 changes: 33 additions & 36 deletions modules/__tests__/Router-test.js
Original file line number Diff line number Diff line change
@@ -807,6 +807,39 @@ describe('Router', function () {

});

describe('Router.makePath', function () {
var router;
beforeEach(function () {
router = Router.create(
<Route name="home" handler={Foo}>
<Route name="users" handler={Foo}>
<Route name="user" path=":id" handler={Foo}/>
</Route>
</Route>
);
});

describe('when given an absolute path', function () {
it('returns that path', function () {
expect(router.makePath('/about')).toEqual('/about');
});
});

describe('when there is a route with the given name', function () {
it('returns the correct path', function () {
expect(router.makePath('home.users.user', { id: 6 })).toEqual('/home/users/6');
});
});

describe('when there is no route with the given name', function () {
it('throws an error', function () {
expect(function () {
router.makePath('not-found');
}).toThrow();
});
});
});

describe('Router.run', function () {

it('matches a root route', function (done) {
@@ -1167,42 +1200,6 @@ describe('Router.run', function () {
});
});
});

describe('makePath', function () {
var router;
beforeEach(function () {
router = Router.create({
routes: [
<Route name="home" handler={Foo}>
<Route name="users" handler={Foo}>
<Route name="user" path=":id" handler={Foo}/>
</Route>
</Route>
]
});
});

describe('when given an absolute path', function () {
it('returns that path', function () {
expect(router.makePath('/about')).toEqual('/about');
});
});

describe('when there is a route with the given name', function () {
it('returns the correct path', function () {
expect(router.makePath('user', { id: 6 })).toEqual('/home/users/6');
});
});

describe('when there is no route with the given name', function () {
it('throws an error', function () {
expect(function () {
router.makePath('not-found');
}).toThrow();
});
});
});

});

describe.skip('unmounting', function () {
6 changes: 5 additions & 1 deletion modules/components/Link.js
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@ var assign = require('react/lib/Object.assign');
var Navigation = require('../Navigation');
var State = require('../State');
var PropTypes = require('../PropTypes');
var Route = require('../Route');

function isLeftClickEvent(event) {
return event.button === 0;
@@ -39,7 +40,10 @@ var Link = React.createClass({

propTypes: {
activeClassName: PropTypes.string.isRequired,
to: PropTypes.string.isRequired,
to: PropTypes.oneOfType([
PropTypes.string,
PropTypes.instanceOf(Route)
]),
params: PropTypes.object,
query: PropTypes.object,
activeStyle: PropTypes.object,
58 changes: 8 additions & 50 deletions modules/createRouter.js
Original file line number Diff line number Diff line change
@@ -12,13 +12,15 @@ var StaticLocation = require('./locations/StaticLocation');
var NavigationContext = require('./NavigationContext');
var ScrollHistory = require('./ScrollHistory');
var StateContext = require('./StateContext');
var createRoutesFromReactChildren = require('./Routing').createRoutesFromReactChildren;
var createRoutesFromReactChildren = require('./createRoutesFromReactChildren');
var isReactChildren = require('./isReactChildren');
var Transition = require('./Transition');
var PropTypes = require('./PropTypes');
var Redirect = require('./Redirect');
var History = require('./History');
var Cancellation = require('./Cancellation');
var Match = require('./Match');
var Route = require('./Route');
var supportsHistory = require('./utils/supportsHistory');
var Path = require('./utils/Path');

@@ -32,47 +34,6 @@ var DEFAULT_LOCATION = canUseDOM ? HashLocation : '/';
*/
var DEFAULT_SCROLL_BEHAVIOR = canUseDOM ? ImitateBrowserBehavior : null;

function createMatch(route, params, pathname, query) {
return {
routes: [ route ],
params: params,
pathname: pathname,
query: query
};
}

function findMatch(routes, defaultRoute, notFoundRoute, pathname, query) {
var route, match, 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.
match = findMatch(route.routes, route.defaultRoute, route.notFoundRoute, pathname, query);

if (match != null) {
match.routes.unshift(route);
return match;
}

// No routes in the subtree matched, so check this route.
params = Path.extractParams(route.path, pathname);

if (params)
return createMatch(route, params, pathname, query);
}

// No routes matched, so try the default route if there is one.
if (defaultRoute && (params = Path.extractParams(defaultRoute.path, pathname)))
return createMatch(defaultRoute, params, pathname, query);

// Last attempt: does the "not found" route match?
if (notFoundRoute && (params = Path.extractParams(notFoundRoute.path, pathname)))
return createMatch(notFoundRoute, params, pathname, query);

return null;
}

function hasProperties(object, properties) {
for (var propertyName in properties)
if (properties.hasOwnProperty(propertyName) && object[propertyName] !== properties[propertyName])
@@ -173,9 +134,6 @@ function createRouter(options) {

clearAllRoutes: function () {
this.cancelPendingTransition();
this.defaultRoute = null;
this.notFoundRoute = null;
this.namedRoutes = {};
this.routes = [];
},

@@ -184,7 +142,7 @@ function createRouter(options) {
*/
addRoutes: function (routes) {
if (isReactChildren(routes))
routes = createRoutesFromReactChildren(routes, this, this.namedRoutes);
routes = createRoutesFromReactChildren(routes);

this.routes.push.apply(this.routes, routes);
},
@@ -204,7 +162,7 @@ function createRouter(options) {
* match can be made.
*/
match: function (path) {
return findMatch(this.routes, this.defaultRoute, this.notFoundRoute, Path.withoutQuery(path), Path.extractQuery(path));
return Match.findMatchForPath(this.routes, path);
},

/**
@@ -216,11 +174,11 @@ function createRouter(options) {
if (Path.isAbsolute(to)) {
path = Path.normalize(to);
} else {
var route = this.namedRoutes[to];
var route = (to instanceof Route) ? to : Route.findRouteByName(this.routes, to);

invariant(
route,
'Unable to find <Route name="%s">',
route instanceof Route,
'Cannot find a route named "%s"',
to
);

88 changes: 88 additions & 0 deletions modules/createRoutesFromReactChildren.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/* jshint -W084 */

var React = require('react');
var assign = require('react/lib/Object.assign');
var warning = require('react/lib/warning');
var DefaultRouteType = require('./components/DefaultRoute').type;
var NotFoundRouteType = require('./components/NotFoundRoute').type;
var RedirectType = require('./components/Redirect').type;
var Route = require('./Route');

function checkPropTypes(componentName, propTypes, props) {
componentName = componentName || 'UnknownComponent';

for (var propName in propTypes) {
if (propTypes.hasOwnProperty(propName)) {
var error = propTypes[propName](props, propName, componentName);

if (error instanceof Error)
warning(false, error.message);
}
}
}

function createRouteOptions(props) {
var options = assign({}, props);
var handler = options.handler;

if (handler) {
options.onEnter = handler.willTransitionTo;
options.onLeave = handler.willTransitionFrom;
}

return options;
}

function createRouteFromReactElement(element) {
if (!React.isValidElement(element))
return;

var type = element.type;
var props = element.props;

if (type.propTypes)
checkPropTypes(type.displayName, type.propTypes, props);

if (type === DefaultRouteType)
return Route.createDefaultRoute(createRouteOptions(props));

if (type === NotFoundRouteType)
return Route.createNotFoundRoute(createRouteOptions(props));

if (type === RedirectType)
return Route.createRedirect(createRouteOptions(props));

return Route.createRoute(createRouteOptions(props), function () {
if (props.children)
createRoutesFromReactChildren(props.children);
});
}

/**
* Creates and returns an array of routes created from the given
* ReactChildren, all of which should be one of <Route>, <DefaultRoute>,
* <NotFoundRoute>, or <Redirect>, e.g.:
*
* var { createRoutesFromReactChildren, Route, Redirect } = require('react-router');
*
* var routes = createRoutesFromReactChildren(
* <Route path="/" handler={App}>
* <Route name="user" path="/user/:userId" handler={User}>
* <Route name="task" path="tasks/:taskId" handler={Task}/>
* <Redirect from="todos/:taskId" to="task"/>
* </Route>
* </Route>
* );
*/
function createRoutesFromReactChildren(children) {
var routes = [];

React.Children.forEach(children, function (child) {
if (child = createRouteFromReactElement(child))
routes.push(child);
});

return routes;
}

module.exports = createRoutesFromReactChildren;
5 changes: 5 additions & 0 deletions modules/index.js
Original file line number Diff line number Diff line change
@@ -18,6 +18,11 @@ exports.Navigation = require('./Navigation');
exports.RouteHandlerMixin = require('./RouteHandlerMixin');
exports.State = require('./State');

exports.createRoute = require('./Route').createRoute;
exports.createDefaultRoute = require('./Route').createDefaultRoute;
exports.createNotFoundRoute = require('./Route').createNotFoundRoute;
exports.createRedirect = require('./Route').createRedirect;
exports.createRoutesFromReactChildren = require('./createRoutesFromReactChildren');
exports.create = require('./createRouter');
exports.run = require('./runRouter');

0 comments on commit c5a24a5

Please sign in to comment.