From 5a849829c42330d7e88e83e916e6e36380c97a97 Mon Sep 17 00:00:00 2001 From: vsavkin Date: Fri, 16 Sep 2016 15:08:15 -0700 Subject: [PATCH] feat(router): add router preloader to optimistically preload routes --- modules/@angular/router/src/index.ts | 2 + modules/@angular/router/src/router.ts | 9 ++ modules/@angular/router/src/router_module.ts | 29 ++-- .../@angular/router/src/router_preloader.ts | 124 +++++++++++++++++ .../@angular/router/test/integration.spec.ts | 60 +++++++- .../router/test/router_preloader.spec.ts | 129 ++++++++++++++++++ .../router/testing/router_testing_module.ts | 5 +- 7 files changed, 344 insertions(+), 14 deletions(-) create mode 100644 modules/@angular/router/src/router_preloader.ts create mode 100644 modules/@angular/router/test/router_preloader.spec.ts diff --git a/modules/@angular/router/src/index.ts b/modules/@angular/router/src/index.ts index c1e1360868..3e96abe201 100644 --- a/modules/@angular/router/src/index.ts +++ b/modules/@angular/router/src/index.ts @@ -15,7 +15,9 @@ export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Resolve} from './ export {Event, NavigationCancel, NavigationEnd, NavigationError, NavigationExtras, NavigationStart, Router, RoutesRecognized} from './router'; export {ExtraOptions, RouterModule, provideRoutes} from './router_module'; export {RouterOutletMap} from './router_outlet_map'; +export {NoPreloading, PreloadAllModules, PreloadingStrategy} from './router_preloader'; export {ActivatedRoute, ActivatedRouteSnapshot, RouterState, RouterStateSnapshot} from './router_state'; export {PRIMARY_OUTLET, Params} from './shared'; export {DefaultUrlSerializer, UrlSegment, UrlSerializer, UrlTree} from './url_tree'; + export * from './private_export' diff --git a/modules/@angular/router/src/router.ts b/modules/@angular/router/src/router.ts index faeec8b136..bcd3b53c3e 100644 --- a/modules/@angular/router/src/router.ts +++ b/modules/@angular/router/src/router.ts @@ -317,6 +317,15 @@ export class Router { this.currentRouterState = createEmptyState(this.currentUrlTree, this.rootComponentType); } + /** + * @internal + * TODO: this should be removed once the constructor of the router made internal + */ + resetRootComponentType(rootComponentType: Type): void { + this.rootComponentType = rootComponentType; + this.currentRouterState = createEmptyState(this.currentUrlTree, this.rootComponentType); + } + /** * Sets up the location change listener and performs the initial navigation. */ diff --git a/modules/@angular/router/src/router_module.ts b/modules/@angular/router/src/router_module.ts index f7c98fc2bd..63381e34c1 100644 --- a/modules/@angular/router/src/router_module.ts +++ b/modules/@angular/router/src/router_module.ts @@ -16,6 +16,7 @@ import {RouterOutlet} from './directives/router_outlet'; import {ErrorHandler, Router} from './router'; import {ROUTES} from './router_config_loader'; import {RouterOutletMap} from './router_outlet_map'; +import {NoPreloading, PreloadAllModules, PreloadingStrategy, RouterPreloader} from './router_preloader'; import {ActivatedRoute} from './router_state'; import {DefaultUrlSerializer, UrlSerializer} from './url_tree'; import {flatten} from './utils/collection'; @@ -58,8 +59,8 @@ export const ROUTER_PROVIDERS: Provider[] = [ ] }, RouterOutletMap, {provide: ActivatedRoute, useFactory: rootRoute, deps: [Router]}, - {provide: NgModuleFactoryLoader, useClass: SystemJsNgModuleLoader}, - {provide: ROUTER_CONFIGURATION, useValue: {enableTracing: false}} + {provide: NgModuleFactoryLoader, useClass: SystemJsNgModuleLoader}, RouterPreloader, NoPreloading, + PreloadAllModules, {provide: ROUTER_CONFIGURATION, useValue: {enableTracing: false}} ]; /** @@ -145,6 +146,11 @@ export class RouterModule { PlatformLocation, [new Inject(APP_BASE_HREF), new Optional()], ROUTER_CONFIGURATION ] }, + { + provide: PreloadingStrategy, + useExisting: config && config.preloadingStrategy ? config.preloadingStrategy : + NoPreloading + }, provideRouterInitializer() ] }; @@ -220,19 +226,19 @@ export interface ExtraOptions { * A custom error handler. */ errorHandler?: ErrorHandler; + + /** + * Configures a preloading strategy. See {@link PreloadAllModules}. + */ + preloadingStrategy?: any; } export function setupRouter( ref: ApplicationRef, urlSerializer: UrlSerializer, outletMap: RouterOutletMap, location: Location, injector: Injector, loader: NgModuleFactoryLoader, compiler: Compiler, config: Route[][], 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, urlSerializer, outletMap, location, injector, loader, compiler, - flatten(config)); + null, urlSerializer, outletMap, location, injector, loader, compiler, flatten(config)); if (opts.errorHandler) { r.errorHandler = opts.errorHandler; @@ -254,8 +260,11 @@ export function rootRoute(router: Router): ActivatedRoute { return router.routerState.root; } -export function initialRouterNavigation(router: Router, opts: ExtraOptions) { +export function initialRouterNavigation( + router: Router, ref: ApplicationRef, preloader: RouterPreloader, opts: ExtraOptions) { return () => { + router.resetRootComponentType(ref.componentTypes[0]); + preloader.setUpPreloading(); if (opts.initialNavigation === false) { router.setUpLocationChangeListener(); } else { @@ -269,6 +278,6 @@ export function provideRouterInitializer() { provide: APP_BOOTSTRAP_LISTENER, multi: true, useFactory: initialRouterNavigation, - deps: [Router, ROUTER_CONFIGURATION] + deps: [Router, ApplicationRef, RouterPreloader, ROUTER_CONFIGURATION] }; } diff --git a/modules/@angular/router/src/router_preloader.ts b/modules/@angular/router/src/router_preloader.ts new file mode 100644 index 0000000000..800c9a49ab --- /dev/null +++ b/modules/@angular/router/src/router_preloader.ts @@ -0,0 +1,124 @@ +/** +*@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 {Compiler, ComponentFactoryResolver, Injectable, Injector, NgModuleFactory, NgModuleFactoryLoader} from '@angular/core'; +import {Observable} from 'rxjs/Observable'; +import {Subscription} from 'rxjs/Subscription'; +import {from} from 'rxjs/observable/from'; +import {of } from 'rxjs/observable/of'; +import {_catch} from 'rxjs/operator/catch'; +import {concatMap} from 'rxjs/operator/concatMap'; +import {filter} from 'rxjs/operator/filter'; +import {map} from 'rxjs/operator/map'; +import {mergeAll} from 'rxjs/operator/mergeAll'; +import {mergeMap} from 'rxjs/operator/mergeMap'; + +import {Route, Routes} from './config'; +import {NavigationEnd, Router} from './router'; +import {RouterConfigLoader} from './router_config_loader'; + +/** + * @whatItDoes Provides a preloading strategy. + * + * @experimental + */ +export abstract class PreloadingStrategy { + abstract preload(route: Route, fn: () => Observable): Observable; +} + +/** + * @whatItDoes Provides a preloading strategy that preloads all modules as quicky as possible. + * + * @howToUse + * + * ``` + * RouteModule.forRoot(ROUTES, {preloadingStrategy: PreloadAllModules}) + * ``` + * + * @experimental + */ +export class PreloadAllModules implements PreloadingStrategy { + preload(route: Route, fn: () => Observable): Observable { + return _catch.call(fn(), () => of (null)); + } +} + +/** + * @whatItDoes Provides a preloading strategy that does not preload any modules. + * + * @description + * + * This strategy is enabled by default. + * + * @experimental + */ +export class NoPreloading implements PreloadingStrategy { + preload(route: Route, fn: () => Observable): Observable { return of (null); } +} + +/** + * The preloader optimistically loads all router configurations to + * make navigations into lazily-loaded sections of the application faster. + * + * The preloader runs in the background. When the router bootstraps, the preloader + * starts listening to all navigation events. After every such event, the preloader + * will check if any configurations can be loaded lazily. + * + * If a route is protected by `canLoad` guards, the preloaded will not load it. + */ +@Injectable() +export class RouterPreloader { + private loader: RouterConfigLoader; + private subscription: Subscription; + + constructor( + private router: Router, moduleLoader: NgModuleFactoryLoader, compiler: Compiler, + private injector: Injector, private preloadingStrategy: PreloadingStrategy) { + this.loader = new RouterConfigLoader(moduleLoader, compiler); + }; + + setUpPreloading(): void { + const navigations = filter.call(this.router.events, (e: any) => e instanceof NavigationEnd); + this.subscription = concatMap.call(navigations, () => this.preload()).subscribe((v: any) => {}); + } + + preload(): Observable { return this.processRoutes(this.injector, this.router.config); } + + ngOnDestroy() { this.subscription.unsubscribe(); } + + private processRoutes(injector: Injector, routes: Routes): Observable { + const res: Observable[] = []; + for (let c of routes) { + // we already have the config loaded, just recurce + if (c.loadChildren && !c.canLoad && (c)._loadedConfig) { + const childConfig = (c)._loadedConfig; + res.push(this.processRoutes(childConfig.injector, childConfig.routes)); + + // no config loaded, fetch the config + } else if (c.loadChildren && !c.canLoad) { + res.push(this.preloadConfig(injector, c)); + + // recurse into children + } else if (c.children) { + res.push(this.processRoutes(injector, c.children)); + } + } + return mergeAll.call(from(res)); + } + + private preloadConfig(injector: Injector, route: Route): Observable { + return this.preloadingStrategy.preload(route, () => { + const loaded = this.loader.load(injector, route.loadChildren); + return mergeMap.call(loaded, (config: any): any => { + const c: any = route; + c._loadedConfig = config; + return this.processRoutes(config.injector, config.routes); + }); + }); + } +} \ No newline at end of file diff --git a/modules/@angular/router/test/integration.spec.ts b/modules/@angular/router/test/integration.spec.ts index 4a529dc443..229e284810 100644 --- a/modules/@angular/router/test/integration.spec.ts +++ b/modules/@angular/router/test/integration.spec.ts @@ -14,7 +14,8 @@ import {Observable} from 'rxjs/Observable'; import {of } from 'rxjs/observable/of'; import {map} from 'rxjs/operator/map'; -import {ActivatedRoute, ActivatedRouteSnapshot, CanActivate, CanDeactivate, Event, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Params, Resolve, Router, RouterModule, RouterStateSnapshot, RoutesRecognized} from '../index'; +import {ActivatedRoute, ActivatedRouteSnapshot, CanActivate, CanDeactivate, Event, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Params, PreloadAllModules, PreloadingStrategy, Resolve, Router, RouterModule, RouterStateSnapshot, RoutesRecognized} from '../index'; +import {RouterPreloader} from '../src/router_preloader'; import {RouterTestingModule, SpyNgModuleFactoryLoader} from '../testing'; @@ -1413,7 +1414,7 @@ describe('Integration', () => { it('should not set the class until the first navigation succeeds', fakeAsync(() => { @Component({ template: - '' + '' }) class RootCmpWithLink { } @@ -1532,6 +1533,7 @@ describe('Integration', () => { expect(location.path()).toEqual('/lazy/loaded/child'); expect(fixture.nativeElement).toHaveText('lazy-loaded-parent [lazy-loaded-child]'); }))); + it('throws an error when forRoot() is used in a lazy context', fakeAsync(inject( [Router, Location, NgModuleFactoryLoader], @@ -1702,6 +1704,60 @@ describe('Integration', () => { recordedEvents, [[NavigationStart, '/lazy/loaded'], [NavigationError, '/lazy/loaded']]); }))); + + describe('preloading', () => { + beforeEach(() => { + TestBed.configureTestingModule( + {providers: [{provide: PreloadingStrategy, useExisting: PreloadAllModules}]}); + const preloader = TestBed.get(RouterPreloader); + preloader.setUpPreloading(); + }); + + it('should work', + fakeAsync(inject( + [Router, Location, NgModuleFactoryLoader], + (router: Router, location: Location, loader: SpyNgModuleFactoryLoader) => { + @Component({selector: 'lazy', template: 'should not show'}) + class LazyLoadedComponent { + } + + @NgModule({ + declarations: [LazyLoadedComponent], + imports: [RouterModule.forChild( + [{path: 'LoadedModule2', component: LazyLoadedComponent}])] + }) + class LoadedModule2 { + } + + @NgModule({ + imports: + [RouterModule.forChild([{path: 'LoadedModule1', loadChildren: 'expected2'}])] + }) + class LoadedModule1 { + } + + loader.stubbedModules = {expected: LoadedModule1, expected2: LoadedModule2}; + + const fixture = createRoot(router, RootCmp); + + router.resetConfig([ + {path: 'blank', component: BlankCmp}, {path: 'lazy', loadChildren: 'expected'} + ]); + + router.navigateByUrl('/blank'); + advance(fixture); + + const config: any = router.config; + const firstConfig = config[1]._loadedConfig; + expect(firstConfig).toBeDefined(); + expect(firstConfig.routes[0].path).toEqual('LoadedModule1'); + + const secondConfig = firstConfig.routes[0]._loadedConfig; + expect(secondConfig).toBeDefined(); + expect(secondConfig.routes[0].path).toEqual('LoadedModule2'); + }))); + + }); }); }); diff --git a/modules/@angular/router/test/router_preloader.spec.ts b/modules/@angular/router/test/router_preloader.spec.ts new file mode 100644 index 0000000000..f639903b74 --- /dev/null +++ b/modules/@angular/router/test/router_preloader.spec.ts @@ -0,0 +1,129 @@ +/** + * @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 {Component, NgModule, NgModuleFactoryLoader} from '@angular/core'; +import {ComponentFixture, TestBed, fakeAsync, inject, tick} from '@angular/core/testing'; + +import {Router, RouterModule} from '../index'; +import {PreloadAllModules, PreloadingStrategy, RouterPreloader} from '../src/router_preloader'; +import {RouterTestingModule, SpyNgModuleFactoryLoader} from '../testing'; + +describe('RouterPreloader', () => { + @Component({template: ''}) + class LazyLoadedCmp { + } + + @Component({}) + class BlankCmp { + } + + describe('should preload configurations', () => { + @NgModule({ + declarations: [LazyLoadedCmp], + imports: [RouterModule.forChild([{path: 'LoadedModule2', component: LazyLoadedCmp}])] + }) + class LoadedModule2 { + } + + @NgModule( + {imports: [RouterModule.forChild([{path: 'LoadedModule1', loadChildren: 'expected2'}])]}) + class LoadedModule1 { + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule.withRoutes([{path: 'lazy', loadChildren: 'expected'}])], + providers: [{provide: PreloadingStrategy, useExisting: PreloadAllModules}] + }); + }); + + it('should work', + fakeAsync(inject( + [NgModuleFactoryLoader, RouterPreloader, Router], + (loader: SpyNgModuleFactoryLoader, preloader: RouterPreloader, router: Router) => { + loader.stubbedModules = {expected: LoadedModule1, expected2: LoadedModule2}; + + preloader.preload().subscribe(() => {}); + + tick(); + + const c = router.config; + expect(c[0].loadChildren).toEqual('expected'); + + const loaded: any = (c[0])._loadedConfig.routes; + expect(loaded[0].path).toEqual('LoadedModule1'); + + const loaded2: any = (loaded[0])._loadedConfig.routes; + expect(loaded2[0].path).toEqual('LoadedModule2'); + }))); + }); + + describe('should not load configurations with canLoad guard', () => { + @NgModule({ + declarations: [LazyLoadedCmp], + imports: [RouterModule.forChild([{path: 'LoadedModule1', component: LazyLoadedCmp}])] + }) + class LoadedModule { + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule.withRoutes( + [{path: 'lazy', loadChildren: 'expected', canLoad: ['someGuard']}])], + providers: [{provide: PreloadingStrategy, useExisting: PreloadAllModules}] + }); + }); + + it('should work', + fakeAsync(inject( + [NgModuleFactoryLoader, RouterPreloader, Router], + (loader: SpyNgModuleFactoryLoader, preloader: RouterPreloader, router: Router) => { + loader.stubbedModules = {expected: LoadedModule}; + + preloader.preload().subscribe(() => {}); + + tick(); + + const c = router.config; + expect(!!((c[0])._loadedConfig)).toBe(false); + }))); + }); + + describe('should ignore errors', () => { + @NgModule({ + declarations: [LazyLoadedCmp], + imports: [RouterModule.forChild([{path: 'LoadedModule1', component: LazyLoadedCmp}])] + }) + class LoadedModule { + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule.withRoutes([ + {path: 'lazy1', loadChildren: 'expected1'}, {path: 'lazy2', loadChildren: 'expected2'} + ])], + providers: [{provide: PreloadingStrategy, useExisting: PreloadAllModules}] + }); + }); + + it('should work', + fakeAsync(inject( + [NgModuleFactoryLoader, RouterPreloader, Router], + (loader: SpyNgModuleFactoryLoader, preloader: RouterPreloader, router: Router) => { + loader.stubbedModules = {expected2: LoadedModule}; + + preloader.preload().subscribe(() => {}); + + tick(); + + const c = router.config; + expect(!!((c[0])._loadedConfig)).toBe(false); + expect(!!((c[1])._loadedConfig)).toBe(true); + }))); + }); +}); diff --git a/modules/@angular/router/testing/router_testing_module.ts b/modules/@angular/router/testing/router_testing_module.ts index ab620d7c29..60e745cdd9 100644 --- a/modules/@angular/router/testing/router_testing_module.ts +++ b/modules/@angular/router/testing/router_testing_module.ts @@ -9,10 +9,11 @@ import {Location, LocationStrategy} from '@angular/common'; import {MockLocationStrategy, SpyLocation} from '@angular/common/testing'; import {Compiler, Injectable, Injector, ModuleWithProviders, NgModule, NgModuleFactory, NgModuleFactoryLoader} from '@angular/core'; -import {Route, Router, RouterModule, RouterOutletMap, Routes, UrlSerializer, provideRoutes} from '@angular/router'; +import {NoPreloading, PreloadAllModules, PreloadingStrategy, Route, Router, RouterModule, RouterOutletMap, Routes, UrlSerializer, provideRoutes} from '@angular/router'; import {ROUTER_PROVIDERS, ROUTES, flatten} from './private_import_router'; + /** * @whatItDoes Allows to simulate the loading of ng modules in tests. * @@ -109,7 +110,7 @@ export function setupTestingRouter( UrlSerializer, RouterOutletMap, Location, NgModuleFactoryLoader, Compiler, Injector, ROUTES ] }, - provideRoutes([]) + {provide: PreloadingStrategy, useExisting: NoPreloading}, provideRoutes([]) ] }) export class RouterTestingModule {