Skip to content

Commit

Permalink
implement transition hooks
Browse files Browse the repository at this point in the history
  • Loading branch information
christopherthielen committed Sep 26, 2014
1 parent 27fafbf commit 5a04411
Show file tree
Hide file tree
Showing 5 changed files with 220 additions and 92 deletions.
2 changes: 1 addition & 1 deletion files.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ routerFiles = {
// 'test/resolveSpec.js',
// 'test/urlMatcherFactorySpec.js',
// 'test/urlRouterSpec.js',
'test/*Spec.js',
'test/transitionSpec.js',
'test/compat/matchers.js'
],
angular: function(version) {
Expand Down
17 changes: 17 additions & 0 deletions src/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,23 @@ function flattenPrototypeChain(obj) {
return result;
}

// Return a completely flattened version of an array.

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

I keep porting underscore functions I find useful... at some point we're going to be reimplement the whole frigging thing

function flatten (array) {
function _flatten(input, output) {
forEach(input, function(value) {
if (angular.isArray(value)) {
_flatten(value, output);
} else {
output.push(value);
}
});
return output;
}

return _flatten(array, []);
}


var GlobBuilder = (function() {

function Glob(text) {
Expand Down
5 changes: 4 additions & 1 deletion src/resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ function $Resolve( $q, $injector) {
});
};

// Eager resolves are resolved before the transition starts.
// Lazy resolves are resolved before their state is entered.
// JIT resolves are resolved just-in-time, right before an injected function that depends on them is invoked.
var resolvePolicies = { eager: 3, lazy: 2, jit: 1 };
var defaultResolvePolicy = "eager"; // TODO: make this configurable

Expand Down Expand Up @@ -203,7 +206,7 @@ function $Resolve( $q, $injector) {
toPathElement = toPathElement || elements[elements.length - 1];
var elementIdx = elements.indexOf(toPathElement);
if (elementIdx == -1) throw new Error("this Path does not contain the toPathElement");
return new ResolveContext(self.slice(0, elementIdx));
return new ResolveContext(self.slice(0, elementIdx + 1));
}

// Public API
Expand Down
280 changes: 193 additions & 87 deletions src/transition.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,51 +7,125 @@
$TransitionProvider.$inject = [];
function $TransitionProvider() {

var $transition = {}, events, stateMatcher = angular.noop, abstractKey = 'abstract';
var $transition = {}, stateMatcher = angular.noop, abstractKey = 'abstract';
var transitionEvents = { on: [], entering: [], exiting: [], success: [], error: [] };

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

data structure to hold $transitionProvider.on/entering/exiting/success/error callbacks


function matchState(current, states) {
var toMatch = angular.isArray(states) ? states : [states];
function matchState(state, globStrings) {
var toMatch = angular.isArray(globStrings) ? globStrings : [globStrings];

for (var i = 0; i < toMatch.length; i++) {
var glob = GlobBuilder.fromString(toMatch[i]);

if ((glob && glob.matches(current.name)) || (!glob && toMatch[i] === current.name)) {
if ((glob && glob.matches(state.name)) || (!glob && toMatch[i] === state.name)) {
return true;
}
}
return false;
}

// $transitionProvider.on({ from: "home", to: "somewhere.else" }, function($transition$, $http) {
// // ...
// });
this.on = function(states, callback) {
};

// $transitionProvider.onEnter({ from: "home", to: "somewhere.else" }, function($transition$, $http) {
// // ...
// });
this.entering = function(states, callback) {
};

// $transitionProvider.onExit({ from: "home", to: "somewhere.else" }, function($transition$, $http) {
// // ...
// });
this.exiting = function(states, callback) {
};

// $transitionProvider.onSuccess({ from: "home", to: "somewhere.else" }, function($transition$, $http) {
// // ...
// });
this.onSuccess = function(states, callback) {
};

// $transitionProvider.onError({ from: "home", to: "somewhere.else" }, function($transition$, $http) {
// // ...
// });
this.onError = function(states, callback) {
};
// Return a registration function of the requested type.
function registerEventHook(eventType) {
return function(stateGlobs, callback) {
transitionEvents[eventType].push(new EventHook(stateGlobs, callback));
};
}

/**
* @ngdoc function
* @name ui.router.state.$transitionProvider#on
* @methodOf ui.router.state.$transitionProvider
*
* @description
* Registers a function to be injected and invoked when a transition between the matched 'to' and 'from' states
* starts.
*
* @param {object} transitionCriteria An object that specifies which transitions to invoke the callback for.
*
* - **`to`** - {string} - A glob string that matches the 'to' state's name.

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

I'm starting to think glob might be insufficient. Maybe we should use functions for the criteria. What do you think?
It would allow for code like this:

.entering({ to: function(state) { return state.data.authrequired; }, from: '*' }, function($transition$) { /* validate auth, etc */ })

This comment has been minimized.

Copy link
@nateabele

nateabele Sep 26, 2014

Owner

Copying my comments from chat:

Re: function criteria, I'm torn. It's an awesome idea. I just know we're going to be fighting
off people begging for those functions to be injectable. :-/

Slippery slope.

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

Maybe a FAQ entry like so:

app.config(function($transitionProvider, $injector) {
  var authStateCriteria = function(state) { 
    return $injector.invoke(function($authService) { 
      return $authService.requiresAuth(state);
    }
  };
  $transitionProvider.entering({ to: authStateCriteria, from: "*" }, function($transition$, $authService) { 
    /* do stuff with $authService */ 
  });
});

This comment has been minimized.

Copy link
@nateabele

nateabele Sep 26, 2014

Owner

Pretty sure you can only inject providers in a config() block. Therein lies the problem.

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

Aha, there's the rub. This is way more difficult than it should be.

I added a runtime injector provider with a .invoke method $rtiProvider.invoke(function) in this commit:
5c5aa5f

Then the FAQ could be:

app.config(function($transitionProvider, $rtiProvider) {
  var authStateCriteria = function(state) { 
    return $rtiProvider.invoke(function($authService) { 
      return $authService.requiresAuth(state);
    });
  };
  $transitionProvider.entering({ to: authStateCriteria, from: "*" }, function($transition$, $authService) { 
    /* do stuff with $authService */ 
  });
});

As a plus, this would let people put an injected fn on any callback anywhere, when we don't inject explicitly.

Not sure if this is too ugly, but it would silence some people complaining about not getting their functions injected automatically.

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 27, 2014

Author Collaborator

@nateabele check it out. If we provide $state$ and functional match criteria, some cool stuff people want is ridiculously easy:

UI-Router Extras style Deep state redirect in less than 20 lines of code.

app.config(function($transitionProvider) {
  var dsr = { }; // hold last-known deep state Transition object

  // Matcher for redirect logic. Triggers when a DSR marked state is being transitioned to.
  var dsrCriteria = { to: function (state) { return state.deepStateRedirect; }, from: "*" };
  $transitionProvider.on(dsrCriteria, function($state$) {
    var redirect = dsr[$state$.name]; // Check if we recorded a deep state redirect Transition
    return redirect;
  });

  $transitionProvider.onSuccess({ to: "*", from: "*" }, function($transition$) {
    // After a successful transition, check if any parents were DSR. If so, record the deep state.
    angular.forEach($transition$.to.$state.path, function(state) {
      if (state.deepStateRedirect) { // If a parent is a DSR, record a redirect Transition object.
        dsr[state.name] = $transition$.redirect($transition$.to.state, $transition$.params().to);
      }
    });
  });
});

default substate in 4 lines of code

As requested in angular-ui#948 and elsewhere

app.config(function($transitionProvider) {
  var defaultSubstateCriteria = { to: function (state) { return state.defaultSubstate; }, from: "*" };
  $transitionProvider.on(defaultSubstateCriteria, function($transition$, $state$) {
    return $transition$.redirect($state$.defaultSubstate);
  });
})

This comment has been minimized.

Copy link
@nateabele

nateabele Sep 28, 2014

Owner

I added a runtime injector provider with a .invoke method $rtiProvider.invoke(function) in this commit:

It feels pretty weird to have a provider that's totally disconnected from the rest of the library and only exists for one specific use case. I'd say pull it out and throw it in a gist or plunk, then link that into the FAQ.

* - **`from`** - {string|RegExp} - A glob string that matches the 'from' state's name.
*
* @param {function} callback The function which will be injected and invoked, when a matching transition is started.
*
* @return {boolean|object|array} May optionally return:
* - **`false`** to abort the current transition
* - **A promise** to suspend the current transition until the promise resolves
* - **Array of Resolvable objects** to add additional resolves to the current transition, which will be available
* for injection to further steps in the transition.
*/
this.on = registerEventHook("on");

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

No resolves injected. Can return false, a promise, or an array of Resolvable objects. This means I am going to expose Resolvable in some manner, perhaps a factory function off $transition$ like .redirect()

This comment has been minimized.

Copy link
@nateabele

nateabele Sep 26, 2014

Owner

This means I am going to expose Resolvable in some manner, perhaps a factory function off $transition$ like .redirect()

So people can chain off of resolved promises in the transition?

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

They can add Resolvable objects to the ResolveContext for use further down the chain.

The transition will resolve the new Resolvable just-in-time when it is used by any other injected function.

$transitionProvider.on({ to: 'foo.*', from: '*' }, function($transition$, someservice) {
  // make 'someobject' available in bar.* onEnter/onExit functions as a "JIT" Resolvable
  return [ new Resolvable("someobject", someservice.asyncFetch()) ]; });

Then you can use it elsewhere

// This function gets invoked from PathContext.invokeLater.  It will wait until someobject is resolved before invoking.
$transitionProvider.entering({ to: "foo.*", from: "*" }, function($transition$, someobject) {
  // someobject is resolved and unwrapped
});

This comment has been minimized.

Copy link
@nateabele

nateabele Sep 26, 2014

Owner

return [ new Resolvable("someobject", someservice.asyncFetch()) ]; });

Alternatively: return { "someobject": someservice.asyncFetch() };

Less ceremony and more fluency for the end user, and the values easily decompose in a way that they can be built back up within a Resolvable internally.

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

I would definitely prefer that, syntactically.

However, my thought process then went to how to interpret the result value in TransitionStep. I currently add dynamic resolves by testing instanceof Resolvable. We also wanted to allow for a $transition$.redirect() to be returned in order to modify the transition (by superseding it). Then consider that there's potential to allow even more return value types that we would also need to interpret (such as Extras concepts like "inactivate this state", etc.).

I'd like to stay away from heuristics (such as "is this an object map, and are the values functions") and would prefer something explicit and undeniable, like instanceof Resolvable.


So let's figure out a clean way to return different values from callbacks.

So let's say we allow return { "resolve": promiseFn, "resolve2", promiseFn2 } . How do we enable return values of $transition$.redirect() or false (or $transition$.abort()) cleanly without backing ourselves into a corner?

This comment has been minimized.

Copy link
@nateabele

nateabele Sep 26, 2014

Owner

Well, $transition$.redirect() will always return instanceof Transition, and promises will always have a .then(), so that's those two. Any fancy statuses should probably be typed as well. Which brings us back to returning plain objects. We can either make that the only non-typed object, or use a factory, like $transition$.resovle({ "someobject": someservice.asyncFetch() }), which takes care of boxing everything.

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

fair enough, plain objects it is for "dynamic resolves"


/**
* @ngdoc function
* @name ui.router.state.$transitionProvider#entering
* @methodOf ui.router.state.$transitionProvider
*
* @description
* Registers a function to be injected and invoked during a transition between the matched 'to' and 'from' states,
* when the matched 'to' state is being entered. This function is in injected with the entering state's resolves.
* @param {object} transitionCriteria See transitionCriteria in {@link ui.router.state.$transitionProvider#on $transitionProvider.on}.
* @param {function} callback See callback in {@link ui.router.state.$transitionProvider#on $transitionProvider.on}.
*
* @return {boolean|object|array} May optionally return:
* - **`false`** to abort the current transition
* - **A promise** to suspend the current transition until the promise resolves
* - **Array of Resolvable objects** to add additional resolves to the current transition, which will be available
* for injection to further steps in the transition.
*/
this.entering = registerEventHook("entering");

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

Injects resolves applicable to current state.


/**
* @ngdoc function
* @name ui.router.state.$transitionProvider#exiting
* @methodOf ui.router.state.$transitionProvider
*
* @description
* Registers a function to be injected and invoked during a transition between the matched 'to' and 'from states,
* when the matched 'from' state is being exited. This function is in injected with the exiting state's resolves.
* @param {object} transitionCriteria See transitionCriteria in {@link ui.router.state.$transitionProvider#on $transitionProvider.on}.
* @param {function} callback See callback in {@link ui.router.state.$transitionProvider#on $transitionProvider.on}.
*
* @return {boolean|object|array} May optionally return:
* - **`false`** to abort the current transition
* - **A promise** to suspend the current transition until the promise resolves
* - **Array of Resolvable objects** to add additional resolves to the current transition, which will be available
* for injection to further steps in the transition.
*/
this.exiting = registerEventHook("exiting");

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

Injects resolves applicable to current state.


/**
* @ngdoc function
* @name ui.router.state.$transitionProvider#onSuccess
* @methodOf ui.router.state.$transitionProvider
*
* @description
* Registers a function to be injected and invoked when a transition has successfully completed between the matched
* 'to' and 'from' state is being exited.
* This function is in injected with the 'to' state's resolves.
* @param {object} transitionCriteria See transitionCriteria in {@link ui.router.state.$transitionProvider#on $transitionProvider.on}.
* @param {function} callback See callback in {@link ui.router.state.$transitionProvider#on $transitionProvider.on}.
*/
this.onSuccess = registerEventHook("success");

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

No resolves injected. No return value.


/**
* @ngdoc function
* @name ui.router.state.$transitionProvider#onError
* @methodOf ui.router.state.$transitionProvider
*
* @description
* Registers a function to be injected and invoked when a transition has failed for any reason between the matched
* 'to' and 'from' state is being exited. This function is in injected with the 'to' state's resolves. The transition
* rejection reason is injected as `$transitionError$`.
* @param {object} transitionCriteria See transitionCriteria in {@link ui.router.state.$transitionProvider#on $transitionProvider.on}.
* @param {function} callback See callback in {@link ui.router.state.$transitionProvider#on $transitionProvider.on}.
*/
this.onError = registerEventHook("error");

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

No resolves injected. No return value. This isn't hooked up yet.


function EventHook(stateGlobs, callback) {
this.callback = callback;
this.matches = function matches(to, from) {
return matchState(to, stateGlobs.to) && matchState(from, stateGlobs.from);
};
}

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

Simple data structure to encapsulate a registered hook


/**
* @ngdoc service
Expand All @@ -67,7 +141,6 @@ function $TransitionProvider() {
this.$get = $get;
$get.$inject = ['$q', '$injector', '$resolve', '$stateParams'];
function $get( $q, $injector, $resolve, $stateParams) {

var from = { state: null, params: null },
to = { state: null, params: null };
var _fromPath = null; // contains resolved data
Expand Down Expand Up @@ -142,55 +215,6 @@ function $TransitionProvider() {
hasCalculated = true;
}

function transitionStep(fn, resolveContext) {
return function() {
if ($transition.transition !== transition) return transition.SUPERSEDED;
return pathElement.invokeAsync(fn, { $stateParams: undefined, $transition$: transition }, resolveContext)
.then(function(result) {
return result ? result : $q.reject(transition.ABORTED);
});
};
}

function buildTransitionSteps() {
// create invokeFn fn.
// - checks if current transition has been superseded
// - invokes Fn async
// - checks result. If falsey, rejects promise

// get exiting & reverse them
// get entering

// walk exiting()
// - InvokeAsync
// resolve all eager Path resolvables
// walk entering()
// - resolve PathElement lazy resolvables
// - then, invokeAsync onEnter

var exitingElements = transition.exiting().slice(0).reverse().elements;
var enteringElements = transition.entering().elements;
var promiseChain = $q.when(true);

forEach(exitingElements, function(elem) {
if (elem.state.onExit) {
var nextStep = transitionStep(elem.state.onExit, fromPath.resolveContext(elem));
promiseChain.then(nextStep);
}
});

forEach(enteringElements, function(elem) {
var resolveContext = toPath.resolveContext(elem);
promiseChain.then(function() { return elem.resolve(resolveContext, { policy: "lazy" }); });
if (elem.state.onEnter) {
var nextStep = transitionStep(elem.state.onEnter, resolveContext);
promiseChain.then(nextStep);
}
});

return promiseChain;
}

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

buildTransitionSteps is completely rewritten inside Transition.run(). I haven't yet hooked into state.onEnter/onExit. I think state.onEnter will be sugar for $transitionProvider.entering(state.name, "**", state.onEnter);

extend(this, {
/**
* @ngdoc function
Expand Down Expand Up @@ -341,12 +365,93 @@ function $TransitionProvider() {
ignored: function() {
return (toState === fromState && !options.reload);
},

run: function() {
calculateTreeChanges();
var pathContext = new ResolveContext(toPath);
return toPath.resolve(pathContext, { policy: "eager" })
.then( buildTransitionSteps );

function TransitionStep(pathElement, fn, locals, resolveContext, otherData) {
this.state = pathElement.state;
this.otherData = otherData;
this.fn = fn;

this.invokeStep = function invokeStep() {
if ($transition.transition !== transition) return transition.SUPERSEDED;

/** Returns a map containing any Resolvables found in result as an object or Array */
function resolvablesFromResult(result) {
var resolvables = [];
if (result instanceof Resolvable) {
resolvables.push(result);
} else if (angular.isArray(result)) {
resolvables.push(filter(result, function(obj) { return obj instanceof Resolvable; }));
}
return indexBy(resolvables, 'name');
}

/** Adds any returned resolvables to the resolveContext for the current state */
function handleHookResult(result) {
var newResolves = resolvablesFromResult(result);
extend(resolveContext.$$resolvablesByState[pathElement.state.name], newResolves);
return result === false ? transition.ABORTED : result;
}

return pathElement.invokeLater(fn, locals, resolveContext).then(handleHookResult);
};
}

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

