angular-docs-cn/packages/router/test/router_preloader.spec.ts
Paul Gammans 95ad452e54 fix(router): fix load interaction of navigation and preload strategies (#40389)
Fix router to ensure that a route module is only loaded once especially
in relation to the use of preload strategies with delayed or partial
loading.

Add test to check the interaction of PreloadingStrategy and normal
router navigation under differing scenarios.
Checking:
 * Prevention of duplicate loading of modules.
   related to #26557
 * Prevention of duplicate RouteConfigLoad(Start|End) events
   related to #22842
 * Ensuring preload strategy remains active for submodules if needed
   The selected preload strategy should still decide when to load submodules
 * Possibility of memory leak with unfinished preload subscription
   related to #26557
 * Ensure that the stored loader promise is cleared so that subsequent
   load will try the fetch again.
 * Add error handle error from loadChildren
 * Ensure we handle error from with NgModule create

Fixes #26557 #22842 #26557

PR Close #40389
2021-02-16 11:28:50 -08:00

605 lines
22 KiB
TypeScript

/**
* @license
* Copyright Google LLC 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, Component, Injector, NgModule, NgModuleFactory, NgModuleFactoryLoader, NgModuleRef, Type} from '@angular/core';
import {resolveComponentResources} from '@angular/core/src/metadata/resource_loading';
import {fakeAsync, inject, TestBed, tick} from '@angular/core/testing';
import {PreloadAllModules, PreloadingStrategy, RouterPreloader} from '@angular/router';
import {BehaviorSubject, Observable, of, throwError} from 'rxjs';
import {catchError, delay, filter, switchMap, take} from 'rxjs/operators';
import {Route, RouteConfigLoadEnd, RouteConfigLoadStart, Router, RouterModule} from '../index';
import {LoadedRouterConfig} from '../src/config';
import {RouterTestingModule, SpyNgModuleFactoryLoader} from '../testing';
describe('RouterPreloader', () => {
@Component({template: ''})
class LazyLoadedCmp {
}
describe('should properly handle', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule.withRoutes(
[{path: 'lazy', loadChildren: 'expected', canLoad: ['someGuard']}])],
providers: [{provide: PreloadingStrategy, useExisting: PreloadAllModules}]
});
});
it('being destroyed before expected', () => {
const preloader: RouterPreloader = TestBed.get(RouterPreloader);
// Calling the RouterPreloader's ngOnDestroy method is done to simulate what would happen if
// the containing NgModule is destroyed.
expect(() => preloader.ngOnDestroy()).not.toThrow();
});
});
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] as any)._loadedConfig).not.toBeDefined();
})));
});
describe('should preload configurations', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule.withRoutes([{path: 'lazy', loadChildren: 'expected'}])],
providers: [{provide: PreloadingStrategy, useExisting: PreloadAllModules}]
});
});
it('should work',
fakeAsync(inject(
[NgModuleFactoryLoader, RouterPreloader, Router, NgModuleRef],
(loader: SpyNgModuleFactoryLoader, preloader: RouterPreloader, router: Router,
testModule: NgModuleRef<any>) => {
const events: Array<RouteConfigLoadStart|RouteConfigLoadEnd> = [];
@NgModule({
declarations: [LazyLoadedCmp],
imports: [RouterModule.forChild([{path: 'LoadedModule2', component: LazyLoadedCmp}])]
})
class LoadedModule2 {
}
@NgModule({
imports:
[RouterModule.forChild([{path: 'LoadedModule1', loadChildren: 'expected2'}])]
})
class LoadedModule1 {
}
router.events.subscribe(e => {
if (e instanceof RouteConfigLoadEnd || e instanceof RouteConfigLoadStart) {
events.push(e);
}
});
loader.stubbedModules = {
expected: LoadedModule1,
expected2: LoadedModule2,
};
preloader.preload().subscribe(() => {});
tick();
const c = router.config;
expect(c[0].loadChildren).toEqual('expected');
const loadedConfig: LoadedRouterConfig = (c[0] as any)._loadedConfig!;
const module: any = loadedConfig.module;
expect(loadedConfig.routes[0].path).toEqual('LoadedModule1');
expect(module._parent).toBe(testModule);
const loadedConfig2: LoadedRouterConfig =
(loadedConfig.routes[0] as any)._loadedConfig!;
const module2: any = loadedConfig2.module;
expect(loadedConfig2.routes[0].path).toEqual('LoadedModule2');
expect(module2._parent).toBe(module);
expect(events.map(e => e.toString())).toEqual([
'RouteConfigLoadStart(path: lazy)',
'RouteConfigLoadEnd(path: lazy)',
'RouteConfigLoadStart(path: LoadedModule1)',
'RouteConfigLoadEnd(path: LoadedModule1)',
]);
})));
});
describe('should support modules that have already been loaded', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule.withRoutes([{path: 'lazy', loadChildren: 'expected'}])],
providers: [{provide: PreloadingStrategy, useExisting: PreloadAllModules}]
});
});
it('should work',
fakeAsync(inject(
[NgModuleFactoryLoader, RouterPreloader, Router, NgModuleRef, Compiler],
(loader: SpyNgModuleFactoryLoader, preloader: RouterPreloader, router: Router,
testModule: NgModuleRef<any>, compiler: Compiler) => {
@NgModule()
class LoadedModule2 {
}
const module2 = compiler.compileModuleSync(LoadedModule2).create(null);
@NgModule({
imports: [RouterModule.forChild([
<Route>{
path: 'LoadedModule2',
loadChildren: 'no',
_loadedConfig: {
routes: [{path: 'LoadedModule3', loadChildren: 'expected3'}],
module: module2,
}
},
])]
})
class LoadedModule1 {
}
@NgModule({imports: [RouterModule.forChild([])]})
class LoadedModule3 {
}
loader.stubbedModules = {
expected: LoadedModule1,
expected3: LoadedModule3,
};
preloader.preload().subscribe(() => {});
tick();
const c = router.config;
const loadedConfig: LoadedRouterConfig = (c[0] as any)._loadedConfig!;
const module: any = loadedConfig.module;
expect(module._parent).toBe(testModule);
const loadedConfig2: LoadedRouterConfig =
(loadedConfig.routes[0] as any)._loadedConfig!;
const loadedConfig3: LoadedRouterConfig =
(loadedConfig2.routes[0] as any)._loadedConfig!;
const module3: any = loadedConfig3.module;
expect(module3._parent).toBe(module2);
})));
});
describe('should support preloading strategies', () => {
let delayLoadUnPaused: BehaviorSubject<string[]>;
let delayLoadObserver$: Observable<string[]>;
let events: Array<RouteConfigLoadStart|RouteConfigLoadEnd>;
const subLoadChildrenSpy = jasmine.createSpy('submodule');
const lazyLoadChildrenSpy = jasmine.createSpy('lazymodule');
const mockPreloaderFactory = (): PreloadingStrategy => {
class DelayedPreLoad implements PreloadingStrategy {
preload(route: Route, fn: () => Observable<any>): Observable<any> {
const routeName =
route.loadChildren ? (route.loadChildren as jasmine.Spy).and.identity : 'noChildren';
return delayLoadObserver$.pipe(
filter(unpauseList => unpauseList.indexOf(routeName) !== -1),
take(1),
switchMap(() => {
return fn().pipe(catchError(() => of(null)));
}),
);
}
}
return new DelayedPreLoad();
};
@NgModule({
declarations: [LazyLoadedCmp],
})
class SharedModule {
}
@NgModule({
imports: [
SharedModule, RouterModule.forChild([
{path: 'LoadedModule1', component: LazyLoadedCmp},
{path: 'sub', loadChildren: subLoadChildrenSpy}
])
]
})
class LoadedModule1 {
}
@NgModule({
imports:
[SharedModule, RouterModule.forChild([{path: 'LoadedModule2', component: LazyLoadedCmp}])]
})
class LoadedModule2 {
}
beforeEach(() => {
delayLoadUnPaused = new BehaviorSubject<string[]>([]);
delayLoadObserver$ = delayLoadUnPaused.asObservable();
subLoadChildrenSpy.calls.reset();
lazyLoadChildrenSpy.calls.reset();
TestBed.configureTestingModule({
imports:
[RouterTestingModule.withRoutes([{path: 'lazy', loadChildren: lazyLoadChildrenSpy}])],
providers: [{provide: PreloadingStrategy, useFactory: mockPreloaderFactory}]
});
events = [];
});
it('without reloading loaded modules', fakeAsync(() => {
const preloader = TestBed.inject(RouterPreloader);
const router = TestBed.inject(Router);
router.events.subscribe(e => {
if (e instanceof RouteConfigLoadEnd || e instanceof RouteConfigLoadStart) {
events.push(e);
}
});
lazyLoadChildrenSpy.and.returnValue(of(LoadedModule1));
// App start activation of preloader
preloader.preload().subscribe((x) => {});
tick();
expect(lazyLoadChildrenSpy).toHaveBeenCalledTimes(0);
// Initial navigation cause route load
router.navigateByUrl('/lazy/LoadedModule1');
tick();
expect(lazyLoadChildrenSpy).toHaveBeenCalledTimes(1);
// Secondary load or navigation should use same loaded object (
// ie this is a noop as the module should already be loaded)
delayLoadUnPaused.next(['lazymodule']);
tick();
expect(lazyLoadChildrenSpy).toHaveBeenCalledTimes(1);
expect(subLoadChildrenSpy).toHaveBeenCalledTimes(0);
expect(events.map(e => e.toString())).toEqual([
'RouteConfigLoadStart(path: lazy)', 'RouteConfigLoadEnd(path: lazy)'
]);
}));
it('and cope with the loader throwing exceptions during module load but allow retry',
fakeAsync(() => {
const preloader = TestBed.inject(RouterPreloader);
const router = TestBed.inject(Router);
router.events.subscribe(e => {
if (e instanceof RouteConfigLoadEnd || e instanceof RouteConfigLoadStart) {
events.push(e);
}
});
lazyLoadChildrenSpy.and.returnValue(
throwError('Error: Fake module load error (expectedreload)'));
preloader.preload().subscribe((x) => {});
tick();
expect(lazyLoadChildrenSpy).toHaveBeenCalledTimes(0);
delayLoadUnPaused.next(['lazymodule']);
tick();
expect(lazyLoadChildrenSpy).toHaveBeenCalledTimes(1);
lazyLoadChildrenSpy.and.returnValue(of(LoadedModule1));
router.navigateByUrl('/lazy/LoadedModule1').catch(() => {
fail('navigation should not throw');
});
tick();
expect(lazyLoadChildrenSpy).toHaveBeenCalledTimes(2);
expect(subLoadChildrenSpy).toHaveBeenCalledTimes(0);
expect(events.map(e => e.toString())).toEqual([
'RouteConfigLoadStart(path: lazy)', 'RouteConfigLoadStart(path: lazy)',
'RouteConfigLoadEnd(path: lazy)'
]);
}));
it('and cope with the loader throwing exceptions but allow retry', fakeAsync(() => {
const preloader = TestBed.inject(RouterPreloader);
const router = TestBed.inject(Router);
router.events.subscribe(e => {
if (e instanceof RouteConfigLoadEnd || e instanceof RouteConfigLoadStart) {
events.push(e);
}
});
lazyLoadChildrenSpy.and.returnValue(
throwError('Error: Fake module load error (expectedreload)'));
preloader.preload().subscribe((x) => {});
tick();
expect(lazyLoadChildrenSpy).toHaveBeenCalledTimes(0);
router.navigateByUrl('/lazy/LoadedModule1').catch((reason) => {
expect(reason).toEqual('Error: Fake module load error (expectedreload)');
});
tick();
expect(lazyLoadChildrenSpy).toHaveBeenCalledTimes(1);
lazyLoadChildrenSpy.and.returnValue(of(LoadedModule1));
router.navigateByUrl('/lazy/LoadedModule1').catch(() => {
fail('navigation should not throw');
});
tick();
expect(lazyLoadChildrenSpy).toHaveBeenCalledTimes(2);
expect(subLoadChildrenSpy).toHaveBeenCalledTimes(0);
expect(events.map(e => e.toString())).toEqual([
'RouteConfigLoadStart(path: lazy)', 'RouteConfigLoadStart(path: lazy)',
'RouteConfigLoadEnd(path: lazy)'
]);
}));
it('without autoloading loading submodules', fakeAsync(() => {
const preloader = TestBed.inject(RouterPreloader);
const router = TestBed.inject(Router);
router.events.subscribe(e => {
if (e instanceof RouteConfigLoadEnd || e instanceof RouteConfigLoadStart) {
events.push(e);
}
});
lazyLoadChildrenSpy.and.returnValue(of(LoadedModule1));
subLoadChildrenSpy.and.returnValue(of(LoadedModule2));
preloader.preload().subscribe((x) => {});
tick();
router.navigateByUrl('/lazy/LoadedModule1');
tick();
expect(lazyLoadChildrenSpy).toHaveBeenCalledTimes(1);
expect(subLoadChildrenSpy).toHaveBeenCalledTimes(0);
expect(events.map(e => e.toString())).toEqual([
'RouteConfigLoadStart(path: lazy)', 'RouteConfigLoadEnd(path: lazy)'
]);
// Release submodule to check it does in fact load
delayLoadUnPaused.next(['lazymodule', 'submodule']);
tick();
expect(lazyLoadChildrenSpy).toHaveBeenCalledTimes(1);
expect(subLoadChildrenSpy).toHaveBeenCalledTimes(1);
expect(events.map(e => e.toString())).toEqual([
'RouteConfigLoadStart(path: lazy)', 'RouteConfigLoadEnd(path: lazy)',
'RouteConfigLoadStart(path: sub)', 'RouteConfigLoadEnd(path: sub)'
]);
}));
it('and close the preload obsservable ', fakeAsync(() => {
const preloader = TestBed.inject(RouterPreloader);
const router = TestBed.inject(Router);
router.events.subscribe(e => {
if (e instanceof RouteConfigLoadEnd || e instanceof RouteConfigLoadStart) {
events.push(e);
}
});
lazyLoadChildrenSpy.and.returnValue(of(LoadedModule1));
subLoadChildrenSpy.and.returnValue(of(LoadedModule2));
const preloadSubscription = preloader.preload().subscribe((x) => {});
router.navigateByUrl('/lazy/LoadedModule1');
tick();
delayLoadUnPaused.next(['lazymodule', 'submodule']);
tick();
expect(lazyLoadChildrenSpy).toHaveBeenCalledTimes(1);
expect(subLoadChildrenSpy).toHaveBeenCalledTimes(1);
expect(preloadSubscription.closed).toBeTruthy();
}));
it('with overlapping loads from navigation and the preloader', fakeAsync(() => {
const preloader = TestBed.inject(RouterPreloader);
const router = TestBed.inject(Router);
router.events.subscribe(e => {
if (e instanceof RouteConfigLoadEnd || e instanceof RouteConfigLoadStart) {
events.push(e);
}
});
lazyLoadChildrenSpy.and.returnValue(of(LoadedModule1));
subLoadChildrenSpy.and.returnValue(of(LoadedModule2).pipe(delay(5)));
preloader.preload().subscribe((x) => {});
tick();
// Load the out modules at start of test and ensure it and only
// it is loaded
delayLoadUnPaused.next(['lazymodule']);
tick();
expect(lazyLoadChildrenSpy).toHaveBeenCalledTimes(1);
expect(events.map(e => e.toString())).toEqual([
'RouteConfigLoadStart(path: lazy)',
'RouteConfigLoadEnd(path: lazy)',
]);
// Cause the load from router to start (has 5 tick delay)
router.navigateByUrl('/lazy/sub/LoadedModule2');
tick(); // T1
// Cause the load from preloader to start
delayLoadUnPaused.next(['lazymodule', 'submodule']);
tick(); // T2
expect(lazyLoadChildrenSpy).toHaveBeenCalledTimes(1);
expect(subLoadChildrenSpy).toHaveBeenCalledTimes(1);
tick(5); // T2 to T7 enough time for mutiple loads to finish
expect(subLoadChildrenSpy).toHaveBeenCalledTimes(1);
expect(events.map(e => e.toString())).toEqual([
'RouteConfigLoadStart(path: lazy)', 'RouteConfigLoadEnd(path: lazy)',
'RouteConfigLoadStart(path: sub)', 'RouteConfigLoadEnd(path: sub)'
]);
}));
it('cope with factory fail from broken modules', fakeAsync(() => {
const preloader = TestBed.inject(RouterPreloader);
const router = TestBed.inject(Router);
router.events.subscribe(e => {
if (e instanceof RouteConfigLoadEnd || e instanceof RouteConfigLoadStart) {
events.push(e);
}
});
class BrokenModuleFactory extends NgModuleFactory<any> {
moduleType: Type<any> = LoadedModule1;
constructor() {
super();
}
create(_parentInjector: Injector|null): NgModuleRef<any> {
throw 'Error: Broken module';
}
}
lazyLoadChildrenSpy.and.returnValue(of(new BrokenModuleFactory()));
preloader.preload().subscribe((x) => {});
tick();
expect(lazyLoadChildrenSpy).toHaveBeenCalledTimes(0);
router.navigateByUrl('/lazy/LoadedModule1').catch((reason) => {
expect(reason).toEqual('Error: Broken module');
});
tick();
expect(lazyLoadChildrenSpy).toHaveBeenCalledTimes(1);
lazyLoadChildrenSpy.and.returnValue(of(LoadedModule1));
router.navigateByUrl('/lazy/LoadedModule1').catch(() => {
fail('navigation should not throw');
});
tick();
expect(lazyLoadChildrenSpy).toHaveBeenCalledTimes(2);
expect(subLoadChildrenSpy).toHaveBeenCalledTimes(0);
expect(events.map(e => e.toString())).toEqual([
'RouteConfigLoadStart(path: lazy)', 'RouteConfigLoadEnd(path: lazy)',
'RouteConfigLoadStart(path: lazy)', 'RouteConfigLoadEnd(path: lazy)'
]);
}));
});
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] as any)._loadedConfig).not.toBeDefined();
expect((c[1] as any)._loadedConfig).toBeDefined();
})));
});
describe('should copy loaded configs', () => {
const configs = [{path: 'LoadedModule1', component: LazyLoadedCmp}];
@NgModule({declarations: [LazyLoadedCmp], imports: [RouterModule.forChild(configs)]})
class LoadedModule {
}
beforeEach(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule.withRoutes([{path: 'lazy1', loadChildren: 'expected'}])],
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 as {_loadedConfig: LoadedRouterConfig}[];
expect(c[0]._loadedConfig).toBeDefined();
expect(c[0]._loadedConfig!.routes).not.toBe(configs);
expect(c[0]._loadedConfig!.routes[0]).not.toBe(configs[0]);
expect(c[0]._loadedConfig!.routes[0].component).toBe(configs[0].component);
})));
});
describe(
'should work with lazy loaded modules that don\'t provide RouterModule.forChild()', () => {
@NgModule({
declarations: [LazyLoadedCmp],
imports: [RouterModule.forChild([{path: 'LoadedModule1', component: LazyLoadedCmp}])]
})
class LoadedModule {
}
@NgModule({})
class EmptyModule {
}
beforeEach(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule.withRoutes(
[{path: 'lazyEmptyModule', loadChildren: 'expected2'}])],
providers: [{provide: PreloadingStrategy, useExisting: PreloadAllModules}]
});
});
it('should work',
fakeAsync(inject(
[NgModuleFactoryLoader, RouterPreloader],
(loader: SpyNgModuleFactoryLoader, preloader: RouterPreloader) => {
loader.stubbedModules = {expected2: EmptyModule};
preloader.preload().subscribe();
})));
});
});