From bef52571bb9d308fd034926bd36351a9ad8ff90d Mon Sep 17 00:00:00 2001 From: Chris Thielen Date: Sat, 27 Aug 2016 11:43:59 -0500 Subject: [PATCH] feat(lazyLoad): Add state.lazyLoad hook to lazy load a tree of states Closes #146 Closes #2739 --- src/hooks/lazyLoadStates.ts | 63 +++++++++++++++++ src/hooks/url.ts | 1 + src/ng2.ts | 4 +- src/ng2/directives/directives.ts | 11 +-- src/ng2/interface.ts | 4 +- src/ng2/lazyLoadNgModule.ts | 68 +++++++++++++++++++ src/ng2/providers.ts | 16 ++++- .../lazyLoadNgModuleResolvable.ts | 21 ++++++ .../{routerModule.ts => uiRouterNgModule.ts} | 18 ++--- src/resolve/resolvable.ts | 3 + src/state/interface.ts | 9 +++ src/state/stateMatcher.ts | 13 ++++ src/state/stateObject.ts | 1 + src/transition/transitionService.ts | 9 ++- 14 files changed, 216 insertions(+), 25 deletions(-) create mode 100644 src/hooks/lazyLoadStates.ts create mode 100644 src/ng2/lazyLoadNgModule.ts create mode 100644 src/ng2/statebuilders/lazyLoadNgModuleResolvable.ts rename src/ng2/{routerModule.ts => uiRouterNgModule.ts} (72%) diff --git a/src/hooks/lazyLoadStates.ts b/src/hooks/lazyLoadStates.ts new file mode 100644 index 000000000..06c67e446 --- /dev/null +++ b/src/hooks/lazyLoadStates.ts @@ -0,0 +1,63 @@ +import {Transition} from "../transition/transition"; +import {TransitionService} from "../transition/transitionService"; +import {TransitionHookFn} from "../transition/interface"; +import {StateDeclaration} 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(); + + function retryOriginalTransition(newStates: State[]) { + if (transition.options().source === 'url') { + let loc = services.location; + let path = loc.path(), search = loc.search(), hash = loc.hash(); + + 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()); + } + 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); + } + + /** + * 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(); + + registry.deregister(placeholderState); + newStates.forEach(state => registry.register(state)); + return newStates.map(state => registry.get(state).$$state()); + } + + return toState.lazyLoad(transition) + .then(updateStateRegistry) + .then(retryOriginalTransition) +}; + +export const registerLazyLoadHook = (transitionService: TransitionService) => + transitionService.onBefore({ to: (state) => !!state.lazyLoad }, lazyLoadHook); diff --git a/src/hooks/url.ts b/src/hooks/url.ts index b68a30320..3cd25b316 100644 --- a/src/hooks/url.ts +++ b/src/hooks/url.ts @@ -3,6 +3,7 @@ import {UrlRouter} from "../url/urlRouter"; import {StateService} from "../state/stateService"; import {Transition} from "../transition/transition"; import {TransitionHookFn} from "../transition/interface"; +import {TransitionService} from "../transition/transitionService"; /** * A [[TransitionHookFn]] which updates the URL after a successful transition diff --git a/src/ng2.ts b/src/ng2.ts index c449704a9..69fdcd44b 100644 --- a/src/ng2.ts +++ b/src/ng2.ts @@ -8,10 +8,12 @@ export * from "./core"; import "./justjs"; export * from "./ng2/interface"; -export * from "./ng2/routerModule"; +export * from "./ng2/lazyLoadNgModule"; export * from "./ng2/providers"; export * from "./ng2/location"; export * from "./ng2/directives/directives"; export * from "./ng2/statebuilders/views"; +export * from "./ng2/statebuilders/lazyLoadNgModuleResolvable"; +export * from "./ng2/uiRouterNgModule"; export * from "./ng2/uiRouterConfig"; diff --git a/src/ng2/directives/directives.ts b/src/ng2/directives/directives.ts index b1225b446..7744c3bdd 100644 --- a/src/ng2/directives/directives.ts +++ b/src/ng2/directives/directives.ts @@ -19,15 +19,6 @@ export * from "./uiSrefActive"; /** * References to the UI-Router directive classes, for use within a @Component's `directives:` property - * - * @example - * ```js - * - * Component({ - * selector: 'my-cmp', - * directives: [UIROUTER_DIRECTIVES], - * template: 'Foo' - * }) - * ``` + * @deprecated use [[UIRouterModule]] */ export let UIROUTER_DIRECTIVES = [UISref, AnchorUISref, UIView, UISrefActive, UISrefStatus]; diff --git a/src/ng2/interface.ts b/src/ng2/interface.ts index 3192d7ad7..30d20fcd9 100644 --- a/src/ng2/interface.ts +++ b/src/ng2/interface.ts @@ -1,7 +1,7 @@ /** @module ng2 */ /** */ import {StateDeclaration, _ViewDeclaration} from "../state/interface"; import {Transition} from "../transition/transition"; -import {Type} from "@angular/core"; +import {Type, OpaqueToken} from "@angular/core"; import {HookResult} from "../transition/interface"; /** @@ -342,4 +342,4 @@ export interface Ng2Component { uiCanExit(): HookResult; } -export const NG2_INJECTOR_TOKEN = {}; +export const NG2_INJECTOR_TOKEN = new OpaqueToken("NgModule Injector"); diff --git a/src/ng2/lazyLoadNgModule.ts b/src/ng2/lazyLoadNgModule.ts new file mode 100644 index 000000000..f315aa78f --- /dev/null +++ b/src/ng2/lazyLoadNgModule.ts @@ -0,0 +1,68 @@ +import {Transition} from "../transition/transition"; +import {NG2_INJECTOR_TOKEN, Ng2StateDeclaration} from "./interface"; +import {UIROUTER_STATES_TOKEN} from "./uiRouterNgModule"; + +import {NgModuleFactoryLoader, NgModuleRef, Injector, NgModuleFactory} from "@angular/core"; +import {unnestR} from "../common/common"; + +/** + * Returns a function which lazy loads a nested module + * + * Use this function as a [[StateDeclaration.lazyLoad]] property to lazy load a state tree (an NgModule). + * + * @param path the path to the module source code. + * @returns A function which takes a transition, then: + * + * - Gets the Injector (scoped properly for the destination state) + * - Loads and creates the NgModule + * - Finds the "replacement state" for the target state, and adds the new NgModule Injector to it (as a resolve) + * + * returns the new states array + */ +export function loadNgModule(path: string) { + /** Get the parent NgModule Injector (from resolves) */ + const getNg2Injector = (transition: Transition) => + transition.injector().getAsync(NG2_INJECTOR_TOKEN); + + /** + * Lazy loads the NgModule using the NgModuleFactoryLoader + * + * Use the parent NgModule's Injector to: + * - Find the correct NgModuleFactoryLoader + * - Load the new NgModuleFactory from the path string (async) + * - Create the new NgModule + */ + const createNg2Module = (path: string, ng2Injector: Injector) => + ng2Injector.get(NgModuleFactoryLoader).load(path) + .then((factory: NgModuleFactory) => factory.create(ng2Injector)); + + /** + * Apply the Lazy Loaded NgModule's Injector to the newly loaded state tree. + * + * Lazy loading uses a placeholder state which is removed (and replaced) after the module is loaded. + * The NgModule should include a state with the same name as the placeholder. + * + * Find the *newly loaded state* with the same name as the *placeholder state*. + * 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[] { + var targetName = transition.to().name; + let newStates: Ng2StateDeclaration[] = ng2Module.injector.get(UIROUTER_STATES_TOKEN).reduce(unnestR, []); + let replacementState = newStates.find(state => state.name === targetName); + + if (!replacementState) { + throw new Error(`The module that was loaded from ${path} should have a state named '${targetName}'` + + `, but it only had: ${(newStates || []).map(s=>s.name).join(', ')}`); + } + + // Add the injector as a resolve. + replacementState['_ngModuleInjector'] = ng2Module.injector; + + return newStates; + } + + return (transition: Transition) => getNg2Injector(transition) + .then((injector: Injector) => createNg2Module(path, injector)) + .then((moduleRef: NgModuleRef) => applyNgModuleToNewStates(transition, moduleRef)) +} diff --git a/src/ng2/providers.ts b/src/ng2/providers.ts index 20c7b2bee..733070ccf 100644 --- a/src/ng2/providers.ts +++ b/src/ng2/providers.ts @@ -64,15 +64,27 @@ import {UIRouterLocation} from "./location"; import {services} from "../common/coreservices"; import {ProviderLike} from "../state/interface"; import {Resolvable} from "../resolve/resolvable"; +import {ngModuleResolvablesBuilder} from "./statebuilders/lazyLoadNgModuleResolvable"; let uiRouterFactory = (routerConfig: UIRouterConfig, location: UIRouterLocation, injector: Injector) => { services.$injector.get = injector.get.bind(injector); - let router = new UIRouter(); location.init(); + + // ----------------- Create router ----------------- + // Create a new ng2 UIRouter and configure it for ng2 + let router = new UIRouter(); + let registry = router.stateRegistry; + + // ----------------- Configure for ng2 ------------- + // Apply ng2 ui-view handling code router.viewService.viewConfigFactory("ng2", (path: PathNode[], config: Ng2ViewDeclaration) => new Ng2ViewConfig(path, config)); - router.stateRegistry.decorator('views', ng2ViewsBuilder); + registry.decorator('views', ng2ViewsBuilder); + + // Apply statebuilder decorator for ng2 NgModule registration + registry.stateQueue.flush(router.stateService); + registry.decorator('resolvables', ngModuleResolvablesBuilder); router.stateRegistry.stateQueue.autoFlush(router.stateService); diff --git a/src/ng2/statebuilders/lazyLoadNgModuleResolvable.ts b/src/ng2/statebuilders/lazyLoadNgModuleResolvable.ts new file mode 100644 index 000000000..3066551fa --- /dev/null +++ b/src/ng2/statebuilders/lazyLoadNgModuleResolvable.ts @@ -0,0 +1,21 @@ +/** @module ng2 */ /** */ +import {State} from "../../state/stateObject"; +import {NG2_INJECTOR_TOKEN} from "../interface"; +import {Resolvable} from "../../resolve/resolvable"; + +/** + * This is a [[StateBuilder.builder]] function which enables lazy Ng2Module support. + * + * See [[loadNgModule]] + * + * After lazy loading an NgModule, any Components from that module should be created using the NgModule's Injecjtor. + * The NgModule's ComponentFactory only exists inside that Injector. + * + * After lazy loading an NgModule, it is stored on the root state of the lazy loaded state tree. + * When instantiating Component, the parent Component's Injector is merged with the NgModule injector. + */ +export function ngModuleResolvablesBuilder(state: State, parentFn: Function): Resolvable[] { + let resolvables: Resolvable[] = parentFn(state); + let injector = state.self['_ngModuleInjector']; + return !injector ? resolvables : resolvables.concat(Resolvable.fromData(NG2_INJECTOR_TOKEN, injector)); +} diff --git a/src/ng2/routerModule.ts b/src/ng2/uiRouterNgModule.ts similarity index 72% rename from src/ng2/routerModule.ts rename to src/ng2/uiRouterNgModule.ts index d1d8830bc..de2efd5f5 100644 --- a/src/ng2/routerModule.ts +++ b/src/ng2/uiRouterNgModule.ts @@ -3,7 +3,7 @@ import {NgModule, NgModuleMetadataType, OpaqueToken} from "@angular/core"; import {UIROUTER_DIRECTIVES} from "./directives/directives"; import {UIROUTER_PROVIDERS} from "./providers"; import {UIView} from "./directives/uiView"; -import {uniqR} from "../common/common"; +import {uniqR, flattenR} from "../common/common"; @NgModule({ declarations: [UIROUTER_DIRECTIVES], @@ -11,7 +11,7 @@ import {uniqR} from "../common/common"; entryComponents: [UIView], providers: [UIROUTER_PROVIDERS] }) -export class _UIRouterModule {} +export class UIRouterRootModule {} /** * A module declaration lteral, including UI-Router states. @@ -23,7 +23,7 @@ export interface UIRouterModuleMetadata extends NgModuleMetadataType { states?: Ng2StateDeclaration[] } -export const UIROUTER_STATES_TOKEN = new OpaqueToken("UIRouterStates"); +export const UIROUTER_STATES_TOKEN = new OpaqueToken("UIRouter States"); /** * Declares a NgModule with UI-Router states @@ -51,17 +51,19 @@ export const UIROUTER_STATES_TOKEN = new OpaqueToken("UIRouterStates"); */ export function UIRouterModule(moduleMetaData: UIRouterModuleMetadata) { let states = moduleMetaData.states || []; + var statesProvider = { provide: UIROUTER_STATES_TOKEN, useValue: states, multi: true }; // Get the component classes for all views for all states in the module - let components = states.map(state => state.views || { $default: state }) + let routedComponents = states.reduce(flattenR, []) + .map(state => state.views || { $default: state }) .map(viewObj => Object.keys(viewObj).map(key => viewObj[key].component)) .reduce((acc, arr) => acc.concat(arr), []) .filter(x => typeof x === 'function' && x !== UIView); - moduleMetaData.imports = (moduleMetaData.imports || []).concat(_UIRouterModule).reduce(uniqR, []); - moduleMetaData.declarations = (moduleMetaData.declarations || []).concat(components).reduce(uniqR, []); - moduleMetaData.entryComponents = (moduleMetaData.entryComponents || []).concat(components).reduce(uniqR, []); - moduleMetaData.providers = (moduleMetaData.providers || []).concat({ provide: UIROUTER_STATES_TOKEN, useValue: states }); + moduleMetaData.imports = (moduleMetaData.imports || []).concat(UIRouterRootModule).reduce(uniqR, []); + moduleMetaData.declarations = (moduleMetaData.declarations || []).concat(routedComponents).reduce(uniqR, []); + moduleMetaData.entryComponents = (moduleMetaData.entryComponents || []).concat(routedComponents).reduce(uniqR, []); + moduleMetaData.providers = (moduleMetaData.providers || []).concat(statesProvider); return function(moduleClass) { return NgModule(moduleMetaData)(moduleClass); diff --git a/src/resolve/resolvable.ts b/src/resolve/resolvable.ts index 2fc521d21..ed61a6845 100644 --- a/src/resolve/resolvable.ts +++ b/src/resolve/resolvable.ts @@ -167,4 +167,7 @@ export class Resolvable implements ResolvableLiteral { clone(): Resolvable { return new Resolvable(this); } + + static fromData = (token: any, data: any) => + new Resolvable(token, () => data, null, null, data); } diff --git a/src/state/interface.ts b/src/state/interface.ts index 365967210..a891194da 100644 --- a/src/state/interface.ts +++ b/src/state/interface.ts @@ -520,6 +520,15 @@ export interface StateDeclaration { */ onExit?: TransitionStateHookFn; + /** + * A function that lazy loads a state tree. + + + * + * @param transition + */ + lazyLoad?: (transition: Transition) => Promise; + /** * @deprecated define individual parameters as [[ParamDeclaration.dynamic]] */ diff --git a/src/state/stateMatcher.ts b/src/state/stateMatcher.ts index ccdc0208f..2c801c8bc 100644 --- a/src/state/stateMatcher.ts +++ b/src/state/stateMatcher.ts @@ -2,6 +2,8 @@ import {isString} from "../common/predicates"; import {StateOrName} from "./interface"; import {State} from "./stateObject"; +import {Glob} from "../common/glob"; +import {values} from "../common/common"; export class StateMatcher { constructor (private _states: { [key: string]: State }) { } @@ -22,6 +24,17 @@ export class StateMatcher { if (state && (isStr || (!isStr && (state === stateOrName || state.self === stateOrName)))) { return state; + } else if (isStr) { + let matches = values(this._states) + .filter(state => !!state.lazyLoad) + .map(state => ({ state, glob: new Glob(state.name + ".**")})) + .filter(({state, glob}) => glob.matches(name)) + .map(({state, glob}) => state); + + if (matches.length > 1) { + console.log(`stateMatcher.find: Found multiple matches for ${name} using glob: `, matches.map(match => match.name)); + } + return matches[0]; } return undefined; } diff --git a/src/state/stateObject.ts b/src/state/stateObject.ts index cddfc7ccd..553a4ccda 100644 --- a/src/state/stateObject.ts +++ b/src/state/stateObject.ts @@ -44,6 +44,7 @@ export class State { public onExit: TransitionStateHookFn; public onRetain: TransitionStateHookFn; public onEnter: TransitionStateHookFn; + public lazyLoad: (transition: Transition) => Promise; redirectTo: ( string | diff --git a/src/transition/transitionService.ts b/src/transition/transitionService.ts index 01b1502ff..dd644fc6e 100644 --- a/src/transition/transitionService.ts +++ b/src/transition/transitionService.ts @@ -17,6 +17,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"; /** * The default [[Transition]] options. @@ -50,12 +51,13 @@ export class TransitionService implements IHookRegistry { public $view: ViewService; /** - * This object has hook de-registration functions. + * This object has hook de-registration functions for the built-in hooks. * This can be used by third parties libraries that wish to customize the behaviors * * @hidden */ _deregisterHookFns: { + redirectTo: Function; onExit: Function; onRetain: Function; onEnter: Function; @@ -64,7 +66,7 @@ export class TransitionService implements IHookRegistry { loadViews: Function; activateViews: Function; updateUrl: Function; - redirectTo: Function; + lazyLoad: Function; }; constructor(private _router: UIRouter) { @@ -96,6 +98,9 @@ export class TransitionService implements IHookRegistry { // After globals.current is updated at priority: 10000 fns.updateUrl = registerUpdateUrl(this); + + // Lazy load state trees + fns.lazyLoad = registerLazyLoadHook(this); } /** @inheritdoc */