This encapsulates a promise step for the running transition. This is a little funky and could probably be revisited. I added otherData, fn, and state because it's helpful when debugging.

TransitonStep has invokeStep function that runs the step.
resolvablesFromResult unpackages the return value and if any resolves were returned, organizes them into an assoc-array.
handleHookResult updates the resolveContext by adding any new resolves for use by any future steps. It also aborts the transition when false was returned.


/**
* returns an array of transition steps (promises) that matched
* 1) the eventType
* 2) the to state
* 3) the from state
*/
function makeSteps(eventType, to, from, pathElement, locals, resolveContext) {
var extraData = { eventType: eventType, to: to, from: from, pathElement: pathElement, locals: locals, resolveContext: resolveContext }; // internal debugging stuff
var hooks = transitionEvents[eventType];
var matchingHooks = filter(hooks, function(hook) { return hook.matches(to, from); });
return map(matchingHooks, function(hook) {
return new TransitionStep(pathElement, hook.callback, locals, resolveContext, extraData);
});
}

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

This function builds an array of TransitionSteps that match the specified criteria. So this array will consist of all the "on" hooks that matched for the current transition, or all the "entering" hooks matching for the current state being entered, etc.


var tLocals = { $transition$: transition };
var rootPE = new PathElement(stateMatcher("", {}));
var rootPath = new Path([rootPE]);
var exitingElements = transition.exiting().slice(0).reverse().elements;
var enteringElements = transition.entering().elements;
var to = transition.to(), from = transition.from();

