Skip to content

Commit

Permalink
feat(lazyLoad): Add state.lazyLoad hook to lazy load a tree of states
Browse files Browse the repository at this point in the history
Closes #146
Closes #2739
  • Loading branch information
christopherthielen committed Aug 31, 2016
1 parent 4440811 commit bef5257
Show file tree
Hide file tree
Showing 14 changed files with 216 additions and 25 deletions.
63 changes: 63 additions & 0 deletions src/hooks/lazyLoadStates.ts
Original file line number Diff line number Diff line change
@@ -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);
1 change: 1 addition & 0 deletions src/hooks/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/ng2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

11 changes: 1 addition & 10 deletions src/ng2/directives/directives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<a uiSref="foo">Foo</a>'
* })
* ```
* @deprecated use [[UIRouterModule]]
*/
export let UIROUTER_DIRECTIVES = [UISref, AnchorUISref, UIView, UISrefActive, UISrefStatus];
4 changes: 2 additions & 2 deletions src/ng2/interface.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -342,4 +342,4 @@ export interface Ng2Component {
uiCanExit(): HookResult;
}

export const NG2_INJECTOR_TOKEN = {};
export const NG2_INJECTOR_TOKEN = new OpaqueToken("NgModule Injector");
68 changes: 68 additions & 0 deletions src/ng2/lazyLoadNgModule.ts
Original file line number Diff line number Diff line change
@@ -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<any>) => 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<any>): 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<any>) => applyNgModuleToNewStates(transition, moduleRef))
}
16 changes: 14 additions & 2 deletions src/ng2/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
21 changes: 21 additions & 0 deletions src/ng2/statebuilders/lazyLoadNgModuleResolvable.ts
Original file line number Diff line number Diff line change
@@ -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));
}
18 changes: 10 additions & 8 deletions src/ng2/routerModule.ts → src/ng2/uiRouterNgModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ 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],
exports: [UIROUTER_DIRECTIVES],
entryComponents: [UIView],
providers: [UIROUTER_PROVIDERS]
})
export class _UIRouterModule {}
export class UIRouterRootModule {}

/**
* A module declaration lteral, including UI-Router states.
Expand All @@ -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
Expand Down Expand Up @@ -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 = <any[]> (moduleMetaData.imports || []).concat(_UIRouterModule).reduce(uniqR, []);
moduleMetaData.declarations = <any[]> (moduleMetaData.declarations || []).concat(components).reduce(uniqR, []);
moduleMetaData.entryComponents = <any[]> (moduleMetaData.entryComponents || []).concat(components).reduce(uniqR, []);
moduleMetaData.providers = (moduleMetaData.providers || []).concat({ provide: UIROUTER_STATES_TOKEN, useValue: states });
moduleMetaData.imports = <any[]> (moduleMetaData.imports || []).concat(UIRouterRootModule).reduce(uniqR, []);
moduleMetaData.declarations = <any[]> (moduleMetaData.declarations || []).concat(routedComponents).reduce(uniqR, []);
moduleMetaData.entryComponents = <any[]> (moduleMetaData.entryComponents || []).concat(routedComponents).reduce(uniqR, []);
moduleMetaData.providers = (moduleMetaData.providers || []).concat(statesProvider);

return function(moduleClass) {
return NgModule(moduleMetaData)(moduleClass);
Expand Down
3 changes: 3 additions & 0 deletions src/resolve/resolvable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
9 changes: 9 additions & 0 deletions src/state/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,15 @@ export interface StateDeclaration {
*/
onExit?: TransitionStateHookFn;

/**
* A function that lazy loads a state tree.
*
* @param transition
*/
lazyLoad?: (transition: Transition) => Promise<StateDeclaration[]>;

/**
* @deprecated define individual parameters as [[ParamDeclaration.dynamic]]
*/
Expand Down
13 changes: 13 additions & 0 deletions src/state/stateMatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) { }
Expand All @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions src/state/stateObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export class State {
public onExit: TransitionStateHookFn;
public onRetain: TransitionStateHookFn;
public onEnter: TransitionStateHookFn;
public lazyLoad: (transition: Transition) => Promise<StateDeclaration[]>;

redirectTo: (
string |
Expand Down
9 changes: 7 additions & 2 deletions src/transition/transitionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -64,7 +66,7 @@ export class TransitionService implements IHookRegistry {
loadViews: Function;
activateViews: Function;
updateUrl: Function;
redirectTo: Function;
lazyLoad: Function;
};

constructor(private _router: UIRouter) {
Expand Down Expand Up @@ -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 */
Expand Down

0 comments on commit bef5257

Please sign in to comment.