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
- 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
  • Loading branch information
christopherthielen committed Sep 9, 2016
1 parent d1dff31 commit 8ecb6c6
Show file tree
Hide file tree
Showing 7 changed files with 347 additions and 51 deletions.
47 changes: 27 additions & 20 deletions src/hooks/lazyLoadStates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -22,42 +22,49 @@ 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());
}
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) =>
Expand Down
12 changes: 7 additions & 5 deletions src/ng2/lazyLoadNgModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<LazyLoadResult> {
/** Get the parent NgModule Injector (from resolves) */
const getNg2Injector = (transition: Transition) =>
transition.injector().getAsync(NG2_INJECTOR_TOKEN);
Expand All @@ -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<any>) => factory.create(ng2Injector));
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.
Expand All @@ -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<any>): Ng2StateDeclaration[] {
function applyNgModuleToNewStates(transition: Transition, ng2Module: NgModuleRef<any>): 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);
Expand All @@ -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)
Expand Down
22 changes: 3 additions & 19 deletions src/ng2/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

This comment has been minimized.

Copy link
@christopherthielen

christopherthielen Sep 14, 2016

Author Contributor

This is how I supported ng1-to-ng2 previously @adharris



// ----------------- 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);
Expand Down Expand Up @@ -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
Expand All @@ -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 },
];

Expand Down
91 changes: 86 additions & 5 deletions src/state/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,20 +521,101 @@ 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<StateDeclaration[]>;
lazyLoad?: (transition: Transition) => Promise<LazyLoadResult>;

/**
* @deprecated define individual parameters as [[ParamDeclaration.dynamic]]
*/
reloadOnSearch?: boolean;
}

export interface LazyLoadResult {
states?: StateDeclaration[];
}

export interface HrefOptions {
relative?: StateOrName;
lossy?: boolean;
Expand Down
13 changes: 13 additions & 0 deletions src/state/stateBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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;
Expand All @@ -53,6 +60,11 @@ function dataBuilder(state: State) {
const getUrlBuilder = ($urlMatcherFactoryProvider: UrlMatcherFactory, root: () => State) =>
function urlBuilder(state: State) {
let stateDec: StateDeclaration = <any> 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 || {},
Expand Down Expand Up @@ -212,6 +224,7 @@ export class StateBuilder {
}

this.builders = {
name: [ nameBuilder ],
self: [ selfBuilder ],
parent: [ parentBuilder ],
data: [ dataBuilder ],
Expand Down
3 changes: 1 addition & 2 deletions src/state/stateMatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading

0 comments on commit 8ecb6c6

Please sign in to comment.