// Build a bunch of arrays of promises for each step of the transition
var transitionOnHooks = makeSteps("on", to, from, rootPE, tLocals, rootPath.resolveContext());

var exitingStateHooks = map(exitingElements, function(elem) {
var enterLocals = extend({}, tLocals, { $stateParams: $stateParams.$localize(elem.state, $stateParams) });
return makeSteps("exiting", to, from, elem, enterLocals, fromPath.resolveContext(elem));
});
var enteringStateHooks = map(enteringElements, function(elem) {
var exitLocals = extend({}, tLocals, { $stateParams: $stateParams.$localize(elem.state, $stateParams) });
return makeSteps("entering", to, from, elem, exitLocals, toPath.resolveContext(elem));
});

var successHooks = makeSteps("onSuccess", to, from, rootPE, tLocals, rootPath.resolveContext());
var errorHooks = makeSteps("onError", to, from, rootPE, tLocals, rootPath.resolveContext());

var eagerResolves = function () { return toPath.resolve(toPath.resolveContext(), { policy: "eager" }); };

var allSteps = flatten(transitionOnHooks, eagerResolves, exitingStateHooks, enteringStateHooks, successHooks);

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

This chunk of code builds up all the transition "steps" in the proper order. When it's done, it flattens the arrays down into one giant array of steps.

This is where we send in the appropriate locals, match the correct states, provide the appropriate resolve context, etc for each step.

I just noticed this is buggy in that it isn't matching the correct hooks for exiting and entering.



// Set up a promise chain. Add the promises in appropriate order to the promise chain.
var chain = $q.when(true);
forEach(allSteps, function (step) {
chain.then(step.invokeStep);
});

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

Since allSteps is an ordered array of TransitionSteps, we can just iterate and chain their invokeStep functions together


// TODO: call errorHooks.

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

I need to think about how to handle the errorHooks.


return chain;
},

begin: function(compare, exec) {
if (!compare()) return this.SUPERSEDED;
if (!exec()) return this.ABORTED;
Expand Down Expand Up @@ -377,7 +482,8 @@ function $TransitionProvider() {

$transition.start = function start(state, params, options) {
to = { state: state, params: params || {} };
return new Transition(from.state, from.params, state, params || {}, options || {});
this.transition = new Transition(from.state, from.params, state, params || {}, options || {});

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 26, 2014

Author Collaborator

Set $transition.transition in $transition.start()

return this.transition;
};

$transition.isActive = function isActive() {
Expand Down
Loading

0 comments on commit 5a04411

Please sign in to comment.