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.
  • Loading branch information
mjackson committed Feb 16, 2015
1 parent 4a14a43 commit c5a24a5
Show file tree
Hide file tree
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;
Loading

0 comments on commit c5a24a5

Please sign in to comment.