Skip to content

Commit

Permalink
Add willTransitionTo/willTransitionAway hooks
Browse files Browse the repository at this point in the history
  • Loading branch information
mjackson committed Jun 9, 2014
1 parent f33fa38 commit d83cf7b
Show file tree
Hide file tree
Showing 2 changed files with 267 additions and 52 deletions.
314 changes: 264 additions & 50 deletions lib/router.js
Expand Up @@ -249,45 +249,6 @@ mergeInto(Router.prototype, {
return null;
},

/**
* Returns a hash of props that should be passed to this router's component
* given the current URL path.
*/
getComponentProps: function (currentPath) {
var match = this.match(path.withoutQuery(currentPath));
var query = path.extractQuery(currentPath) || {};

warning(
!(currentPath && !match),
'No routes matched path "' + currentPath + '"'
);

if (!match)
return {};

return match.reduceRight(function (childProps, m) {
var router = m.router, params = m.params;
var props = {
handler: router.handler,
params: params,
query: query
};

if (router.staticProps)
mergeInto(props, router.staticProps);

if (childProps && childProps.handler) {
props.activeRoute = childProps.handler(childProps);
} else {
// Make sure transitioning to the same path with new
// params causes an update.
props.key = currentPath;
}

return props;
}, null);
},

/**
* Renders this router's component to the given DOM node and returns a
* reference to the rendered component.
Expand All @@ -300,24 +261,89 @@ mergeInto(Router.prototype, {
urlStore.addChangeListener(this.handleRouteChange.bind(this));
}

var component = this.handler(this.getComponentProps(urlStore.getCurrentPath()));

this._component = React.renderComponent(component, node);
this._component = React.renderComponent(this.handler(), node);
this.transitionTo(urlStore.getCurrentPath());

return this._component;
},

handleRouteChange: function () {
var currentPath = urlStore.getCurrentPath();

// TODO: Use route handlers here to determine whether or
// not the transition should be cancelled.

this._updateComponentProps(currentPath);
this.transitionTo(urlStore.getCurrentPath());
},

_updateComponentProps: function (currentPath) {
this._component.setProps(this.getComponentProps(currentPath));
/**
* Executes a transition of this router to the given path, checking all
* transition hooks along the way to ensure the transition is valid.
*
* Note: This method may only be called on top-level routers that have
* already been rendered (see Router#renderComponent).
*
* Transition Hooks
* ----------------
*
* Route handlers may use the willTransitionTo and willTransitionAway static
* methods to control routing behavior. When transitioning to a route, the
* willTransitionTo hook is called with the transition object and a hash of
* params that handler will receive. It should return an object that will be
* used as the "context" prop in the handler instance.
*
* When transitioning away from a route, the willTransitionAway hook is called
* with the transition object.
*
* Either hook may return a promise for its result if it needs to do something
* asynchronous, like make a request to a server.
*
* Aborting or Redirecting Transitions
* -----------------------------------
*
* A hook may abort the transition by calling `transition.abort()`. Similarly,
* hooks can perform a redirect using `transition.redirect('/login')`, for example.
* After either aborting or redirecting the transition no other hooks will be called.
*
* Note: transition.redirect replaces the current URL so it is not added to the
* browser's history.
*
* Retrying Transitions
* --------------------
*
* A reference to a transition object may be kept and used later to retry a
* transition that was aborted or redirected for some reason. To do this, use
* `transition.retry()`.
*/
transitionTo: function (currentPath) {
invariant(
this._component,
'You cannot use transitionTo on a router that has not been rendered'
);

var matches = this.match(path.withoutQuery(currentPath));

if (!matches) {
warning(
currentPath === '/',
'No routes matched path "' + currentPath + '"'
);

this._component.setProps({});
} else {
var transition = new Transition(currentPath);

checkTransitionHooks(this._lastMatches, matches, transition).then(function () {
if (transition.isCancelled()) {
var reason = transition.cancelReason;

if (reason instanceof Redirect)
Router.replaceWith(reason.to, reason.params, reason.query);
} else {
this._lastMatches = matches;
this._component.setProps(getComponentProps(currentPath, matches));
}
}.bind(this), function (error) {
// There was an unrecoverable error. Should we provide some kind of
// hook for users to handle this (e.g. Router.handleError)?
throw error;
});
}
},

toString: function () {
Expand All @@ -326,12 +352,200 @@ mergeInto(Router.prototype, {

});

function Transition(path) {
this.path = path;
this.cancelReason = null;
}

mergeInto(Transition.prototype, {

isCancelled: function () {
return this.cancelReason != null;
},

abort: function () {
this.cancelReason = new Abort();
},

redirect: function (to, params, query) {
this.cancelReason = new Redirect(to, params, query);
},

retry: function () {
Router.transitionTo(this.path);
}

});

function Abort() {}

function Redirect(to, params, query) {
this.to = to;
this.params = params;
this.query = query;
}

function getComponentDisplayName(component) {
return component.type.displayName || 'UnnamedComponent';
}

function getComponentProps(currentPath, matches) {
var query = path.extractQuery(currentPath) || {};

return matches.reduceRight(function (childProps, match) {
var router = match.router, params = match.params;
var props = {
handler: router.handler,
context: router.currentContext,
params: params,
query: query
};

if (router.staticProps)
mergeInto(props, router.staticProps);

if (childProps && childProps.handler) {
props.activeRoute = childProps.handler(childProps);
} else {
// Make sure transitioning to the same path with new
// params causes an update.
props.key = currentPath;
}

return props;
}, null);
}

function makeMatch(router, params) {
return { router: router, params: params };
}

function hasMatch(matches, match) {
return matches.some(function (m) {
return match.router === m.router && hasProperties(match.params, m.params);
});
}

function hasProperties(object, properties) {
for (var property in object) {
if (object[property] !== properties[property])
return false;
}

return true;
}

/**
* Figures out which routers we're transitioning away from and to and runs all
* transition hooks in serial, starting with the willTransitionAway hook on the most
* deeply nested handler that is currently active, up the route hierarchy to the common
* parent route, and back down the route hierarchy ending with the willTransitionTo
* hook on the most deeply nested handler that will be active if the transition
* completes successfully.
*
* After all hooks complete successfully, the currentContext of all routers involved
* in the trasition is updated accordingly. Routes that we're transitioning to receive
* the result of willTransitionTo as their currentContext.
*
* Returns a promise that resolves when all transition hooks are complete.
*/
function checkTransitionHooks(lastMatches, currentMatches, transition) {
var awayMatches, toMatches;
if (lastMatches) {
awayMatches = lastMatches.filter(function (match) {
return !hasMatch(currentMatches, match);
});

toMatches = currentMatches.filter(function (match) {
return !hasMatch(lastMatches, match);
});
} else {
awayMatches = [];
toMatches = currentMatches;
}

return checkTransitionAwayHooks(awayMatches, transition).then(function () {
if (transition.isCancelled())
return; // No need to continue.

return checkTransitionToHooks(toMatches, transition).then(function (contexts) {
if (transition.isCancelled())
return; // No need to continue.

// At this point all checks have completed successfully so we proceed
// with the transition and remove the reference to the context object
// from routers we're transitioning away from.
awayMatches.forEach(function (match) {
delete match.router.currentContext;
});

// Likewise, update the currentContext of routers we're transitioning to.
toMatches.forEach(function (match, index) {
match.router.currentContext = contexts[index];
});
});
});
}

var Promise = require('es6-promise').Promise;

/**
* Calls the willTransitionAway hook of all handlers in the given matches
* serially in reverse, so that the deepest nested handlers are called first.
* Returns a promise that resolves after the last handler.
*/
function checkTransitionAwayHooks(matches, transition) {
var promise = Promise.resolve();

reversedArray(matches).forEach(function (match) {
promise = promise.then(function () {
if (transition.isCancelled())
return; // Short-circuit.

var handler = match.router.handler;

if (handler.willTransitionAway)
return handler.willTransitionAway(transition);
});
});

return promise;
}

function reversedArray(array) {
return array.slice(0).reverse();
}

/**
* 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 for an array of the context objects that are returned. If any handler
* does not specify a willTransitionTo hook, its context is undefined.
*/
function checkTransitionToHooks(matches, transition) {
var promise = Promise.resolve();
var contexts = [];

matches.forEach(function (match) {
promise = promise.then(function () {
if (transition.isCancelled())
return; // Short-circuit.

var handler = match.router.handler;

if (handler.willTransitionTo) {
return Promise.resolve(handler.willTransitionTo(transition, match.params)).then(function (context) {
contexts.push(context);
});
}

contexts.push(undefined);
});
});

return promise.then(function () {
return contexts;
});
}

module.exports = Router;
5 changes: 3 additions & 2 deletions package.json
Expand Up @@ -19,8 +19,9 @@
"mocha": "^1.20.1"
},
"dependencies": {
"react": "^0.10.0",
"es6-promise": "^1.0.0",
"event-emitter": "^0.3.1",
"querystring": "^0.2.0",
"event-emitter": "^0.3.1"
"react": "^0.10.0"
}
}

0 comments on commit d83cf7b

Please sign in to comment.