From 8ebb8e44c8a5f7101ea3ebb7d815969cb942bc4c Mon Sep 17 00:00:00 2001 From: vsavkin Date: Wed, 6 Jul 2016 11:02:16 -0700 Subject: [PATCH] feat(router): add support for lazily loaded modules --- modules/@angular/router/index.ts | 2 +- .../@angular/router/src/apply_redirects.ts | 200 ++++++++++++------ .../router/src/common_router_providers.ts | 35 ++- modules/@angular/router/src/config.ts | 14 +- .../router/src/directives/router_outlet.ts | 16 +- modules/@angular/router/src/recognize.ts | 12 +- modules/@angular/router/src/router.ts | 52 +++-- .../router/src/router_config_loader.ts | 29 +++ modules/@angular/router/src/url_tree.ts | 16 -- .../@angular/router/src/utils/collection.ts | 38 ++++ .../router/test/apply_redirects.spec.ts | 38 +++- modules/@angular/router/test/config.spec.ts | 14 +- modules/@angular/router/test/router.spec.ts | 105 ++++++++- 13 files changed, 431 insertions(+), 140 deletions(-) create mode 100644 modules/@angular/router/src/router_config_loader.ts diff --git a/modules/@angular/router/index.ts b/modules/@angular/router/index.ts index 4ab9b4241e..d3565679fc 100644 --- a/modules/@angular/router/index.ts +++ b/modules/@angular/router/index.ts @@ -10,7 +10,7 @@ import {RouterLink, RouterLinkWithHref} from './src/directives/router_link'; import {RouterLinkActive} from './src/directives/router_link_active'; import {RouterOutlet} from './src/directives/router_outlet'; -export {ExtraOptions} from './src/common_router_providers'; +export {ExtraOptions, provideRoutes} from './src/common_router_providers'; export {Data, ResolveData, Route, RouterConfig} from './src/config'; export {RouterLink, RouterLinkWithHref} from './src/directives/router_link'; export {RouterLinkActive} from './src/directives/router_link_active'; diff --git a/modules/@angular/router/src/apply_redirects.ts b/modules/@angular/router/src/apply_redirects.ts index 3cf14bd031..711caa9be6 100644 --- a/modules/@angular/router/src/apply_redirects.ts +++ b/modules/@angular/router/src/apply_redirects.ts @@ -6,14 +6,20 @@ * found in the LICENSE file at https://angular.io/license */ +import 'rxjs/add/operator/first'; +import 'rxjs/add/operator/catch'; +import 'rxjs/add/operator/concatAll'; + import {Observable} from 'rxjs/Observable'; import {Observer} from 'rxjs/Observer'; import {of } from 'rxjs/observable/of'; +import {EmptyError} from 'rxjs/util/EmptyError'; import {Route, RouterConfig} from './config'; +import {RouterConfigLoader} from './router_config_loader'; import {PRIMARY_OUTLET} from './shared'; -import {UrlPathWithParams, UrlSegment, UrlTree, mapChildren} from './url_tree'; -import {merge} from './utils/collection'; +import {UrlPathWithParams, UrlSegment, UrlTree} from './url_tree'; +import {merge, waitForMap} from './utils/collection'; class NoMatch { constructor(public segment: UrlSegment = null) {} @@ -22,138 +28,192 @@ class AbsoluteRedirect { constructor(public paths: UrlPathWithParams[]) {} } -export function applyRedirects(urlTree: UrlTree, config: RouterConfig): Observable { - try { - return createUrlTree(urlTree, expandSegment(config, urlTree.root, PRIMARY_OUTLET)); - } catch (e) { - if (e instanceof AbsoluteRedirect) { - return createUrlTree( - urlTree, new UrlSegment([], {[PRIMARY_OUTLET]: new UrlSegment(e.paths, {})})); - } else if (e instanceof NoMatch) { - return new Observable( - (obs: Observer) => - obs.error(new Error(`Cannot match any routes: '${e.segment}'`))); - } else { - return new Observable((obs: Observer) => obs.error(e)); - } - } +function noMatch(segment: UrlSegment): Observable { + return new Observable((obs: Observer) => obs.error(new NoMatch(segment))); } -function createUrlTree(urlTree: UrlTree, rootCandidate: UrlSegment): Observable { +function absoluteRedirect(newPaths: UrlPathWithParams[]): Observable { + return new Observable( + (obs: Observer) => obs.error(new AbsoluteRedirect(newPaths))); +} + +export function applyRedirects( + configLoader: RouterConfigLoader, urlTree: UrlTree, config: RouterConfig): Observable { + return expandSegment(configLoader, config, urlTree.root, PRIMARY_OUTLET) + .map(rootSegment => createUrlTree(urlTree, rootSegment)) + .catch(e => { + if (e instanceof AbsoluteRedirect) { + return of (createUrlTree( + urlTree, new UrlSegment([], {[PRIMARY_OUTLET]: new UrlSegment(e.paths, {})}))); + } else if (e instanceof NoMatch) { + throw new Error(`Cannot match any routes: '${e.segment}'`); + } else { + throw e; + } + }); +} + +function createUrlTree(urlTree: UrlTree, rootCandidate: UrlSegment): UrlTree { const root = rootCandidate.pathsWithParams.length > 0 ? new UrlSegment([], {[PRIMARY_OUTLET]: rootCandidate}) : rootCandidate; - return of (new UrlTree(root, urlTree.queryParams, urlTree.fragment)); + return new UrlTree(root, urlTree.queryParams, urlTree.fragment); } -function expandSegment(routes: Route[], segment: UrlSegment, outlet: string): UrlSegment { +function expandSegment( + configLoader: RouterConfigLoader, routes: Route[], segment: UrlSegment, + outlet: string): Observable { if (segment.pathsWithParams.length === 0 && segment.hasChildren()) { - return new UrlSegment([], expandSegmentChildren(routes, segment)); + return expandSegmentChildren(configLoader, routes, segment) + .map(children => new UrlSegment([], children)); } else { - return expandPathsWithParams(segment, routes, segment.pathsWithParams, outlet, true); + return expandPathsWithParams( + configLoader, segment, routes, segment.pathsWithParams, outlet, true); } } -function expandSegmentChildren(routes: Route[], segment: UrlSegment): {[name: string]: UrlSegment} { - return mapChildren(segment, (child, childOutlet) => expandSegment(routes, child, childOutlet)); +function expandSegmentChildren( + configLoader: RouterConfigLoader, routes: Route[], + segment: UrlSegment): Observable<{[name: string]: UrlSegment}> { + return waitForMap( + segment.children, + (childOutlet, child) => expandSegment(configLoader, routes, child, childOutlet)); } function expandPathsWithParams( - segment: UrlSegment, routes: Route[], paths: UrlPathWithParams[], outlet: string, - allowRedirects: boolean): UrlSegment { - for (let r of routes) { - try { - return expandPathsWithParamsAgainstRoute(segment, routes, r, paths, outlet, allowRedirects); - } catch (e) { - if (!(e instanceof NoMatch)) throw e; + configLoader: RouterConfigLoader, segment: UrlSegment, routes: Route[], + paths: UrlPathWithParams[], outlet: string, allowRedirects: boolean): Observable { + const processRoutes = + of (...routes) + .map(r => { + return expandPathsWithParamsAgainstRoute( + configLoader, segment, routes, r, paths, outlet, allowRedirects) + .catch((e) => { + if (e instanceof NoMatch) + return of (null); + else + throw e; + }); + }) + .concatAll(); + + return processRoutes.first(s => !!s).catch((e: any, _: any): Observable => { + if (e instanceof EmptyError) { + throw new NoMatch(segment); + } else { + throw e; } - } - throw new NoMatch(segment); + }); } function expandPathsWithParamsAgainstRoute( - segment: UrlSegment, routes: Route[], route: Route, paths: UrlPathWithParams[], outlet: string, - allowRedirects: boolean): UrlSegment { - if (getOutlet(route) !== outlet) throw new NoMatch(); - if (route.redirectTo !== undefined && !allowRedirects) throw new NoMatch(); + configLoader: RouterConfigLoader, segment: UrlSegment, routes: Route[], route: Route, + paths: UrlPathWithParams[], outlet: string, allowRedirects: boolean): Observable { + if (getOutlet(route) !== outlet) return noMatch(segment); + if (route.redirectTo !== undefined && !allowRedirects) return noMatch(segment); if (route.redirectTo !== undefined) { - return expandPathsWithParamsAgainstRouteUsingRedirect(segment, routes, route, paths, outlet); + return expandPathsWithParamsAgainstRouteUsingRedirect( + configLoader, segment, routes, route, paths, outlet); } else { - return matchPathsWithParamsAgainstRoute(segment, route, paths); + return matchPathsWithParamsAgainstRoute(configLoader, segment, route, paths); } } function expandPathsWithParamsAgainstRouteUsingRedirect( - segment: UrlSegment, routes: Route[], route: Route, paths: UrlPathWithParams[], - outlet: string): UrlSegment { + configLoader: RouterConfigLoader, segment: UrlSegment, routes: Route[], route: Route, + paths: UrlPathWithParams[], outlet: string): Observable { if (route.path === '**') { return expandWildCardWithParamsAgainstRouteUsingRedirect(route); } else { return expandRegularPathWithParamsAgainstRouteUsingRedirect( - segment, routes, route, paths, outlet); + configLoader, segment, routes, route, paths, outlet); } } -function expandWildCardWithParamsAgainstRouteUsingRedirect(route: Route): UrlSegment { +function expandWildCardWithParamsAgainstRouteUsingRedirect(route: Route): Observable { const newPaths = applyRedirectCommands([], route.redirectTo, {}); if (route.redirectTo.startsWith('/')) { - throw new AbsoluteRedirect(newPaths); + return absoluteRedirect(newPaths); } else { - return new UrlSegment(newPaths, {}); + return of (new UrlSegment(newPaths, {})); } } function expandRegularPathWithParamsAgainstRouteUsingRedirect( - segment: UrlSegment, routes: Route[], route: Route, paths: UrlPathWithParams[], - outlet: string): UrlSegment { - const {consumedPaths, lastChild, positionalParamSegments} = match(segment, route, paths); + configLoader: RouterConfigLoader, segment: UrlSegment, routes: Route[], route: Route, + paths: UrlPathWithParams[], outlet: string): Observable { + const {matched, consumedPaths, lastChild, positionalParamSegments} = match(segment, route, paths); + if (!matched) return noMatch(segment); + const newPaths = applyRedirectCommands(consumedPaths, route.redirectTo, positionalParamSegments); if (route.redirectTo.startsWith('/')) { - throw new AbsoluteRedirect(newPaths); + return absoluteRedirect(newPaths); } else { return expandPathsWithParams( - segment, routes, newPaths.concat(paths.slice(lastChild)), outlet, false); + configLoader, segment, routes, newPaths.concat(paths.slice(lastChild)), outlet, false); } } function matchPathsWithParamsAgainstRoute( - rawSegment: UrlSegment, route: Route, paths: UrlPathWithParams[]): UrlSegment { + configLoader: RouterConfigLoader, rawSegment: UrlSegment, route: Route, + paths: UrlPathWithParams[]): Observable { if (route.path === '**') { - return new UrlSegment(paths, {}); + return of (new UrlSegment(paths, {})); + } else { - const {consumedPaths, lastChild} = match(rawSegment, route, paths); - const childConfig = route.children ? route.children : []; + const {matched, consumedPaths, lastChild} = match(rawSegment, route, paths); + if (!matched) return noMatch(rawSegment); + const rawSlicedPath = paths.slice(lastChild); - const {segment, slicedPath} = split(rawSegment, consumedPaths, rawSlicedPath, childConfig); + return getChildConfig(configLoader, route).mergeMap(childConfig => { + const {segment, slicedPath} = split(rawSegment, consumedPaths, rawSlicedPath, childConfig); - if (slicedPath.length === 0 && segment.hasChildren()) { - const children = expandSegmentChildren(childConfig, segment); - return new UrlSegment(consumedPaths, children); + if (slicedPath.length === 0 && segment.hasChildren()) { + return expandSegmentChildren(configLoader, childConfig, segment) + .map(children => new UrlSegment(consumedPaths, children)); - } else if (childConfig.length === 0 && slicedPath.length === 0) { - return new UrlSegment(consumedPaths, {}); + } else if (childConfig.length === 0 && slicedPath.length === 0) { + return of (new UrlSegment(consumedPaths, {})); - } else { - const cs = expandPathsWithParams(segment, childConfig, slicedPath, PRIMARY_OUTLET, true); - return new UrlSegment(consumedPaths.concat(cs.pathsWithParams), cs.children); - } + } else { + return expandPathsWithParams( + configLoader, segment, childConfig, slicedPath, PRIMARY_OUTLET, true) + .map(cs => new UrlSegment(consumedPaths.concat(cs.pathsWithParams), cs.children)); + } + }); + } +} + +function getChildConfig(configLoader: RouterConfigLoader, route: Route): Observable { + if (route.children) { + return of (route.children); + } else if (route.mountChildren) { + return configLoader.load(route.mountChildren).map(r => { + (route)._loadedConfig = r; + return r.routes; + }); + } else { + return of ([]); } } function match(segment: UrlSegment, route: Route, paths: UrlPathWithParams[]): { + matched: boolean, consumedPaths: UrlPathWithParams[], lastChild: number, positionalParamSegments: {[k: string]: UrlPathWithParams} } { + const noMatch = + {matched: false, consumedPaths: [], lastChild: 0, positionalParamSegments: {}}; if (route.path === '') { if ((route.terminal || route.pathMatch === 'full') && (segment.hasChildren() || paths.length > 0)) { - throw new NoMatch(); + return {matched: false, consumedPaths: [], lastChild: 0, positionalParamSegments: {}}; } else { - return {consumedPaths: [], lastChild: 0, positionalParamSegments: {}}; + return {matched: true, consumedPaths: [], lastChild: 0, positionalParamSegments: {}}; } } @@ -165,13 +225,13 @@ function match(segment: UrlSegment, route: Route, paths: UrlPathWithParams[]): { let currentIndex = 0; for (let i = 0; i < parts.length; ++i) { - if (currentIndex >= paths.length) throw new NoMatch(); + if (currentIndex >= paths.length) return noMatch; const current = paths[currentIndex]; const p = parts[i]; const isPosParam = p.startsWith(':'); - if (!isPosParam && p !== current.path) throw new NoMatch(); + if (!isPosParam && p !== current.path) return noMatch; if (isPosParam) { positionalParamSegments[p.substring(1)] = current; } @@ -180,10 +240,10 @@ function match(segment: UrlSegment, route: Route, paths: UrlPathWithParams[]): { } if (route.terminal && (segment.hasChildren() || currentIndex < paths.length)) { - throw new NoMatch(); + return {matched: false, consumedPaths: [], lastChild: 0, positionalParamSegments: {}}; } - return {consumedPaths, lastChild: currentIndex, positionalParamSegments}; + return {matched: true, consumedPaths, lastChild: currentIndex, positionalParamSegments}; } function applyRedirectCommands( diff --git a/modules/@angular/router/src/common_router_providers.ts b/modules/@angular/router/src/common_router_providers.ts index 9e7381557f..4f7f3c3b4a 100644 --- a/modules/@angular/router/src/common_router_providers.ts +++ b/modules/@angular/router/src/common_router_providers.ts @@ -7,7 +7,7 @@ */ import {Location, LocationStrategy, PathLocationStrategy} from '@angular/common'; -import {APP_INITIALIZER, ApplicationRef, ComponentResolver, Injector, OpaqueToken} from '@angular/core'; +import {APP_INITIALIZER, AppModuleFactoryLoader, ApplicationRef, ComponentResolver, Injector, OpaqueToken, SystemJsAppModuleLoader} from '@angular/core'; import {RouterConfig} from './config'; import {Router} from './router'; @@ -25,14 +25,14 @@ export interface ExtraOptions { enableTracing?: boolean; } export function setupRouter( ref: ApplicationRef, resolver: ComponentResolver, urlSerializer: UrlSerializer, - outletMap: RouterOutletMap, location: Location, injector: Injector, config: RouterConfig, - opts: ExtraOptions) { + outletMap: RouterOutletMap, location: Location, injector: Injector, + loader: AppModuleFactoryLoader, config: RouterConfig, opts: ExtraOptions) { if (ref.componentTypes.length == 0) { throw new Error('Bootstrap at least one component before injecting Router.'); } const componentType = ref.componentTypes[0]; - const r = - new Router(componentType, resolver, urlSerializer, outletMap, location, injector, config); + const r = new Router( + componentType, resolver, urlSerializer, outletMap, location, injector, loader, config); ref.registerDisposeListener(() => r.dispose()); if (opts.enableTracing) { @@ -93,7 +93,7 @@ export function provideRouter(_config: RouterConfig, _opts: ExtraOptions): any[] useFactory: setupRouter, deps: [ ApplicationRef, ComponentResolver, UrlSerializer, RouterOutletMap, Location, Injector, - ROUTER_CONFIG, ROUTER_OPTIONS + AppModuleFactoryLoader, ROUTER_CONFIG, ROUTER_OPTIONS ] }, @@ -101,6 +101,27 @@ export function provideRouter(_config: RouterConfig, _opts: ExtraOptions): any[] {provide: ActivatedRoute, useFactory: (r: Router) => r.routerState.root, deps: [Router]}, // Trigger initial navigation - {provide: APP_INITIALIZER, multi: true, useFactory: setupRouterInitializer, deps: [Injector]} + {provide: APP_INITIALIZER, multi: true, useFactory: setupRouterInitializer, deps: [Injector]}, + {provide: AppModuleFactoryLoader, useClass: SystemJsAppModuleLoader} ]; } + +/** + * Router configuration. + * + * ### Example + * + * ``` + * @AppModule({providers: [ + * provideRoutes([{path: 'home', component: Home}]) + * ]}) + * class LazyLoadedModule { + * // ... + * } + * ``` + * + * @experimental + */ +export function provideRoutes(config: RouterConfig): any { + return {provide: ROUTER_CONFIG, useValue: config}; +} \ No newline at end of file diff --git a/modules/@angular/router/src/config.ts b/modules/@angular/router/src/config.ts index 89775f55fb..a84e9745dc 100644 --- a/modules/@angular/router/src/config.ts +++ b/modules/@angular/router/src/config.ts @@ -267,6 +267,7 @@ export interface Route { data?: Data; resolve?: ResolveData; children?: Route[]; + mountChildren?: string; } export function validateConfig(config: RouterConfig): void { @@ -278,13 +279,22 @@ function validateNode(route: Route): void { throw new Error( `Invalid configuration of route '${route.path}': redirectTo and children cannot be used together`); } + if (!!route.redirectTo && !!route.mountChildren) { + throw new Error( + `Invalid configuration of route '${route.path}': redirectTo and mountChildren cannot be used together`); + } + if (!!route.children && !!route.mountChildren) { + throw new Error( + `Invalid configuration of route '${route.path}': children and mountChildren cannot be used together`); + } if (!!route.redirectTo && !!route.component) { throw new Error( `Invalid configuration of route '${route.path}': redirectTo and component cannot be used together`); } - if (route.redirectTo === undefined && !route.component && !route.children) { + if (route.redirectTo === undefined && !route.component && !route.children && + !route.mountChildren) { throw new Error( - `Invalid configuration of route '${route.path}': component, redirectTo, children must be provided`); + `Invalid configuration of route '${route.path}': component, redirectTo, children, mountChildren must be provided`); } if (route.path === undefined) { throw new Error(`Invalid route configuration: routes must have path specified`); diff --git a/modules/@angular/router/src/directives/router_outlet.ts b/modules/@angular/router/src/directives/router_outlet.ts index 01cb65511e..9cfac24aba 100644 --- a/modules/@angular/router/src/directives/router_outlet.ts +++ b/modules/@angular/router/src/directives/router_outlet.ts @@ -33,7 +33,7 @@ export class RouterOutlet { constructor( parentOutletMap: RouterOutletMap, private location: ViewContainerRef, - private componentFactoryResolver: ComponentFactoryResolver, @Attribute('name') name: string) { + private resolver: ComponentFactoryResolver, @Attribute('name') name: string) { parentOutletMap.registerOutlet(name ? name : PRIMARY_OUTLET, this); } @@ -55,8 +55,8 @@ export class RouterOutlet { } activate( - activatedRoute: ActivatedRoute, providers: ResolvedReflectiveProvider[], - outletMap: RouterOutletMap): void { + activatedRoute: ActivatedRoute, loadedResolver: ComponentFactoryResolver, + providers: ResolvedReflectiveProvider[], outletMap: RouterOutletMap): void { this.outletMap = outletMap; this._activatedRoute = activatedRoute; @@ -65,9 +65,13 @@ export class RouterOutlet { let factory: ComponentFactory; try { - factory = typeof component === 'string' ? - snapshot._resolvedComponentFactory : - this.componentFactoryResolver.resolveComponentFactory(component); + if (typeof component === 'string') { + factory = snapshot._resolvedComponentFactory; + } else if (loadedResolver) { + factory = loadedResolver.resolveComponentFactory(component); + } else { + factory = this.resolver.resolveComponentFactory(component); + } } catch (e) { if (!(e instanceof NoComponentFactoryError)) throw e; diff --git a/modules/@angular/router/src/recognize.ts b/modules/@angular/router/src/recognize.ts index a3d1b2e38e..63e5a5880d 100644 --- a/modules/@angular/router/src/recognize.ts +++ b/modules/@angular/router/src/recognize.ts @@ -122,7 +122,7 @@ function processPathsWithParamsAgainstRoute( const {consumedPaths, parameters, lastChild} = match(rawSegment, route, paths); const rawSlicedPath = paths.slice(lastChild); - const childConfig = route.children ? route.children : []; + const childConfig = getChildConfig(route); const newInherited = route.component ? InheritedFromParent.empty : new InheritedFromParent(inherited, parameters, getData(route), newInheritedResolve); @@ -149,6 +149,16 @@ function processPathsWithParamsAgainstRoute( } } +function getChildConfig(route: Route): Route[] { + if (route.children) { + return route.children; + } else if (route.mountChildren) { + return (route)._loadedConfig.routes; + } else { + return []; + } +} + function match(segment: UrlSegment, route: Route, paths: UrlPathWithParams[]) { if (route.path === '') { if ((route.terminal || route.pathMatch === 'full') && diff --git a/modules/@angular/router/src/router.ts b/modules/@angular/router/src/router.ts index 3a5652c372..fc579e6299 100644 --- a/modules/@angular/router/src/router.ts +++ b/modules/@angular/router/src/router.ts @@ -15,24 +15,25 @@ import 'rxjs/add/observable/from'; import 'rxjs/add/observable/forkJoin'; import {Location} from '@angular/common'; -import {ComponentResolver, Injector, ReflectiveInjector, Type} from '@angular/core'; +import {AppModuleFactoryLoader, ComponentFactoryResolver, ComponentResolver, Injector, ReflectiveInjector, Type} from '@angular/core'; import {Observable} from 'rxjs/Observable'; import {Subject} from 'rxjs/Subject'; import {Subscription} from 'rxjs/Subscription'; import {of } from 'rxjs/observable/of'; import {applyRedirects} from './apply_redirects'; -import {Data, ResolveData, RouterConfig, validateConfig} from './config'; +import {ResolveData, RouterConfig, validateConfig} from './config'; import {createRouterState} from './create_router_state'; import {createUrlTree} from './create_url_tree'; import {RouterOutlet} from './directives/router_outlet'; import {recognize} from './recognize'; import {resolve} from './resolve'; +import {RouterConfigLoader} from './router_config_loader'; import {RouterOutletMap} from './router_outlet_map'; -import {ActivatedRoute, ActivatedRouteSnapshot, InheritedResolve, RouterState, RouterStateSnapshot, advanceActivatedRoute, createEmptyState} from './router_state'; +import {ActivatedRoute, ActivatedRouteSnapshot, RouterState, RouterStateSnapshot, advanceActivatedRoute, createEmptyState} from './router_state'; import {PRIMARY_OUTLET, Params} from './shared'; import {UrlSerializer, UrlTree, createEmptyUrlTree} from './url_tree'; -import {forEach, merge, shallowEqual} from './utils/collection'; +import {forEach, merge, shallowEqual, waitForMap} from './utils/collection'; import {TreeNode} from './utils/tree'; export interface NavigationExtras { @@ -124,6 +125,7 @@ export class Router { private navigationId: number = 0; private config: RouterConfig; private futureUrlTree: UrlTree; + private configLoader: RouterConfigLoader; /** * Creates the router service. @@ -131,11 +133,13 @@ export class Router { constructor( private rootComponentType: Type, private resolver: ComponentResolver, private urlSerializer: UrlSerializer, private outletMap: RouterOutletMap, - private location: Location, private injector: Injector, config: RouterConfig) { + private location: Location, private injector: Injector, loader: AppModuleFactoryLoader, + config: RouterConfig) { this.resetConfig(config); this.routerEvents = new Subject(); this.currentUrlTree = createEmptyUrlTree(); this.futureUrlTree = this.currentUrlTree; + this.configLoader = new RouterConfigLoader(loader); this.currentRouterState = createEmptyState(this.currentUrlTree, this.rootComponentType); } @@ -310,7 +314,7 @@ export class Router { let state: RouterState; let navigationIsSuccessful: boolean; let preActivation: PreActivation; - applyRedirects(url, this.config) + applyRedirects(this.configLoader, url, this.config) .mergeMap(u => { this.futureUrlTree = u; return recognize( @@ -555,20 +559,11 @@ class PreActivation { } private resolveNode(resolve: ResolveData, future: ActivatedRouteSnapshot): Observable { - const resolvingObs: Observable[] = []; - const resolvedData: {[k: string]: any} = {}; - forEach(resolve, (v: any, k: string) => { + return waitForMap(resolve, (k, v) => { const resolver = this.injector.get(v); - const obs = resolver.resolve ? wrapIntoObservable(resolver.resolve(future, this.future)) : - wrapIntoObservable(resolver(future, this.future)); - resolvingObs.push(obs.map((_: any) => { resolvedData[k] = _; })); + return resolver.resolve ? wrapIntoObservable(resolver.resolve(future, this.future)) : + wrapIntoObservable(resolver(future, this.future)); }); - - if (resolvingObs.length > 0) { - return Observable.forkJoin(resolvingObs).map(r => resolvedData); - } else { - return of (resolvedData); - } } } @@ -656,11 +651,22 @@ class ActivateRoutes { private placeComponentIntoOutlet( outletMap: RouterOutletMap, future: ActivatedRoute, outlet: RouterOutlet): void { - const resolved = ReflectiveInjector.resolve([ - {provide: ActivatedRoute, useValue: future}, - {provide: RouterOutletMap, useValue: outletMap} - ]); - outlet.activate(future, resolved, outletMap); + const resolved = [{provide: ActivatedRoute, useValue: future}, { + provide: RouterOutletMap, + useValue: outletMap + }]; + + const parentFuture = this.futureState.parent(future); // find the closest parent? + const config = parentFuture ? parentFuture.snapshot._routeConfig : null; + let loadedFactoryResolver: ComponentFactoryResolver = null; + + if (config && (config)._loadedConfig) { + const loadedResolver = (config)._loadedConfig.factoryResolver; + loadedFactoryResolver = loadedResolver; + resolved.push({provide: ComponentFactoryResolver, useValue: loadedResolver}); + }; + + outlet.activate(future, loadedFactoryResolver, ReflectiveInjector.resolve(resolved), outletMap); } private deactivateOutletAndItChildren(outlet: RouterOutlet): void { diff --git a/modules/@angular/router/src/router_config_loader.ts b/modules/@angular/router/src/router_config_loader.ts new file mode 100644 index 0000000000..67e0b97879 --- /dev/null +++ b/modules/@angular/router/src/router_config_loader.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {AppModuleFactoryLoader, AppModuleRef, ComponentFactoryResolver} from '@angular/core'; +import {Observable} from 'rxjs/Observable'; +import {fromPromise} from 'rxjs/observable/fromPromise'; + +import {ROUTER_CONFIG} from './common_router_providers'; +import {Route} from './config'; + +export class LoadedRouterConfig { + constructor(public routes: Route[], public factoryResolver: ComponentFactoryResolver) {} +} + +export class RouterConfigLoader { + constructor(private loader: AppModuleFactoryLoader) {} + + load(path: string): Observable { + return fromPromise(this.loader.load(path).then(r => { + const ref = r.create(); + return new LoadedRouterConfig(ref.injector.get(ROUTER_CONFIG), ref.componentFactoryResolver); + })); + } +} \ No newline at end of file diff --git a/modules/@angular/router/src/url_tree.ts b/modules/@angular/router/src/url_tree.ts index b78fb00e53..34571b1f81 100644 --- a/modules/@angular/router/src/url_tree.ts +++ b/modules/@angular/router/src/url_tree.ts @@ -135,22 +135,6 @@ export function equalPath(a: UrlPathWithParams[], b: UrlPathWithParams[]): boole return true; } -export function mapChildren(segment: UrlSegment, fn: (v: UrlSegment, k: string) => UrlSegment): - {[name: string]: UrlSegment} { - const newChildren: {[name: string]: UrlSegment} = {}; - forEach(segment.children, (child: UrlSegment, childOutlet: string) => { - if (childOutlet === PRIMARY_OUTLET) { - newChildren[childOutlet] = fn(child, childOutlet); - } - }); - forEach(segment.children, (child: UrlSegment, childOutlet: string) => { - if (childOutlet !== PRIMARY_OUTLET) { - newChildren[childOutlet] = fn(child, childOutlet); - } - }); - return newChildren; -} - export function mapChildrenIntoArray( segment: UrlSegment, fn: (v: UrlSegment, k: string) => T[]): T[] { let res: T[] = []; diff --git a/modules/@angular/router/src/utils/collection.ts b/modules/@angular/router/src/utils/collection.ts index b765c9747b..af5f3737fe 100644 --- a/modules/@angular/router/src/utils/collection.ts +++ b/modules/@angular/router/src/utils/collection.ts @@ -6,6 +6,14 @@ * found in the LICENSE file at https://angular.io/license */ +import 'rxjs/add/operator/concatAll'; +import 'rxjs/add/operator/last'; + +import {Observable} from 'rxjs/Observable'; +import {of } from 'rxjs/observable/of'; + +import {PRIMARY_OUTLET} from '../shared'; + export function shallowEqualArrays(a: any[], b: any[]): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; ++i) { @@ -77,4 +85,34 @@ export function forEach( callback(map[prop], prop); } } +} + +export function waitForMap( + obj: {[k: string]: A}, fn: (k: string, a: A) => Observable): Observable<{[k: string]: B}> { + const waitFor: Observable[] = []; + const res: {[k: string]: B} = {}; + + forEach(obj, (a: A, k: string) => { + if (k === PRIMARY_OUTLET) { + waitFor.push(fn(k, a).map((_: B) => { + res[k] = _; + return _; + })); + } + }); + + forEach(obj, (a: A, k: string) => { + if (k !== PRIMARY_OUTLET) { + waitFor.push(fn(k, a).map((_: B) => { + res[k] = _; + return _; + })); + } + }); + + if (waitFor.length > 0) { + return of (...waitFor).concatAll().last().map((last) => res); + } else { + return of (res); + } } \ No newline at end of file diff --git a/modules/@angular/router/test/apply_redirects.spec.ts b/modules/@angular/router/test/apply_redirects.spec.ts index d7c73c5467..dd2a95c400 100644 --- a/modules/@angular/router/test/apply_redirects.spec.ts +++ b/modules/@angular/router/test/apply_redirects.spec.ts @@ -1,7 +1,10 @@ +import {Observable} from 'rxjs/Observable'; +import {of } from 'rxjs/observable/of'; + import {applyRedirects} from '../src/apply_redirects'; import {RouterConfig} from '../src/config'; +import {LoadedRouterConfig} from '../src/router_config_loader'; import {DefaultUrlSerializer, UrlSegment, UrlTree, equalPathsWithParams} from '../src/url_tree'; -import {TreeNode} from '../src/utils/tree'; describe('applyRedirects', () => { it('should return the same url tree when no redirects', () => { @@ -26,7 +29,7 @@ describe('applyRedirects', () => { }); it('should throw when cannot handle a positional parameter', () => { - applyRedirects(tree('/a/1'), [ + applyRedirects(null, tree('/a/1'), [ {path: 'a/:id', redirectTo: 'a/:other'} ]).subscribe(() => {}, (e) => { expect(e.message).toEqual('Cannot redirect to \'a/:other\'. Cannot find \':other\'.'); @@ -128,6 +131,31 @@ describe('applyRedirects', () => { '/a/b/1', (t: UrlTree) => { compareTrees(t, tree('/absolute/1')); }); }); + describe('lazy loading', () => { + it('should load config on demand', () => { + const loadedConfig = + new LoadedRouterConfig([{path: 'b', component: ComponentB}], 'stubFactoryResolver'); + const loader = {load: (p: any) => of (loadedConfig)}; + const config = [{path: 'a', component: ComponentA, mountChildren: 'children'}]; + + applyRedirects(loader, tree('a/b'), config).forEach(r => { + compareTrees(r, tree('/a/b')); + expect((config[0])._loadedConfig).toBe(loadedConfig); + }); + }); + + it('should handle the case when the loader errors', () => { + const loader = { + load: (p: any) => new Observable((obs: any) => obs.error(new Error('Loading Error'))) + }; + const config = [{path: 'a', component: ComponentA, mountChildren: 'children'}]; + + applyRedirects(loader, tree('a/b'), config).subscribe(() => {}, (e) => { + expect(e.message).toEqual('Loading Error'); + }); + }); + }); + describe('empty paths', () => { it('redirect from an empty path should work (local redirect)', () => { checkRedirect( @@ -171,7 +199,7 @@ describe('applyRedirects', () => { {path: '', redirectTo: 'a', pathMatch: 'full'} ]; - applyRedirects(tree('b'), config) + applyRedirects(null, tree('b'), config) .subscribe( (_) => { throw 'Should not be reached'; }, e => { expect(e.message).toEqual('Cannot match any routes: \'b\''); }); @@ -301,7 +329,7 @@ describe('applyRedirects', () => { ] }]; - applyRedirects(tree('a/(d//aux:e)'), config) + applyRedirects(null, tree('a/(d//aux:e)'), config) .subscribe( (_) => { throw 'Should not be reached'; }, e => { expect(e.message).toEqual('Cannot match any routes: \'a\''); }); @@ -311,7 +339,7 @@ describe('applyRedirects', () => { }); function checkRedirect(config: RouterConfig, url: string, callback: any): void { - applyRedirects(tree(url), config).subscribe(callback, e => { throw e; }); + applyRedirects(null, tree(url), config).subscribe(callback, e => { throw e; }); } function tree(url: string): UrlTree { diff --git a/modules/@angular/router/test/config.spec.ts b/modules/@angular/router/test/config.spec.ts index 7bd5d0d6eb..a86aad9df5 100644 --- a/modules/@angular/router/test/config.spec.ts +++ b/modules/@angular/router/test/config.spec.ts @@ -15,6 +15,18 @@ describe('config', () => { `Invalid configuration of route 'a': redirectTo and children cannot be used together`); }); + it('should throw when redirectTo and mountChildren are used together', () => { + expect(() => { validateConfig([{path: 'a', redirectTo: 'b', mountChildren: 'value'}]); }) + .toThrowError( + `Invalid configuration of route 'a': redirectTo and mountChildren cannot be used together`); + }); + + it('should throw when children and mountChildren are used together', () => { + expect(() => { validateConfig([{path: 'a', children: [], mountChildren: 'value'}]); }) + .toThrowError( + `Invalid configuration of route 'a': children and mountChildren cannot be used together`); + }); + it('should throw when component and redirectTo are used together', () => { expect(() => { validateConfig([{path: 'a', component: ComponentA, redirectTo: 'b'}]); }) .toThrowError( @@ -30,7 +42,7 @@ describe('config', () => { it('should throw when none of component and children or direct are missing', () => { expect(() => { validateConfig([{path: 'a'}]); }) .toThrowError( - `Invalid configuration of route 'a': component, redirectTo, children must be provided`); + `Invalid configuration of route 'a': component, redirectTo, children, mountChildren must be provided`); }); it('should throw when path starts with a slash', () => { diff --git a/modules/@angular/router/test/router.spec.ts b/modules/@angular/router/test/router.spec.ts index 46ec0a7ce8..f8aca4656c 100644 --- a/modules/@angular/router/test/router.spec.ts +++ b/modules/@angular/router/test/router.spec.ts @@ -4,14 +4,14 @@ import {Location, LocationStrategy} from '@angular/common'; import {SpyLocation} from '@angular/common/testing'; import {MockLocationStrategy} from '@angular/common/testing/mock_location_strategy'; import {ComponentFixture, TestComponentBuilder} from '@angular/compiler/testing'; -import {Component, Injector} from '@angular/core'; +import {AppModule, AppModuleFactory, AppModuleFactoryLoader, Compiler, Component, Injectable, Injector, Type} from '@angular/core'; import {ComponentResolver} from '@angular/core'; import {beforeEach, beforeEachProviders, ddescribe, describe, fakeAsync, iit, inject, it, tick, xdescribe, xit} from '@angular/core/testing'; import {expect} from '@angular/platform-browser/testing/matchers'; import {Observable} from 'rxjs/Observable'; import {of } from 'rxjs/observable/of'; -import {ActivatedRoute, ActivatedRouteSnapshot, CanActivate, CanDeactivate, DefaultUrlSerializer, Event, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Params, ROUTER_DIRECTIVES, Resolve, Router, RouterConfig, RouterOutletMap, RouterStateSnapshot, RoutesRecognized, UrlSerializer} from '../index'; +import {ActivatedRoute, ActivatedRouteSnapshot, CanActivate, CanDeactivate, DefaultUrlSerializer, Event, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Params, ROUTER_DIRECTIVES, Resolve, Router, RouterConfig, RouterOutletMap, RouterStateSnapshot, RoutesRecognized, UrlSerializer, provideRoutes} from '../index'; describe('Integration', () => { @@ -26,13 +26,18 @@ describe('Integration', () => { {provide: LocationStrategy, useClass: MockLocationStrategy}, { provide: Router, - useFactory: (resolver: ComponentResolver, urlSerializer: UrlSerializer, - outletMap: RouterOutletMap, location: Location, injector: Injector) => { - return new Router( - RootCmp, resolver, urlSerializer, outletMap, location, injector, config); - }, - deps: [ComponentResolver, UrlSerializer, RouterOutletMap, Location, Injector] + useFactory: + (resolver: ComponentResolver, urlSerializer: UrlSerializer, outletMap: RouterOutletMap, + location: Location, loader: AppModuleFactoryLoader, injector: Injector) => { + return new Router( + RootCmp, resolver, urlSerializer, outletMap, location, injector, loader, config); + }, + deps: [ + ComponentResolver, UrlSerializer, RouterOutletMap, Location, AppModuleFactoryLoader, + Injector + ] }, + {provide: AppModuleFactoryLoader, useClass: SpyAppModuleFactoryLoader}, {provide: ActivatedRoute, useFactory: (r: Router) => r.routerState.root, deps: [Router]}, ]; }); @@ -713,6 +718,8 @@ describe('Integration', () => { describe('should not activate a route when CanActivate returns false', () => { beforeEachProviders(() => [{provide: 'alwaysFalse', useValue: (a: any, b: any) => false}]); + // handle errors + it('works', fakeAsync(inject( [Router, TestComponentBuilder, Location], @@ -1084,8 +1091,90 @@ describe('Integration', () => { }))); }); + + describe('lazy loading', () => { + it('works', fakeAsync(inject( + [Router, TestComponentBuilder, Location, AppModuleFactoryLoader], + (router: Router, tcb: TestComponentBuilder, location: Location, + loader: AppModuleFactoryLoader) => { + @Component({ + selector: 'lazy', + template: 'lazy-loaded-parent {}', + directives: ROUTER_DIRECTIVES + }) + class ParentLazyLoadedComponent { + } + + @Component({selector: 'lazy', template: 'lazy-loaded-child'}) + class ChildLazyLoadedComponent { + } + + @AppModule({ + providers: [provideRoutes([{ + path: 'loaded', + component: ParentLazyLoadedComponent, + children: [{path: 'child', component: ChildLazyLoadedComponent}] + }])], + precompile: [ParentLazyLoadedComponent, ChildLazyLoadedComponent] + }) + class LoadedModule { + } + (loader).expectedPath = 'expected'; + (loader).expected = LoadedModule; + + const fixture = createRoot(tcb, router, RootCmp); + + router.resetConfig([{path: 'lazy', mountChildren: 'expected'}]); + + router.navigateByUrl('/lazy/loaded/child'); + advance(fixture); + + expect(location.path()).toEqual('/lazy/loaded/child'); + expect(fixture.debugElement.nativeElement) + .toHaveText('lazy-loaded-parent {lazy-loaded-child}'); + }))); + + it('error emit an error when cannot load a config', + fakeAsync(inject( + [Router, TestComponentBuilder, Location, AppModuleFactoryLoader], + (router: Router, tcb: TestComponentBuilder, location: Location, + loader: AppModuleFactoryLoader) => { + (loader).expectedPath = 'expected'; + const fixture = createRoot(tcb, router, RootCmp); + + router.resetConfig([{path: 'lazy', mountChildren: 'invalid'}]); + + const recordedEvents: any = []; + router.events.forEach(e => recordedEvents.push(e)); + + router.navigateByUrl('/lazy/loaded').catch(s => {}) + advance(fixture); + + expect(location.path()).toEqual('/'); + + expectEvents( + recordedEvents, + [[NavigationStart, '/lazy/loaded'], [NavigationError, '/lazy/loaded']]); + }))); + }); }); +@Injectable() +class SpyAppModuleFactoryLoader implements AppModuleFactoryLoader { + public expected: any; + public expectedPath: string; + + constructor(private compiler: Compiler) {} + + load(path: string): Promise> { + if (path === this.expectedPath) { + return this.compiler.compileAppModuleAsync(this.expected); + } else { + return Promise.reject(new Error('boom')); + } + } +} + function expectEvents(events: Event[], pairs: any[]) { for (let i = 0; i < events.length; ++i) { expect((events[i].constructor).name).toBe(pairs[i][0].name);