From ec50da49f6768f15512eea5cd4b83c4d7491c730 Mon Sep 17 00:00:00 2001 From: christopherthielen Date: Tue, 29 Nov 2016 11:47:17 -0600 Subject: [PATCH] feat(futureState): States with a `.**` name suffix (i.e., `foo.**`) are considered future states - instead of states with a `lazyLoad` fn feat(lazyLoad): Created `StateService.lazyLoad` method to imperatively lazy load a state Closes #8 feat(lazyLoad): Exported/exposed the `lazyLoadState` function - This can be used to manually trigger lazy loading of states. feat(lazyLoad): the `lazyLoad` hook can be used to lazy load anything (component code, etc) - Previously, `lazyLoad` was only used to load future states. - Now, `lazyLoad` can be used to load anything. - Previously, `lazyLoad` would forcibly de-register the future state. - Now, `lazyLoad` does not deregister the future state. - Now, the future state is deregistered when a normal state of the same name (without the .**) is registered. Closes #4 BREAKING CHANGE: Previously, a state with a `lazyLoad` function was considered a future state. Now, a state whose name ends with `.**` (i.e., a glob pattern which matches all children) is a future state. ### All future states should be given a name that ends in `.**`. Change your future states from: ``` { name: 'future', url: '/future', lazyLoad: () => ... } ``` to: ``` { name: 'future.**', url: '/future', lazyLoad: () => ... } ``` --- src/hooks/lazyLoad.ts | 115 ++++++++++++++ src/hooks/lazyLoadStates.ts | 76 --------- src/state/interface.ts | 107 +++++++++---- src/state/stateBuilder.ts | 6 +- src/state/stateQueueManager.ts | 12 +- src/state/stateRegistry.ts | 2 +- src/state/stateService.ts | 39 ++++- src/transition/transitionService.ts | 2 +- test/lazyLoadSpec.ts | 232 ++++++++++++++++++++++++++-- test/stateRegistrySpec.ts | 1 - 10 files changed, 462 insertions(+), 130 deletions(-) create mode 100644 src/hooks/lazyLoad.ts delete mode 100644 src/hooks/lazyLoadStates.ts diff --git a/src/hooks/lazyLoad.ts b/src/hooks/lazyLoad.ts new file mode 100644 index 00000000..1c34a065 --- /dev/null +++ b/src/hooks/lazyLoad.ts @@ -0,0 +1,115 @@ +/** @module hooks */ /** */ +import {Transition} from "../transition/transition"; +import {TransitionService} from "../transition/transitionService"; +import {TransitionHookFn} from "../transition/interface"; +import {StateDeclaration, LazyLoadResult} from "../state/interface"; +import {State} from "../state/stateObject"; +import {services} from "../common/coreservices"; + +/** + * A [[TransitionHookFn]] that performs lazy loading + * + * When entering a state "abc" which has a `lazyLoad` function defined: + * - Invoke the `lazyLoad` function (unless it is already in process) + * - Flag the hook function as "in process" + * - The function should return a promise (that resolves when lazy loading is complete) + * - Wait for the promise to settle + * - If the promise resolves to a [[LazyLoadResult]], then register those states + * - Flag the hook function as "not in process" + * - If the hook was successful + * - Remove the `lazyLoad` function from the state declaration + * - If all the hooks were successful + * - Retry the transition (by returning a TargetState) + * + * ``` + * .state('abc', { + * component: 'fooComponent', + * lazyLoad: () => System.import('./fooComponent') + * }); + * ``` + * + * See [[StateDeclaration.lazyLoad]] + */ +const lazyLoadHook: TransitionHookFn = (transition: Transition) => { + const transitionSource = (trans: Transition) => + trans.redirectedFrom() ? transitionSource(trans.redirectedFrom()) : trans.options().source; + + function retryOriginalTransition() { + if (transitionSource(transition) === 'url') { + let loc = services.location, path = loc.path(), search = loc.search(), hash = loc.hash(); + + let matchState = state => + [state, state.url && state.url.exec(path, search, hash)]; + + let matches = transition.router.stateRegistry.get() + .map(s => s.$$state()) + .map(matchState) + .filter(([state, params]) => !!params); + + if (matches.length) { + let [state, params] = matches[0]; + return transition.router.stateService.target(state, params, transition.options()); + } + + transition.router.urlRouter.sync(); + return; + } + + // The original transition was not triggered via url sync + // The lazy state should be loaded now, so re-try the original transition + let orig = transition.targetState(); + return transition.router.stateService.target(orig.identifier(), orig.params(), orig.options()); + } + + let promises = transition.entering() + .filter(state => !!state.lazyLoad) + .map(state => lazyLoadState(transition, state)); + + return services.$q.all(promises).then(retryOriginalTransition); +}; + +export const registerLazyLoadHook = (transitionService: TransitionService) => + transitionService.onBefore({ entering: (state) => !!state.lazyLoad }, lazyLoadHook); + + +/** + * Invokes a state's lazy load function + * + * @param transition a Transition context + * @param state the state to lazy load + * @returns A promise for the lazy load result + */ +export function lazyLoadState(transition: Transition, state: StateDeclaration): Promise { + let lazyLoadFn = state.lazyLoad; + + // Store/get the lazy load promise on/from the hookfn so it doesn't get re-invoked + let promise = lazyLoadFn['_promise']; + if (!promise) { + const success = (result) => { + delete state.lazyLoad; + delete state.$$state().lazyLoad; + delete lazyLoadFn['_promise']; + return result; + }; + + const error = (err) => { + delete lazyLoadFn['_promise']; + return services.$q.reject(err); + }; + + promise = lazyLoadFn['_promise'] = + services.$q.when(lazyLoadFn(transition, state)) + .then(updateStateRegistry) + .then(success, error); + } + + /** Register any lazy loaded state definitions */ + function updateStateRegistry(result: LazyLoadResult) { + if (result && Array.isArray(result.states)) { + result.states.forEach(state => transition.router.stateRegistry.register(state)); + } + return result; + } + + return promise; +} diff --git a/src/hooks/lazyLoadStates.ts b/src/hooks/lazyLoadStates.ts deleted file mode 100644 index df15ff42..00000000 --- a/src/hooks/lazyLoadStates.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** @module hooks */ /** */ -import {Transition} from "../transition/transition"; -import {TransitionService} from "../transition/transitionService"; -import {TransitionHookFn} from "../transition/interface"; -import {StateDeclaration, LazyLoadResult} from "../state/interface"; -import {State} from "../state/stateObject"; -import {services} from "../common/coreservices"; - -/** - * A [[TransitionHookFn]] that lazy loads a state tree. - * - * When transitioning to a state "abc" which has a `lazyLoad` function defined: - * - Invoke the `lazyLoad` function - * - The function should return a promise for an array of lazy loaded [[StateDeclaration]]s - * - Wait for the promise to resolve - * - Deregister the original state "abc" - * - The original state definition is a placeholder for the lazy loaded states - * - Register the new states - * - Retry the transition - * - * See [[StateDeclaration.lazyLoad]] - */ -const lazyLoadHook: TransitionHookFn = (transition: Transition) => { - var toState = transition.to(); - let registry = transition.router.stateRegistry; - - const transitionSource = (trans: Transition) => - trans.redirectedFrom() ? transitionSource(trans.redirectedFrom()) : trans.options().source; - - function retryOriginalTransition() { - if (transitionSource(transition) === 'url') { - let loc = services.location, path = loc.path(), search = loc.search(), hash = loc.hash(); - - let matchState = state => [state, state.url && state.url.exec(path, search, hash)]; - let matches = registry.get().map(s => s.$$state()).map(matchState).filter(([state, params]) => !!params); - - if (matches.length) { - let [state, params] = matches[0]; - return transition.router.stateService.target(state, params, transition.options()); - } - - transition.router.urlRouter.sync(); - return; - } - - // The original transition was not triggered via url sync - // The lazy state should be loaded now, so re-try the original transition - let orig = transition.targetState(); - return transition.router.stateService.target(orig.identifier(), orig.params(), orig.options()); - } - - /** - * Replace the placeholder state with the newly loaded states from the NgModule. - */ - function updateStateRegistry(result: LazyLoadResult) { - // deregister placeholder state - registry.deregister(transition.$to()); - if (result && Array.isArray(result.states)) { - result.states.forEach(state => registry.register(state)); - } - } - - let hook = toState.lazyLoad; - // Store/get the lazy load promise on/from the hookfn so it doesn't get re-invoked - let promise = hook['_promise']; - if (!promise) { - promise = hook['_promise'] = hook(transition).then(updateStateRegistry); - const cleanup = () => delete hook['_promise']; - promise.then(cleanup, cleanup); - } - - return promise.then(retryOriginalTransition); -}; - -export const registerLazyLoadHook = (transitionService: TransitionService) => - transitionService.onBefore({ to: (state) => !!state.lazyLoad }, lazyLoadHook); diff --git a/src/state/interface.ts b/src/state/interface.ts index a11881b8..499b1b89 100644 --- a/src/state/interface.ts +++ b/src/state/interface.ts @@ -504,66 +504,106 @@ export interface StateDeclaration { onExit?: TransitionStateHookFn; /** - * A function which lazy loads the state definition (and child state definitions) + * A function used to lazy load code * - * A state which has a `lazyLoad` function is treated as a **temporary - * placeholder** for a state definition that will be lazy loaded some time - * in the future. - * These temporary placeholder states are called "**Future States**". + * The `lazyLoad` function is invoked before the state is activated. + * The transition waits while the code is loading. * + * The function should load the code that is required to activate the state. + * For example, it may load a component class, or some service code. + * The function must retur a promise which resolves when loading is complete. * - * #### `lazyLoad`: + * For example, this code lazy loads a service before the `abc` state is activated: * - * A future state's `lazyLoad` function should return a Promise to lazy load the - * code for one or more lazy loaded [[StateDeclaration]] objects. + * ``` + * .state('abc', { + * lazyLoad: (transition, state) => System.import('./abcService') + * } + * ``` * - * If the promise resolves to an object with a `states: []` array, - * the lazy loaded states will be registered with the [[StateRegistry]]. - * Generally, of the lazy loaded states should have the same name as the future state; - * then it will **replace the future state placeholder** in the registry. + * The `abcService` file is imported and loaded + * (it is assumed that the `abcService` file knows how to register itself as a service). * - * In any case, when the promise successfully resolves, the placeholder Future State will be deregistered. + * #### Lifecycle * - * #### `url` + * - The `lazyLoad` function is invoked if a transition is going to enter the state. + * - The function is invoked before the transition starts (using an `onBefore` transition hook). + * - The function is only invoked once; while the `lazyLoad` function is loading code, it will not be invoked again. + * For example, if the user double clicks a ui-sref, `lazyLoad` is only invoked once even though there were two transition attempts. + * Instead, the existing lazy load promise is re-used. + * - When the promise resolves successfully, the `lazyLoad` property is deleted from the state declaration. + * - If the promise resolves to a [[LazyLoadResult]] which has an array of `states`, those states are registered. + * - The original transition is retried (this time without the `lazyLoad` property present). * - * A future state's `url` property acts as a wildcard. + * - If the `lazyLoad` function fails, then the transition also fails. + * The failed transition (and the `lazyLoad` function) could potentially be retried by the user. * - * UI-Router matches all paths that begin with the `url`. - * It effectively appends `.*` to the internal regular expression. + * ### Lazy loading state definitions (Future States) * - * #### `name` + * State definitions can also be lazy loaded. + * This might be desirable when building large, multi-module applications. * - * A future state's `name` property acts as a wildcard. + * To lazy load state definitions, a Future State should be registered as a placeholder. + * When the state definitions are lazy loaded, the Future State is deregistered. * - * It matches any state name that starts with the `name`. - * UI-Router effectively matches the future state using a `.**` [[Glob]] appended to the `name`. + * A future state can act as a placeholder for a single state, or for an entire module of states and substates. + * A future state should have: * - * @example - * #### states.js + * - A `name` which ends in `.**`. + * A future state's `name` property acts as a wildcard [[Glob]]. + * It matches any state name that starts with the `name` (including child states that are not yet loaded). + * - A `url` prefix. + * A future state's `url` property acts as a wildcard. + * UI-Router matches all paths that begin with the `url`. + * It effectively appends `.*` to the internal regular expression. + * When the prefix matches, the future state will begin loading. + * - A `lazyLoad` function. + * This function should should return a Promise to lazy load the code for one or more [[StateDeclaration]] objects. + * It should return a [[LazyLoadResult]]. + * Generally, one of the lazy loaded states should have the same name as the future state. + * The new state will then **replace the future state placeholder** in the registry. + * + * ### Additional resources + * + * For in depth information on lazy loading and Future States, see the [Lazy Loading Guide](https://ui-router.github.io/guides/lazyload). + * + * #### Example: states.js * ```js * * // This child state is a lazy loaded future state * // The `lazyLoad` function loads the final state definition * { - * name: 'parent.child', - * url: '/child', - * lazyLoad: () => System.import('./child.state.js') + * name: 'parent.**', + * url: '/parent', + * lazyLoad: () => System.import('./lazy.states.js') * } * ``` * - * #### child.state.js + * #### Example: lazy.states.js * * This file is lazy loaded. It exports an array of states. * * ```js * import {ChildComponent} from "./child.component.js"; + * import {ParentComponent} from "./parent.component.js"; * - * let childState = { + * // This fully defined state replaces the future state + * let parentState = { * // the name should match the future state + * name: 'parent', + * url: '/parent/:parentId', + * component: ParentComponent, + * resolve: { + * parentData: ($transition$, ParentService) => + * ParentService.get($transition$.params().parentId) + * } + * } + * + * let childState = { * name: 'parent.child', * url: '/child/:childId', * params: { - * id: "default" + * childId: "default" * }, * resolve: { * childData: ($transition$, ChildService) => @@ -572,19 +612,20 @@ export interface StateDeclaration { * }; * * // This array of states will be registered by the lazyLoad hook - * let result = { - * states: [ childState ] + * let lazyLoadResults = { + * states: [ parentState, childState ] * }; * - * export default result; + * export default lazyLoadResults; * ``` * * @param transition the [[Transition]] that is activating the future state + * @param state the [[StateDeclaration]] that the `lazyLoad` function is declared on * @return a Promise to load the states. * Optionally, if the promise resolves to a [[LazyLoadResult]], * the states will be registered with the [[StateRegistry]]. */ - lazyLoad?: (transition: Transition) => Promise; + lazyLoad?: (transition: Transition, state: StateDeclaration) => Promise; /** * @deprecated define individual parameters as [[ParamDeclaration.dynamic]] diff --git a/src/state/stateBuilder.ts b/src/state/stateBuilder.ts index a19b6f5c..bd6c2ec1 100644 --- a/src/state/stateBuilder.ts +++ b/src/state/stateBuilder.ts @@ -40,8 +40,6 @@ interface Builders { function nameBuilder(state: State) { - if (state.lazyLoad) - state.name = state.self.name + ".**"; return state.name; } @@ -61,7 +59,9 @@ const getUrlBuilder = ($urlMatcherFactoryProvider: UrlMatcherFactory, root: () = function urlBuilder(state: State) { let stateDec: StateDeclaration = state; - if (stateDec && stateDec.url && stateDec.lazyLoad) { + // For future states, i.e., states whose name ends with `.**`, + // match anything that starts with the url prefix + if (stateDec && stateDec.url && stateDec.name && stateDec.name.match(/\.\*\*$/)) { stateDec.url += "{remainder:any}"; // match any path (.*) } diff --git a/src/state/stateQueueManager.ts b/src/state/stateQueueManager.ts index 7f69c4cd..2c4b8615 100644 --- a/src/state/stateQueueManager.ts +++ b/src/state/stateQueueManager.ts @@ -15,6 +15,7 @@ export class StateQueueManager { constructor( public states: { [key: string]: State; }, + public $registry: StateRegistry, public builder: StateBuilder, public $urlRouterProvider: UrlRouterProvider, public listeners: StateRegistryListener[]) { @@ -55,8 +56,17 @@ export class StateQueueManager { let orphanIdx: number = orphans.indexOf(state); if (result) { - if (states.hasOwnProperty(state.name)) + let existingState = this.$registry.get(state.name); + + if (existingState && existingState.name === state.name) { throw new Error(`State '${state.name}' is already defined`); + } + + if (existingState && existingState.name === state.name + ".**") { + // Remove future state of the same name + this.$registry.deregister(existingState); + } + states[state.name] = state; this.attachRoute($state, state); if (orphanIdx >= 0) orphans.splice(orphanIdx, 1); diff --git a/src/state/stateRegistry.ts b/src/state/stateRegistry.ts index ea88ec04..957872ca 100644 --- a/src/state/stateRegistry.ts +++ b/src/state/stateRegistry.ts @@ -34,7 +34,7 @@ export class StateRegistry { constructor(urlMatcherFactory: UrlMatcherFactory, private urlRouterProvider: UrlRouterProvider) { this.matcher = new StateMatcher(this.states); this.builder = new StateBuilder(this.matcher, urlMatcherFactory); - this.stateQueue = new StateQueueManager(this.states, this.builder, urlRouterProvider, this.listeners); + this.stateQueue = new StateQueueManager(this.states, this, this.builder, urlRouterProvider, this.listeners); let rootStateDef: StateDeclaration = { name: '', diff --git a/src/state/stateService.ts b/src/state/stateService.ts index a10473ee..395e3a08 100644 --- a/src/state/stateService.ts +++ b/src/state/stateService.ts @@ -12,7 +12,7 @@ import {defaultTransOpts} from "../transition/transitionService"; import {Rejection, RejectType} from "../transition/rejectFactory"; import {Transition} from "../transition/transition"; -import {StateOrName, StateDeclaration, TransitionPromise} from "./interface"; +import {StateOrName, StateDeclaration, TransitionPromise, LazyLoadResult} from "./interface"; import {State} from "./stateObject"; import {TargetState} from "./targetState"; @@ -27,7 +27,8 @@ import {Globals} from "../globals"; import {UIRouter} from "../router"; import {UIInjector} from "../interface"; import {ResolveContext} from "../resolve/resolveContext"; -import {StateParams} from "../params/stateParams"; // has or is using +import {StateParams} from "../params/stateParams"; +import {lazyLoadState} from "../hooks/lazyLoad"; // has or is using export type OnInvalidCallback = (toState?: TargetState, fromState?: TargetState, injector?: UIInjector) => HookResult; @@ -282,6 +283,13 @@ export class StateService { return new TargetState(identifier, stateDefinition, params, options); }; + private getCurrentPath(): PathNode[] { + let globals = this.router.globals; + let latestSuccess: Transition = globals.successfulTransitions.peekTail(); + const rootPath = () => [ new PathNode(this.router.stateRegistry.root()) ]; + return latestSuccess ? latestSuccess.treeChanges().to : rootPath(); + } + /** * Low-level method for transitioning to a new state. * @@ -313,9 +321,7 @@ export class StateService { options = extend(options, { current: transHistory.peekTail.bind(transHistory)}); let ref: TargetState = this.target(to, toParams, options); - let latestSuccess: Transition = globals.successfulTransitions.peekTail(); - const rootPath = () => [ new PathNode(this.router.stateRegistry.root()) ]; - let currentPath: PathNode[] = latestSuccess ? latestSuccess.treeChanges().to : rootPath(); + let currentPath = this.getCurrentPath(); if (!ref.exists()) return this._handleInvalidTargetState(currentPath, ref); @@ -572,4 +578,27 @@ export class StateService { if (arguments.length === 0) return reg.get(); return reg.get(stateOrName, base || this.$current); } + + /** + * Lazy loads a state + * + * Explicitly runs a state's [[StateDeclaration.lazyLoad]] function. + * + * @param stateOrName the state that should be lazy loaded + * @param transition the optional Transition context to use (if the lazyLoad function requires an injector, etc) + * Note: If no transition is provided, a noop transition is created using the from the current state to the current state. + * This noop transition is not actually run. + * + * @returns a promise to lazy load + */ + lazyLoad(stateOrName: StateOrName, transition?: Transition): Promise { + let state: StateDeclaration = this.get(stateOrName); + if (!state || !state.lazyLoad) throw new Error("Can not lazy load " + stateOrName); + + let currentPath = this.getCurrentPath(); + let target = PathFactory.makeTargetState(currentPath); + transition = transition || this.router.transitionService.create(currentPath, target); + + return lazyLoadState(transition, state); + } } diff --git a/src/transition/transitionService.ts b/src/transition/transitionService.ts index c25ed38d..ec2a5659 100644 --- a/src/transition/transitionService.ts +++ b/src/transition/transitionService.ts @@ -20,7 +20,7 @@ import {registerLoadEnteringViews, registerActivateViews} from "../hooks/views"; import {registerUpdateUrl} from "../hooks/url"; import {registerRedirectToHook} from "../hooks/redirectTo"; import {registerOnExitHook, registerOnRetainHook, registerOnEnterHook} from "../hooks/onEnterExitRetain"; -import {registerLazyLoadHook} from "../hooks/lazyLoadStates"; +import {registerLazyLoadHook} from "../hooks/lazyLoad"; import {TransitionHookType} from "./transitionHookType"; import {TransitionHook} from "./transitionHook"; import {isDefined} from "../common/predicates"; diff --git a/test/lazyLoadSpec.ts b/test/lazyLoadSpec.ts index 36128308..57fa58ed 100644 --- a/test/lazyLoadSpec.ts +++ b/test/lazyLoadSpec.ts @@ -3,8 +3,11 @@ import "../src/justjs"; import { StateRegistry } from "../src/state/stateRegistry"; import { services } from "../src/common/coreservices"; import { UrlRouter } from "../src/url/urlRouter"; +import {StateDeclaration} from "../src/state/interface"; +import {tail} from "../src/common/common"; +import {Transition} from "../src/transition/transition"; -describe('a Future State', function () { +describe('future state', function () { let router: UIRouter; let $registry: StateRegistry; let $transitions: TransitionService; @@ -23,13 +26,224 @@ describe('a Future State', function () { router.stateRegistry.stateQueue.autoFlush($state); }); + describe('registry', () => { + it('should register future states', () => { + let beforeLen = $registry.get().length; + $registry.register({ name: 'future.**' }); + expect($registry.get().length).toBe(beforeLen + 1); + expect(tail($registry.get()).name).toBe('future.**'); + }); + + it('should get future states by non-wildcard name', () => { + $registry.register({ name: 'future.**' }); + expect($registry.get('future')).toBeDefined(); + }); + + it('should get future states by wildcard name', () => { + $registry.register({ name: 'future.**' }); + expect($registry.get('future.**')).toBeDefined(); + }); + + it('should get future states by state declaration object', () => { + let statedef = { name: 'future.**' }; + let state = $registry.register(statedef); + expect($registry.get(statedef)).toBe(statedef); + }); + + it('should get future states by state object', () => { + let statedef = { name: 'future.**' }; + let state = $registry.register(statedef); + expect($registry.get(state)).toBe(statedef); + }); + + it('should replace a future state when a normal state of the same name is registered', () => { + let state = $registry.register({ name: 'future.**' }); + + expect($registry.get('future')).toBe(state.self); + expect($registry.get('future.**')).toBe(state.self); + expect($registry.matcher.find('future')).toBe(state); + expect($registry.matcher.find('future').name).toBe('future.**'); + let statecount = $registry.get().length; + + // Register the regular (non-future) state + let regularState = $registry.register({ name: 'future', url: '/future', resolve: {} }); + + expect($registry.get('future')).toBe(regularState.self); + expect($registry.matcher.find('future')).toBe(regularState); + expect($registry.get('future.**')).toBeFalsy(); + expect($registry.get().length).toBe(statecount); // Total number of states did not change + }); + }); + + describe('state matcher', () => { + it('should match future states (by non-wildcard name)', () => { + let state = $registry.register({ name: 'future.**' }); + expect($registry.matcher.find('future')).toBe(state); + }); + + it('should match any potential children of the future state (by name prefix)', () => { + let state = $registry.register({ name: 'future.**' }); + expect($registry.matcher.find('future.lazystate')).toBe(state); + }); + + it('should match any potential descendants of the future state (by name prefix)', () => { + let state = $registry.register({ name: 'future.**' }); + expect($registry.matcher.find('future.foo.bar.baz')).toBe(state); + }); + + it('should match future states (by wildcard name)', () => { + let state = $registry.register({ name: 'future.**' }); + expect($registry.matcher.find('future.**')).toBe(state); + }); + + it('should match future states (by state declaration object)', () => { + let stateDef = { name: 'future.**' }; + let state = $registry.register(stateDef); + expect($registry.matcher.find(stateDef)).toBe(state); + }); + + it('should match future states (by internal state object)', () => { + let stateDef = { name: 'future.**' }; + let state = $registry.register(stateDef); + expect($registry.matcher.find(state)).toBe(state); + }); + + it('should not match future states with non-matching prefix', () => { + let state = $registry.register({ name: 'future.**' }); + expect($registry.matcher.find('futurX')).toBeFalsy(); + expect($registry.matcher.find('futurX.lazystate')).toBeFalsy(); + expect($registry.matcher.find('futurX.foo.bar.baz')).toBeFalsy(); + expect($registry.matcher.find('futurX.**')).toBeFalsy(); + }); + }); + + describe('url matcher', () => { + let match = (url): StateDeclaration => { + let matches: StateDeclaration[] = router.stateRegistry.get() + .filter(state => state.$$state().url.exec(url)); + if (matches.length > 1) throw new Error('Matched ' + matches.length + ' states'); + return matches[0]; + }; + + it('should match future states by url', () => { + let state = $registry.register({ name: 'future.**', url: '/future' }); + expect(match('/future')).toBe(state.self); + }); + + it('should not match future states if the prefix does not match', () => { + let state = $registry.register({ name: 'future.**', url: '/future' }); + expect(match('/futurX')).toBeFalsy(); + }); + + it('should match the future state for any urls that start with the url prefix', () => { + let state = $registry.register({ name: 'future.**', url: '/future' }); + expect(match('/future')).toBe(state.self); + expect(match('/futurex')).toBe(state.self); + expect(match('/future/foo')).toBe(state.self); + expect(match('/future/asdflj/32oi/diufg')).toBe(state.self); + }); + }); + + describe('imperative StateService.lazyLoad api', () => { + + describe('should run the lazyLoad function', () => { + let stateDeclaration, stateObject, lazyLoadCount; + + beforeEach(() => { + stateDeclaration = { name: 'state1', url: '/state1', lazyLoad: () => Promise.resolve(lazyLoadCount++) }; + stateObject = $registry.register(stateDeclaration); + lazyLoadCount = 0; + }); + + afterEach(() => expect(lazyLoadCount).toBe(1)); + + it('given a name', (done) => $state.lazyLoad('state1').then(done)); + it('given a state declaration', (done) => $state.lazyLoad(stateDeclaration).then(done)); + it('given a state object', (done) => $state.lazyLoad(stateObject).then(done)); + it('given a state from the registry', (done) => $state.lazyLoad($registry.get('state1')).then(done)); + }); + + it('should throw if there is no lazyLoad function', () => { + $registry.register({ name: 'nolazyloadfn', url: '/nolazyloadfn' }); + expect(() => $state.lazyLoad('nolazyloadfn')).toThrow(); + }); + + it('should resolve to the lazyLoad result', (done) => { + $registry.register({name: 'll', url: '/ll', lazyLoad: () => Promise.resolve('abc')}); + $state.lazyLoad('ll').then((result) => { + expect(result).toBe('abc'); + done(); + }); + }); + + it('should pass a transition and the state context to the lazyLoad function', (done) => { + let objs = {}; + var stateDefinition = {name: 'll', url: '/ll', lazyLoad: (trans, state) => (objs = { trans, state }, null) }; + $registry.register(stateDefinition); + $state.lazyLoad('ll').then(() => { + expect(objs['trans'] instanceof Transition).toBeTruthy(); + expect(objs['state']).toBe(stateDefinition); + done(); + }); + }); + + it('should remove the lazyLoad function from the state definition', (done) => { + let llstate = {name: 'll', url: '/ll', lazyLoad: () => Promise.resolve('abc')}; + $registry.register(llstate); + $state.lazyLoad('ll').then(() => { + expect(llstate.lazyLoad).toBeUndefined(); + done(); + }); + }); + + it('should not re-run the pending lazyLoad function', (done) => { + let lazyLoadCount = 0; + const lazyLoad = () => new Promise(resolve => setTimeout(() => resolve(++lazyLoadCount), 100)); + $registry.register({name: 'll', lazyLoad: lazyLoad}); + + Promise.all([ + $state.lazyLoad('ll'), + $state.lazyLoad('ll') + ]).then((result) => { + expect(result).toEqual([1,1]); + expect(lazyLoadCount).toBe(1); + done(); + }); + }); + + it('should allow lazyLoad retry after a failed lazyload attempt', (done) => { + let lazyLoadCount = 0; + const lazyLoad = () => new Promise(resolve => { + lazyLoadCount++; + throw new Error('doh'); + }); + let stateDeclaration = {name: 'll', lazyLoad: lazyLoad}; + $registry.register(stateDeclaration); + + $state.lazyLoad('ll').catch(err => { + expect(err).toBeDefined(); + expect(err.message).toBe('doh'); + expect(stateDeclaration.lazyLoad['_promise']).toBeUndefined(); + + $state.lazyLoad('ll').catch(err => { + expect(err).toBeDefined(); + expect(err.message).toBe('doh'); + expect(lazyLoadCount).toBe(2); + done(); + }) + }); + + expect(stateDeclaration.lazyLoad['_promise']).toBeDefined(); + }); + }); + describe('which returns a successful promise', () => { let lazyStateDefA = { name: 'A', url: '/a/:id', params: {id: "default"} }; let futureStateDef; beforeEach(() => { futureStateDef = { - name: 'A', url: '/a', + name: 'A.**', url: '/a', lazyLoad: () => new Promise(resolve => { resolve({ states: [lazyStateDefA] }); }) }; @@ -37,7 +251,7 @@ describe('a Future State', function () { }); it('should deregister the placeholder (future state)', (done) => { - expect($state.get().map(x=>x.name)).toEqual(["", "A"]); + expect($state.get().map(x=>x.name)).toEqual(["", "A.**"]); expect($state.get('A')).toBe(futureStateDef); expect($state.get('A').lazyLoad).toBeDefined(); @@ -88,14 +302,14 @@ describe('a Future State', function () { beforeEach(() => { futureStateDef = { - name: 'A', url: '/a', + name: 'A.**', url: '/a', lazyLoad: () => new Promise(resolve => { resolve({ states: [lazyStateDefA, lazyStateDefAB] }); }) }; $registry.register(futureStateDef) }); it('should register all returned states and remove the placeholder', (done) => { - expect($state.get().map(x=>x.name)).toEqual(["", "A"]); + expect($state.get().map(x=>x.name)).toEqual(["", "A.**"]); expect($state.get('A')).toBe(futureStateDef); expect($state.get('A').lazyLoad).toBeDefined(); @@ -132,7 +346,7 @@ describe('a Future State', function () { let count = 0; let futureStateDef = { - name: 'A', url: '/a', + name: 'A.**', url: '/a', lazyLoad: () => new Promise(resolve => { count++; setTimeout(() => resolve({ states: [{ name: 'A', url: '/a' }] }), 50); @@ -156,7 +370,7 @@ describe('a Future State', function () { router.stateService.defaultErrorHandler(err => errors.push(err)); count = 0; futureStateDef = { - name: 'A', url: '/a', + name: 'A.**', url: '/a', lazyLoad: () => new Promise((resolve, reject) => { if (count++ < 2) { reject("nope"); @@ -213,12 +427,12 @@ describe('a Future State', function () { let lazyStateDefB = { name: 'A.B', url: '/b/:bid', params: {id: "bdefault"} }; beforeEach(() => { futureStateDefA = { - name: 'A', url: '/a', + name: 'A.**', url: '/a', lazyLoad: () => new Promise(resolve => { resolve({ states: [lazyStateDefA, futureStateDefB] }); }) }; futureStateDefB = { - name: 'A.B', url: '/b', + name: 'A.B.**', url: '/b', lazyLoad: () => new Promise(resolve => { resolve({ states: [lazyStateDefB] }); }) }; diff --git a/test/stateRegistrySpec.ts b/test/stateRegistrySpec.ts index 5d303736..c911f212 100644 --- a/test/stateRegistrySpec.ts +++ b/test/stateRegistrySpec.ts @@ -150,6 +150,5 @@ describe("StateRegistry", () => { registry.register({name: 'A3'}); expect(log).toEqual("2: [registered:A3,A3.B]"); }); - }); }); \ No newline at end of file