From 8ecb6c68353ac833db02c2a970be124a233f5f60 Mon Sep 17 00:00:00 2001 From: Chris Thielen Date: Thu, 8 Sep 2016 20:58:11 -0500 Subject: [PATCH] feat(lazyLoad): Add state.lazyLoad hook to lazy load a tree of states - Retry URL sync by matching state*.url - Make registration of new states (by the lazyload hook) optional - Reuse lazy load promise, if one is in progress - Refactor so ng1-to-ng2 UIRouter provider is implicit - When state has a .lazyLoad, decorate state.name with `.**` wildcard Closes #146 Closes #2739 --- src/hooks/lazyLoadStates.ts | 47 ++++---- src/ng2/lazyLoadNgModule.ts | 12 ++- src/ng2/providers.ts | 22 +--- src/state/interface.ts | 91 +++++++++++++++- src/state/stateBuilder.ts | 13 +++ src/state/stateMatcher.ts | 3 +- test/core/lazyLoadSpec.ts | 210 ++++++++++++++++++++++++++++++++++++ 7 files changed, 347 insertions(+), 51 deletions(-) create mode 100644 test/core/lazyLoadSpec.ts diff --git a/src/hooks/lazyLoadStates.ts b/src/hooks/lazyLoadStates.ts index 628c25577..70af35254 100644 --- a/src/hooks/lazyLoadStates.ts +++ b/src/hooks/lazyLoadStates.ts @@ -2,7 +2,7 @@ import {Transition} from "../transition/transition"; import {TransitionService} from "../transition/transitionService"; import {TransitionHookFn} from "../transition/interface"; -import {StateDeclaration} from "../state/interface"; +import {StateDeclaration, LazyLoadResult} from "../state/interface"; import {State} from "../state/stateObject"; import {services} from "../common/coreservices"; @@ -22,14 +22,15 @@ import {services} from "../common/coreservices"; */ const lazyLoadHook: TransitionHookFn = (transition: Transition) => { var toState = transition.to(); + let registry = transition.router.stateRegistry; - function retryOriginalTransition(newStates: State[]) { + function retryOriginalTransition() { if (transition.options().source === 'url') { - let loc = services.location; - let path = loc.path(), search = loc.search(), hash = loc.hash(); + 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); - let matchState = state => [state, state.url.exec(path, search, hash)]; - let matches = newStates.map(matchState).filter(([state, params]) => !!params); if (matches.length) { let [state, params] = matches[0]; return transition.router.stateService.target(state, params, transition.options()); @@ -37,27 +38,33 @@ const lazyLoadHook: TransitionHookFn = (transition: Transition) => { transition.router.urlRouter.sync(); } - let state = transition.targetState().identifier(); - let params = transition.params(); - let options = transition.options(); - return transition.router.stateService.target(state, params, options); + // 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(newStates: StateDeclaration[]) { - let registry = transition.router.stateRegistry; - let placeholderState = transition.to(); + function updateStateRegistry(result: LazyLoadResult) { + // deregister placeholder state + registry.deregister(transition.$to()); + if (result && Array.isArray(result.states)) { + result.states.forEach(state => registry.register(state)); + } + } - registry.deregister(placeholderState); - newStates.forEach(state => registry.register(state)); - return newStates.map(state => registry.get(state).$$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.catch(cleanup, cleanup); } - - return toState.lazyLoad(transition) - .then(updateStateRegistry) - .then(retryOriginalTransition) + + return promise.then(retryOriginalTransition); }; export const registerLazyLoadHook = (transitionService: TransitionService) => diff --git a/src/ng2/lazyLoadNgModule.ts b/src/ng2/lazyLoadNgModule.ts index 251db7ed5..f7edc1a0d 100644 --- a/src/ng2/lazyLoadNgModule.ts +++ b/src/ng2/lazyLoadNgModule.ts @@ -5,6 +5,7 @@ import {UIROUTER_STATES_TOKEN} from "./uiRouterNgModule"; import {NgModuleFactoryLoader, NgModuleRef, Injector, NgModuleFactory} from "@angular/core"; import {unnestR} from "../common/common"; +import {LazyLoadResult} from "../state/interface"; /** * Returns a function which lazy loads a nested module @@ -20,7 +21,7 @@ import {unnestR} from "../common/common"; * * returns the new states array */ -export function loadNgModule(path: string) { +export function loadNgModule(path: string): (transition: Transition) => Promise { /** Get the parent NgModule Injector (from resolves) */ const getNg2Injector = (transition: Transition) => transition.injector().getAsync(NG2_INJECTOR_TOKEN); @@ -34,8 +35,8 @@ export function loadNgModule(path: string) { * - Create the new NgModule */ const createNg2Module = (path: string, ng2Injector: Injector) => - ng2Injector.get(NgModuleFactoryLoader).load(path) - .then((factory: NgModuleFactory) => factory.create(ng2Injector)); + ng2Injector.get(NgModuleFactoryLoader).load(path).then((factory: NgModuleFactory) => + factory.create(ng2Injector)); /** * Apply the Lazy Loaded NgModule's Injector to the newly loaded state tree. @@ -47,7 +48,7 @@ export function loadNgModule(path: string) { * The NgModule's Injector (and ComponentFactoryResolver) will be added to that state. * The Injector/Factory are used when creating Components for the `replacement` state and all its children. */ - function applyNgModuleToNewStates(transition: Transition, ng2Module: NgModuleRef): Ng2StateDeclaration[] { + function applyNgModuleToNewStates(transition: Transition, ng2Module: NgModuleRef): LazyLoadResult { var targetName = transition.to().name; let newStates: Ng2StateDeclaration[] = ng2Module.injector.get(UIROUTER_STATES_TOKEN).reduce(unnestR, []); let replacementState = newStates.find(state => state.name === targetName); @@ -60,7 +61,8 @@ export function loadNgModule(path: string) { // Add the injector as a resolve. replacementState['_ngModuleInjector'] = ng2Module.injector; - return newStates; + // Return states to be registered by the lazyLoadHook + return { states: newStates }; } return (transition: Transition) => getNg2Injector(transition) diff --git a/src/ng2/providers.ts b/src/ng2/providers.ts index 0921363dd..8646508d6 100644 --- a/src/ng2/providers.ts +++ b/src/ng2/providers.ts @@ -70,29 +70,13 @@ import {UIROUTER_STATES_TOKEN} from "./uiRouterNgModule"; import {UIRouterRx} from "./rx"; import {LocationStrategy, HashLocationStrategy, PathLocationStrategy} from "@angular/common"; -export const NG1_UIROUTER_TOKEN = new OpaqueToken("$uiRouter"); - /** * This is a factory function for a UIRouter instance * * Creates a UIRouter instance and configures it for Angular 2, then invokes router bootstrap. * This function is used as an Angular 2 `useFactory` Provider. */ -let uiRouterFactory = (injector: Injector) => { - // ----------------- ng1-to-ng2 short circuit ------ - // Before creating a UIRouter instance, see if there is - // already one created (from ng1-to-ng2 as NG1_UIROUTER_TOKEN) - let $uiRouter = injector.get(NG1_UIROUTER_TOKEN, null); - if ($uiRouter) return $uiRouter; - - - // ----------------- Get DI dependencies ----------- - // Get the DI deps manually from the injector - // (no UIRouterConfig is provided when in hybrid mode) - let routerConfig: UIRouterConfig = injector.get(UIRouterConfig); - let location: UIRouterLocation = injector.get(UIRouterLocation); - - +let uiRouterFactory = (routerConfig: UIRouterConfig, location: UIRouterLocation, injector: Injector) => { // ----------------- Monkey Patches ---------------- // Monkey patch the services.$injector to the ng2 Injector services.$injector.get = injector.get.bind(injector); @@ -131,7 +115,7 @@ let uiRouterFactory = (injector: Injector) => { routerConfig.configure(router); // Register the states from the root NgModule [[UIRouterModule]] - let states = (injector.get(UIROUTER_STATES_TOKEN) || []).reduce(flattenR, []); + let states = injector.get(UIROUTER_STATES_TOKEN, []).reduce(flattenR, []); states.forEach(state => registry.register(state)); // Start monitoring the URL @@ -145,7 +129,7 @@ let uiRouterFactory = (injector: Injector) => { }; export const _UIROUTER_INSTANCE_PROVIDERS: Provider[] = [ - { provide: UIRouter, useFactory: uiRouterFactory, deps: [Injector] }, + { provide: UIRouter, useFactory: uiRouterFactory, deps: [UIRouterConfig, UIRouterLocation, Injector] }, { provide: UIRouterLocation, useClass: UIRouterLocation }, ]; diff --git a/src/state/interface.ts b/src/state/interface.ts index a891194da..ee0a11263 100644 --- a/src/state/interface.ts +++ b/src/state/interface.ts @@ -521,13 +521,90 @@ export interface StateDeclaration { onExit?: TransitionStateHookFn; /** - * A function that lazy loads a state tree. - - + * A function which lazy loads the state (and child states) * - * @param transition + * 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**". + * + * + * ### Future State placeholders + * + * #### `lazyLoad`: + * + * A future state's `lazyLoad` function should return a Promise for an array of + * lazy loaded [[StateDeclaration]] objects. + * + * The lazy loaded states are registered with the [[StateRegistry]]. + * One of the lazy loaded states must have the same name as the future state; + * it will **replace the future state placeholder** in the registry. + * + * #### `url` + * 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. + * + * #### `name` + * + * A future state's `name` property acts as a wildcard. + * + * It matches any state name that starts with the `name`. + * UI-Router effectively matches the future state using a `.**` [[Glob]] appended to the `name`. + * + * --- + * + * Future state placeholders should only define `lazyLoad`, `name`, and `url`. + * Any additional properties should only be defined on the state that will eventually be lazy loaded. + * + * @example + * #### states.js + * ```js + * + * // The parent state is not lazy loaded + * { + * name: 'parent', + * url: '/parent', + * component: ParentComponent + * } + * + * // 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') + * } + * ``` + * + * #### child.state.js + * + * This file is lazy loaded. It exports an array of states. + * + * ```js + * import {ChildComponent} from "./child.component.js"; + * + * let childState = { + * // the name should match the future state + * name: 'parent.child', + * url: '/child/:childId', + * params: { + * id: "default" + * }, + * resolve: { + * childData: ($transition$, ChildService) => + * ChildService.get($transition$.params().childId) + * } + * }; + * + * // The future state's lazyLoad imports this array of states + * export default [ childState ]; + * ``` + * + * @param transition the [[Transition]] that is activating the future state */ - lazyLoad?: (transition: Transition) => Promise; + lazyLoad?: (transition: Transition) => Promise; /** * @deprecated define individual parameters as [[ParamDeclaration.dynamic]] @@ -535,6 +612,10 @@ export interface StateDeclaration { reloadOnSearch?: boolean; } +export interface LazyLoadResult { + states?: StateDeclaration[]; +} + export interface HrefOptions { relative?: StateOrName; lossy?: boolean; diff --git a/src/state/stateBuilder.ts b/src/state/stateBuilder.ts index 904dd4d49..0c440226b 100644 --- a/src/state/stateBuilder.ts +++ b/src/state/stateBuilder.ts @@ -26,6 +26,7 @@ export type BuilderFunction = (state: State, parent?: BuilderFunction) => any; interface Builders { [key: string]: BuilderFunction[]; + name: BuilderFunction[]; parent: BuilderFunction[]; data: BuilderFunction[]; url: BuilderFunction[]; @@ -38,6 +39,12 @@ interface Builders { } +function nameBuilder(state: State) { + if (state.lazyLoad) + state.name = state.self.name + ".**"; + return state.name; +} + function selfBuilder(state: State) { state.self.$$state = () => state; return state.self; @@ -53,6 +60,11 @@ function dataBuilder(state: State) { const getUrlBuilder = ($urlMatcherFactoryProvider: UrlMatcherFactory, root: () => State) => function urlBuilder(state: State) { let stateDec: StateDeclaration = state; + + if (stateDec && stateDec.url && stateDec.lazyLoad) { + stateDec.url += "{remainder:any}"; // match any path (.*) + } + const parsed = parseUrl(stateDec.url), parent = state.parent; const url = !parsed ? stateDec.url : $urlMatcherFactoryProvider.compile(parsed.val, { params: state.params || {}, @@ -212,6 +224,7 @@ export class StateBuilder { } this.builders = { + name: [ nameBuilder ], self: [ selfBuilder ], parent: [ parentBuilder ], data: [ dataBuilder ], diff --git a/src/state/stateMatcher.ts b/src/state/stateMatcher.ts index 2c801c8bc..0baa1532c 100644 --- a/src/state/stateMatcher.ts +++ b/src/state/stateMatcher.ts @@ -26,8 +26,7 @@ export class StateMatcher { return state; } else if (isStr) { let matches = values(this._states) - .filter(state => !!state.lazyLoad) - .map(state => ({ state, glob: new Glob(state.name + ".**")})) + .map(state => ({ state, glob: new Glob(state.name)})) .filter(({state, glob}) => glob.matches(name)) .map(({state, glob}) => state); diff --git a/test/core/lazyLoadSpec.ts b/test/core/lazyLoadSpec.ts new file mode 100644 index 000000000..125e845f2 --- /dev/null +++ b/test/core/lazyLoadSpec.ts @@ -0,0 +1,210 @@ +import { trace, UIRouter TransitionService, StateService } from "../../src/core"; +import "../../src/justjs"; +import {StateRegistry} from "../../src/state/stateRegistry"; +import {services} from "../../src/common/coreservices"; +import {UrlRouter} from "../../src/url/urlRouter"; + +describe('a Future State', function () { + let router: UIRouter; + let $registry: StateRegistry; + let $transitions: TransitionService; + let $state: StateService; + let $urlRouter: UrlRouter; + + const wait = (val?) => + new Promise((resolve) => setTimeout(() => resolve(val))); + + beforeEach(() => { + router = new UIRouter(); + $registry = router.stateRegistry; + $state = router.stateService; + $transitions = router.transitionService; + $urlRouter = router.urlRouter; + router.stateRegistry.stateQueue.autoFlush($state); + }); + + describe('which returns a successful promise', () => { + let lazyStateDefA = { name: 'A', url: '/a/:id', params: {id: "default"} }; + let futureStateDef; + + beforeEach(() => { + futureStateDef = { + name: 'A', url: '/a', + lazyLoad: () => new Promise(resolve => { resolve({ states: [lazyStateDefA] }); }) + }; + + $registry.register(futureStateDef) + }); + + it('should deregister the placeholder (future state)', (done) => { + expect($state.get().map(x=>x.name)).toEqual(["", "A"]); + expect($state.get('A')).toBe(futureStateDef); + expect($state.get('A').lazyLoad).toBeDefined(); + + $state.go('A').then(() => { + expect($state.get().map(x=>x.name)).toEqual(["", "A"]); + expect($state.get('A')).toBe(lazyStateDefA); + expect($state.get('A').lazyLoad).toBeUndefined(); + expect($state.current.name).toBe('A'); + done(); + }) + }); + + it('should register newly loaded states returned in the `states: ` array', (done) => { + expect($state.get('A')).toBe(futureStateDef); + + $state.go('A').then(() => { + expect($state.get().map(x=>x.name)).toEqual(["", "A"]); + expect($state.get('A')).toBe(lazyStateDefA); + expect($state.get('A').lazyLoad).toBeUndefined(); + expect($state.current.name).toBe('A'); + done(); + }) + }); + + it('should retry the original $state.go()', (done) => { + $state.go('A', { id: 'abc' }).then(() => { + expect($state.current.name).toBe('A'); + expect($state.params).toEqualValues({ id: 'abc' }); + done(); + }) + }); + + it('triggered by a URL sync should re-parse the URL to activate the lazy loaded state', (done) => { + services.location.setUrl('/a/def'); + $urlRouter.sync(); + $transitions.onSuccess({}, () => { + expect($state.current.name).toBe('A'); + expect($state.params).toEqualValues({ id: 'def' }); + done(); + }); + }); + }); + + describe('that resolves to multiple states', () => { + let lazyStateDefA = { name: 'A', url: '/a/:id', params: {id: "default"} }; + let lazyStateDefAB = { name: 'A.B', url: '/b' }; + let futureStateDef; + + beforeEach(() => { + futureStateDef = { + 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('A')).toBe(futureStateDef); + expect($state.get('A').lazyLoad).toBeDefined(); + + $state.go('A').then(() => { + expect($state.get().map(x=>x.name)).toEqual(["", "A", "A.B"]); + expect($state.get('A')).toBe(lazyStateDefA); + expect($state.get('A').lazyLoad).toBeUndefined(); + expect($state.current.name).toBe('A'); + done(); + }) + }); + + it('should allow transitions to non-loaded child states', (done) => { + $state.go('A.B', { id: 'abc' }).then(() => { + expect($state.current.name).toBe('A.B'); + expect($state.params).toEqualValues({ id: 'abc' }); + done(); + }) + }); + + it('should re-parse the URL to activate the final state', (done) => { + services.location.setUrl('/a/def/b'); + $urlRouter.sync(); + $transitions.onSuccess({}, () => { + expect($state.current.name).toBe('A.B'); + expect($state.params).toEqualValues({ id: 'def' }); + done(); + }); + }); + }); + + it('should not invoke lazyLoad twice', (done) => { + $state.defaultErrorHandler(function() {}); + + let count = 0; + let futureStateDef = { + name: 'A', url: '/a', + lazyLoad: () => new Promise(resolve => { + count++; + setTimeout(() => resolve({ states: [{ name: 'A', url: '/a' }] }), 50); + }) + }; + $registry.register(futureStateDef); + + $state.go('A'); + $state.go('A').then(() => { + expect(count).toBe(1); + expect($state.current.name).toBe('A'); + done(); + }); + }); + + describe('that return a rejected promise', () => { + let count, futureStateDef, errors; + + beforeEach(() => { + errors = []; + router.stateService.defaultErrorHandler(err => errors.push(err)); + count = 0; + futureStateDef = { + name: 'A', url: '/a', + lazyLoad: () => new Promise((resolve, reject) => { + if (count++ < 2) { + reject("nope"); + } else { + resolve({ states: [{ name: 'A', url: '/a' }] }); + } + }) + }; + + $registry.register(futureStateDef) + }); + + it('should not remove the placeholder', (done) => { + expect($state.get('A')).toBe(futureStateDef); + + $state.go('A').catch(() => { + expect(errors).toEqual(['nope']); + expect($state.get('A')).toBe(futureStateDef); + done(); + }); + }); + + it('should allow lazy loading to be retried', (done) => { + expect($state.get('A')).toBe(futureStateDef); + + $state.go('A').catch(() => { + expect(errors).toEqual(['nope']); + expect($state.get('A')).toBe(futureStateDef); + expect(count).toBe(1); + + $state.go('A').catch(() => { + expect(errors).toEqual(['nope', 'nope']); + expect($state.get('A')).toBe(futureStateDef); + expect(count).toBe(2); + + // this time it should lazy load + $state.go('A').then(() => { + expect(errors).toEqual(['nope', 'nope']); + expect($state.get('A')).toBeTruthy(); + expect($state.get('A')).not.toBe(futureStateDef); + expect(count).toBe(3); + expect($state.current.name).toBe('A'); + + done(); + }) + }) + }) + }); + + }); +});