diff --git a/karma.conf.js b/karma.conf.js index 0f7340aa..e697edbe 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -34,7 +34,7 @@ module.exports = function (karma) { ], webpack: { - devtool: 'source-map', + devtool: 'inline-source-map', resolve: { modulesDirectories: ['node_modules'], diff --git a/src/transition/hookBuilder.ts b/src/transition/hookBuilder.ts index 14e26994..341055e3 100644 --- a/src/transition/hookBuilder.ts +++ b/src/transition/hookBuilder.ts @@ -70,7 +70,7 @@ export class HookBuilder { */ buildHooks(hookType: TransitionHookType): TransitionHook[] { // Find all the matching registered hooks for a given hook type - let matchingHooks = this._matchingHooks(hookType.name, this.treeChanges); + let matchingHooks = this.getMatchingHooks(hookType, this.treeChanges); if (!matchingHooks) return []; const makeTransitionHooks = (hook: IEventHook) => { @@ -87,7 +87,7 @@ export class HookBuilder { }, this.baseHookOptions); let state = hookType.hookScope === TransitionHookScope.STATE ? node.state : null; - let transitionHook = new TransitionHook(this.transition, state, hook, _options); + let transitionHook = new TransitionHook(this.transition, state, hook, hookType, _options); return { hook, node, transitionHook }; }); }; @@ -109,12 +109,16 @@ export class HookBuilder { * * @returns an array of matched [[IEventHook]]s */ - private _matchingHooks(hookName: string, treeChanges: TreeChanges): IEventHook[] { - return [ this.transition, this.$transitions ] // Instance and Global hook registries - .map((reg: IHookRegistry) => reg.getHooks(hookName)) // Get named hooks from registries - .filter(assertPredicate(isArray, `broken event named: ${hookName}`)) // Sanity check - .reduce(unnestR, []) // Un-nest IEventHook[][] to IEventHook[] array - .filter(hook => hook.matches(treeChanges)); // Only those satisfying matchCriteria + public getMatchingHooks(hookType: TransitionHookType, treeChanges: TreeChanges): IEventHook[] { + let isCreate = hookType.hookPhase === TransitionHookPhase.CREATE; + + // Instance and Global hook registries + let registries = isCreate ? [ this.$transitions ] : [ this.transition, this.$transitions ]; + + return registries.map((reg: IHookRegistry) => reg.getHooks(hookType.name)) // Get named hooks from registries + .filter(assertPredicate(isArray, `broken event named: ${hookType.name}`)) // Sanity check + .reduce(unnestR, []) // Un-nest IEventHook[][] to IEventHook[] array + .filter(hook => hook.matches(treeChanges)); // Only those satisfying matchCriteria } } diff --git a/src/transition/interface.ts b/src/transition/interface.ts index 00d00693..b865e90d 100644 --- a/src/transition/interface.ts +++ b/src/transition/interface.ts @@ -158,9 +158,6 @@ export interface TreeChanges { entering: PathNode[]; } -export type IErrorHandler = (error: Error) => void; - -export type IHookGetter = (hookName: string) => IEventHook[]; export type IHookRegistration = (matchCriteria: HookMatchCriteria, callback: HookFn, options?: HookRegOptions) => Function; /** @@ -214,7 +211,20 @@ export interface TransitionStateHookFn { (transition: Transition, state: State) : HookResult } -export type HookFn = (TransitionHookFn|TransitionStateHookFn); +/** + * The signature for Transition onCreate Hooks. + * + * Transition onCreate Hooks are callbacks that allow customization or preprocessing of + * a Transition before it is returned from [[TransitionService.create]] + * + * @param transition the [[Transition]] that was just created + * @return a [[Transition]] which will then be returned from [[TransitionService.create]] + */ +export interface TransitionCreateHookFn { + (transition: Transition): void +} + +export type HookFn = (TransitionHookFn|TransitionStateHookFn|TransitionCreateHookFn); /** * The return value of a [[TransitionHookFn]] or [[TransitionStateHookFn]] diff --git a/src/transition/transition.ts b/src/transition/transition.ts index 65f1eeb8..3bef5881 100644 --- a/src/transition/transition.ts +++ b/src/transition/transition.ts @@ -10,7 +10,10 @@ import { isObject, isArray } from "../common/predicates"; import { prop, propEq, val, not } from "../common/hof"; import {StateDeclaration, StateOrName} from "../state/interface"; -import { TransitionOptions, TreeChanges, IHookRegistry, IEventHook, TransitionHookPhase } from "./interface"; +import { + TransitionOptions, TreeChanges, IHookRegistry, IEventHook, TransitionHookPhase, + TransitionCreateHookFn +} from "./interface"; import { TransitionStateHookFn, TransitionHookFn } from "./interface"; // has or is using @@ -150,9 +153,21 @@ export class Transition implements IHookRegistry { this.$id = transitionCount++; let toPath = PathFactory.buildToPath(fromPath, targetState); this._treeChanges = PathFactory.treeChanges(fromPath, toPath, this._options.reloadState); + this.createTransitionHookRegFns(); + + let onCreateHooks = this.hookBuilder().buildHooksForPhase(TransitionHookPhase.CREATE); + TransitionHook.runAllHooks(onCreateHooks); + + this.applyViewConfigs(router); + this.applyRootResolvables(router); + } + + private applyViewConfigs(router: UIRouter) { let enteringStates = this._treeChanges.entering.map(node => node.state); PathFactory.applyViewConfigs(router.transitionService.$view, this._treeChanges.to, enteringStates); + } + private applyRootResolvables(router: UIRouter) { let rootResolvables: Resolvable[] = [ new Resolvable(UIRouter, () => router, [], undefined, router), new Resolvable(Transition, () => this, [], undefined, this), @@ -163,8 +178,6 @@ export class Transition implements IHookRegistry { let rootNode: PathNode = this._treeChanges.to[0]; let context = new ResolveContext(this._treeChanges.to); context.addResolvables(rootResolvables, rootNode.state); - - this.createTransitionHookRegFns(); } /** @@ -548,14 +561,13 @@ export class Transition implements IHookRegistry { * @returns a promise for a successful transition. */ run(): Promise { - let runSynchronousHooks = TransitionHook.runSynchronousHooks; let runAllHooks = TransitionHook.runAllHooks; let hookBuilder = this.hookBuilder(); let globals = this.router.globals; globals.transitionHistory.enqueue(this); let onBeforeHooks = hookBuilder.buildHooksForPhase(TransitionHookPhase.BEFORE); - let syncResult = runSynchronousHooks(onBeforeHooks); + let syncResult = TransitionHook.runOnBeforeHooks(onBeforeHooks); if (Rejection.isTransitionRejectionPromise(syncResult)) { syncResult.catch(() => 0); // issue #2676 @@ -601,7 +613,7 @@ export class Transition implements IHookRegistry { prev.then(() => nextHook.invokeHook()); // Run the hooks, then resolve or reject the overall deferred in the .then() handler - let asyncHooks = hookBuilder.buildHooksForPhase(TransitionHookPhase.ASYNC) + let asyncHooks = hookBuilder.buildHooksForPhase(TransitionHookPhase.ASYNC); asyncHooks.reduce(appendHookToChain, syncResult) .then(transitionSuccess, transitionError); diff --git a/src/transition/transitionHook.ts b/src/transition/transitionHook.ts index 50dea623..6d980c4d 100644 --- a/src/transition/transitionHook.ts +++ b/src/transition/transitionHook.ts @@ -1,9 +1,9 @@ /** @coreapi @module transition */ /** for typedoc */ import {TransitionHookOptions, IEventHook, HookResult} from "./interface"; -import {defaults, noop} from "../common/common"; +import {defaults, noop, identity} from "../common/common"; import {fnToString, maxLength} from "../common/strings"; import {isPromise} from "../common/predicates"; -import {val, is, parse } from "../common/hof"; +import {val, is, parse} from "../common/hof"; import {trace} from "../common/trace"; import {services} from "../common/coreservices"; @@ -11,6 +11,8 @@ import {Rejection} from "./rejectFactory"; import {TargetState} from "../state/targetState"; import {Transition} from "./transition"; import {State} from "../state/stateObject"; +import {StateService} from "../state/stateService"; // has or is using +import {TransitionHookType} from "./transitionHookType"; // has or is using let defaultOptions: TransitionHookOptions = { async: true, @@ -21,30 +23,77 @@ let defaultOptions: TransitionHookOptions = { bind: null }; +export type GetResultHandler = (hook: TransitionHook) => ResultHandler; +export type GetErrorHandler = (hook: TransitionHook) => ErrorHandler; + +export type ResultHandler = (result: HookResult) => Promise; +export type ErrorHandler = (error) => Promise; + /** @hidden */ export class TransitionHook { constructor(private transition: Transition, private stateContext: State, private eventHook: IEventHook, + private hookType: TransitionHookType, private options: TransitionHookOptions) { this.options = defaults(options, defaultOptions); } - private isSuperseded = () => - this.options.current() !== this.options.transition; + stateService = () => this.transition.router.stateService; + + static HANDLE_RESULT: GetResultHandler = (hook: TransitionHook) => + (result: HookResult) => + hook.handleHookResult(result); + + static IGNORE_RESULT: GetResultHandler = (hook: TransitionHook) => + (result: HookResult) => undefined; + + static LOG_ERROR: GetErrorHandler = (hook: TransitionHook) => + (error) => + (hook.stateService().defaultErrorHandler()(error), undefined); + + static REJECT_ERROR: GetErrorHandler = (hook: TransitionHook) => + (error) => + Rejection.errored(error).toPromise(); + + static THROW_ERROR: GetErrorHandler = (hook: TransitionHook) => + undefined; + + private rejectForSuperseded = () => + this.hookType.rejectIfSuperseded && this.options.current() !== this.options.transition; invokeHook(): Promise { - let { options, eventHook } = this; + if (this.eventHook._deregistered) return; + + let options = this.options; trace.traceHookInvocation(this, options); - if (options.rejectIfSuperseded && this.isSuperseded()) { + + if (this.rejectForSuperseded()) { return Rejection.superseded(options.current()).toPromise(); } - let synchronousHookResult = !eventHook._deregistered - ? eventHook.callback.call(options.bind, this.transition, this.stateContext) - : undefined; + let errorHandler = this.hookType.errorHandler(this); + let resultHandler = this.hookType.resultHandler(this); - return this.handleHookResult(synchronousHookResult); + return this._invokeCallback(resultHandler, errorHandler); + } + + private _invokeCallback(resultHandler: ResultHandler, errorHandler: ErrorHandler): Promise { + let cb = this.eventHook.callback; + let bind = this.options.bind; + let trans = this.transition; + let state = this.stateContext; + resultHandler = resultHandler || identity; + + if (!errorHandler) { + return resultHandler(cb.call(bind, trans, state)); + } + + try { + return resultHandler(cb.call(bind, trans, state)); + } catch (error) { + return errorHandler(error); + } } /** @@ -56,10 +105,10 @@ export class TransitionHook { * This also handles "transition superseded" -- when a new transition * was started while the hook was still running */ - handleHookResult(result: HookResult): Promise { + handleHookResult(result: HookResult): Promise { // This transition is no longer current. // Another transition started while this hook was still running. - if (this.isSuperseded()) { + if (this.rejectForSuperseded()) { // Abort this transition return Rejection.superseded(this.options.current()).toPromise(); } @@ -98,14 +147,7 @@ export class TransitionHook { * Run all TransitionHooks, ignoring their return value. */ static runAllHooks(hooks: TransitionHook[]): void { - hooks.forEach(hook => { - try { - hook.invokeHook(); - } catch (exception) { - let errorHandler = hook.transition.router.stateService.defaultErrorHandler(); - errorHandler(exception); - } - }); + hooks.forEach(hook => hook.invokeHook()); } /** @@ -114,22 +156,18 @@ export class TransitionHook { * * Returns a promise chain composed of any promises returned from each hook.invokeStep() call */ - static runSynchronousHooks(hooks: TransitionHook[]): Promise { + static runOnBeforeHooks(hooks: TransitionHook[]): Promise { let results: Promise[] = []; for (let hook of hooks) { - try { - let hookResult = hook.invokeHook(); - - if (Rejection.isTransitionRejectionPromise(hookResult)) { - // Break on first thrown error or false/TargetState - return hookResult; - } - - results.push(hookResult); - } catch (exception) { - return Rejection.errored(exception).toPromise(); + let hookResult = hook.invokeHook(); + + if (Rejection.isTransitionRejectionPromise(hookResult)) { + // Break on first thrown error or false/TargetState + return hookResult; } + + results.push(hookResult); } return results diff --git a/src/transition/transitionHookType.ts b/src/transition/transitionHookType.ts index 5f63f9e7..3ff6404c 100644 --- a/src/transition/transitionHookType.ts +++ b/src/transition/transitionHookType.ts @@ -2,6 +2,7 @@ import {TransitionHookScope, TransitionHookPhase} from "./interface"; import {PathNode} from "../path/node"; import {Transition} from "./transition"; import {isString} from "../common/predicates"; +import {GetErrorHandler, GetResultHandler, TransitionHook} from "./transitionHook"; /** * This class defines a type of hook, such as `onBefore` or `onEnter`. * Plugins can define custom hook types, such as sticky states does for `onInactive`. @@ -12,20 +13,26 @@ import {isString} from "../common/predicates"; export class TransitionHookType { public name: string; - public hookScope: TransitionHookScope; public hookPhase: TransitionHookPhase; + public hookScope: TransitionHookScope; public hookOrder: number; public criteriaMatchPath: string; public resolvePath: (trans: Transition) => PathNode[]; public reverseSort: boolean; + public errorHandler: GetErrorHandler; + public resultHandler: GetResultHandler; + public rejectIfSuperseded: boolean; constructor(name: string, - hookScope: TransitionHookScope, hookPhase: TransitionHookPhase, + hookScope: TransitionHookScope, hookOrder: number, criteriaMatchPath: string, resolvePath: ((trans: Transition) => PathNode[]) | string, - reverseSort: boolean = false + reverseSort: boolean = false, + resultHandler: GetResultHandler = TransitionHook.HANDLE_RESULT, + errorHandler: GetErrorHandler = TransitionHook.REJECT_ERROR, + rejectIfSuperseded: boolean = true, ) { this.name = name; this.hookScope = hookScope; @@ -34,5 +41,8 @@ export class TransitionHookType { this.criteriaMatchPath = criteriaMatchPath; this.resolvePath = isString(resolvePath) ? (trans: Transition) => trans.treeChanges(resolvePath) : resolvePath; this.reverseSort = reverseSort; + this.resultHandler = resultHandler; + this.errorHandler = errorHandler; + this.rejectIfSuperseded = rejectIfSuperseded; } } diff --git a/src/transition/transitionService.ts b/src/transition/transitionService.ts index 492d63a5..c2fc4d0b 100644 --- a/src/transition/transitionService.ts +++ b/src/transition/transitionService.ts @@ -1,5 +1,8 @@ /** @coreapi @module transition */ /** for typedoc */ -import {IHookRegistry, TransitionOptions, TransitionHookScope, TransitionHookPhase} from "./interface"; +import { + IHookRegistry, TransitionOptions, TransitionHookScope, TransitionHookPhase, + TransitionCreateHookFn +} from "./interface"; import { HookMatchCriteria, HookRegOptions, TransitionStateHookFn, TransitionHookFn @@ -20,6 +23,8 @@ import {registerRedirectToHook} from "../hooks/redirectTo"; import {registerOnExitHook, registerOnRetainHook, registerOnEnterHook} from "../hooks/onEnterExitRetain"; import {registerLazyLoadHook} from "../hooks/lazyLoadStates"; import {TransitionHookType} from "./transitionHookType"; +import {TransitionHook} from "./transitionHook"; +import {isDefined} from "../common/predicates"; /** * The default [[Transition]] options. @@ -50,6 +55,29 @@ export let defaultTransOpts: TransitionOptions = { */ export class TransitionService implements IHookRegistry { + /** + * Registers a [[TransitionHookFn]], called *while a transition is being constructed*. + * + * Registers a transition lifecycle hook, which is invoked during transition construction. + * + * This low level hook should only be used by plugins. + * This can be a useful time for plugins to add resolves or mutate the transition as needed. + * The Sticky States plugin uses this hook to modify the treechanges. + * + * ### Lifecycle + * + * `onBefore` hooks are invoked *while a transition is being constructed*. + * + * ### Return value + * + * The hook's return value is ignored + * + * @internalapi + * @param matchCriteria defines which Transitions the Hook should be invoked for. + * @param callback the hook function which will be invoked. + * @returns a function which deregisters the hook. + */ + onCreate: (criteria: HookMatchCriteria, callback: TransitionCreateHookFn, options?: HookRegOptions) => Function; /** @inheritdoc */ onBefore; /** @inheritdoc */ @@ -119,16 +147,26 @@ export class TransitionService implements IHookRegistry { private registerTransitionHookTypes() { const Scope = TransitionHookScope; const Phase = TransitionHookPhase; + const TH = TransitionHook; + + const to = (transition: Transition) => + transition.treeChanges('to'); + const from = (transition: Transition) => + transition.treeChanges('from'); let hookTypes = [ - new TransitionHookType("onBefore", Scope.TRANSITION, Phase.BEFORE, 0, "to", t => t.treeChanges("to")), - new TransitionHookType("onStart", Scope.TRANSITION, Phase.ASYNC, 0, "to", t => t.treeChanges("to")), - new TransitionHookType("onExit", Scope.STATE, Phase.ASYNC, 10, "exiting", t => t.treeChanges("from"), true), - new TransitionHookType("onRetain", Scope.STATE, Phase.ASYNC, 20, "retained", t => t.treeChanges("to")), - new TransitionHookType("onEnter", Scope.STATE, Phase.ASYNC, 30, "entering", t => t.treeChanges("to")), - new TransitionHookType("onFinish", Scope.TRANSITION, Phase.ASYNC, 40, "to", t => t.treeChanges("to")), - new TransitionHookType("onSuccess", Scope.TRANSITION, Phase.SUCCESS, 0, "to", t => t.treeChanges("to")), - new TransitionHookType("onError", Scope.TRANSITION, Phase.ERROR, 0, "to", t => t.treeChanges("to")), + new TransitionHookType("onCreate", Phase.CREATE, Scope.TRANSITION, 0, "to", to, false, TH.IGNORE_RESULT, TH.THROW_ERROR, false), + + new TransitionHookType("onBefore", Phase.BEFORE, Scope.TRANSITION, 0, "to", to, false, TH.HANDLE_RESULT), + + new TransitionHookType("onStart", Phase.ASYNC, Scope.TRANSITION, 0, "to", to), + new TransitionHookType("onExit", Phase.ASYNC, Scope.STATE, 10, "exiting", from, true), + new TransitionHookType("onRetain", Phase.ASYNC, Scope.STATE, 20, "retained", to), + new TransitionHookType("onEnter", Phase.ASYNC, Scope.STATE, 30, "entering", to), + new TransitionHookType("onFinish", Phase.ASYNC, Scope.TRANSITION, 40, "to", to), + + new TransitionHookType("onSuccess", Phase.SUCCESS, Scope.TRANSITION, 0, "to", to, false, TH.IGNORE_RESULT, TH.LOG_ERROR, false), + new TransitionHookType("onError", Phase.ERROR, Scope.TRANSITION, 0, "to", to, false, TH.IGNORE_RESULT, TH.LOG_ERROR, false), ]; hookTypes.forEach(type => this[type.name] = this.registerTransitionHookType(type)) @@ -145,7 +183,7 @@ export class TransitionService implements IHookRegistry { } getTransitionHookTypes(phase?: TransitionHookPhase): TransitionHookType[] { - let transitionHookTypes = phase ? + let transitionHookTypes = isDefined(phase) ? this._transitionHookTypes.filter(type => type.hookPhase === phase) : this._transitionHookTypes.slice(); diff --git a/test/transitionSpec.ts b/test/transitionSpec.ts index 70007c61..4546bf23 100644 --- a/test/transitionSpec.ts +++ b/test/transitionSpec.ts @@ -85,7 +85,9 @@ describe('transition', function () { .then(goFail("", "third")) .then(() => { expect(result.called()).toEqual({resolve: false, reject: true, complete: true}); - expect(result.get().reject.message).toEqual("transition failed"); + expect(result.get().reject instanceof Rejection).toBeTruthy(); + expect(result.get().reject.message).toEqual("The transition errored"); + expect(result.get().reject.detail.message).toEqual("transition failed"); }) .then(done); }); @@ -102,6 +104,8 @@ describe('transition', function () { .then(goFail("", "third")) .then(() => { expect(result.called()).toEqual({ resolve: false, reject: true, complete: true }); + expect(result.get().reject instanceof Rejection).toBeTruthy(); + expect(result.get().reject.message).toEqual("The transition errored"); expect(result.get().reject.detail.message).toEqual("transition failed"); }) .then(done); @@ -144,6 +148,52 @@ describe('transition', function () { }), 20); })); + describe('.onCreate()', function() { + beforeEach(() => $state.defaultErrorHandler(() => {})); + + it('should pass the transition', () => { + let log = ""; + $transitions.onCreate({}, t => log += `${t.from().name};${t.to().name};`); + + log += "create;"; + let trans = makeTransition('first', 'second'); + log += "created;"; + + expect(log).toBe('create;first;second;created;'); + }); + + it('should run in priority order', (() => { + let log = ""; + $transitions.onCreate({}, t => (log += "2;", null), { priority: 2 }); + $transitions.onCreate({}, t => (log += "3;", null), { priority: 3 }); + $transitions.onCreate({}, t => (log += "1;", null), { priority: 1 }); + + log += "create;"; + let trans = makeTransition('first', 'second'); + log += "created;"; + + expect(log).toBe('create;3;2;1;created;'); + })); + + it('should ignore return values', ((done) => { + let log = ""; + $transitions.onCreate({}, t => false); + $transitions.onCreate({}, t => new Promise(resolve => resolve(false))); + + let trans = makeTransition('first', 'second'); + trans.run().then(() => { + expect($state.current.name).toBe('second'); + done(); + }); + })); + + it('should fail on error', () => { + let log = ""; + $transitions.onCreate({}, () => { throw "doh" }); + expect(() => makeTransition('first', 'second')).toThrow(); + }); + }); + describe('.onBefore()', function() { beforeEach(() => $state.defaultErrorHandler(() => {})); @@ -470,7 +520,7 @@ describe('transition', function () { Promise.resolve() .then(goFail("A", "D")) - .then(() => expect(transError).toBe(error)) + .then(() => expect(transError.detail).toBe(error)) .then(done); }));