fix(router): support lazy loading for empty path named outlets (#38379)

In general, the router only matches and loads a single Route config tree. However,
named outlets with empty paths are a special case where the router can
and should actually match two different `Route`s and ensure that the
modules are loaded for each match.

This change updates the "ApplyRedirects" stage to ensure that named
outlets with empty paths finish loading their configs before proceeding
to the next stage in the routing pipe. This is necessary because if the
named outlet has `loadChildren` but the associated lazy config is not loaded
before following stages attempt to match and activate relevant `Route`s,
an error will occur.

fixes #12842

PR Close #38379
This commit is contained in:
Andrew Scott 2020-08-06 11:48:37 -07:00 committed by Misko Hevery
parent 9ad69c1503
commit 7ad32649c0
3 changed files with 171 additions and 27 deletions

View File

@ -39,7 +39,7 @@
"master": {
"uncompressed": {
"runtime-es2015": 2289,
"main-es2015": 245351,
"main-es2015": 245885,
"polyfills-es2015": 36938,
"5-es2015": 751
}

View File

@ -8,7 +8,7 @@
import {Injector, NgModuleRef} from '@angular/core';
import {defer, EmptyError, Observable, Observer, of} from 'rxjs';
import {catchError, concatAll, first, map, mergeMap, tap} from 'rxjs/operators';
import {catchError, first, map, mergeMap, switchMap, tap} from 'rxjs/operators';
import {LoadedRouterConfig, Route, Routes} from './config';
import {CanLoadFn} from './interfaces';
@ -148,20 +148,38 @@ class ApplyRedirects {
ngModule: NgModuleRef<any>, segmentGroup: UrlSegmentGroup, routes: Route[],
segments: UrlSegment[], outlet: string,
allowRedirects: boolean): Observable<UrlSegmentGroup> {
return of(...routes).pipe(
map((r: any) => {
type MatchedSegment = {segment: UrlSegmentGroup, outlet: string};
// This logic takes each route and switches to a new observable that depends on the result of
// the previous route expansion. In this way, we compose a list of results where each one can
// depend on and look at the previous to determine how to proceed with expansion of the
// current route.
return routes
.reduce(
(accumulatedResults: Observable<Array<MatchedSegment>>, r: Route) => {
return accumulatedResults.pipe(switchMap(resultsThusFar => {
// If we already matched a previous `Route` with the same outlet as the current,
// we should not process the current one.
if (resultsThusFar.some(result => result && result.outlet === getOutlet(r))) {
return of(resultsThusFar);
}
const expanded$ = this.expandSegmentAgainstRoute(
ngModule, segmentGroup, routes, r, segments, outlet, allowRedirects);
return expanded$.pipe(catchError((e: any) => {
return expanded$.pipe(
map((segment) => resultsThusFar.concat({segment, outlet: getOutlet(r)})),
catchError((e: any) => {
if (e instanceof NoMatch) {
// TODO(i): this return type doesn't match the declared Observable<UrlSegmentGroup> -
// talk to Jason
return of(null) as any;
return of(resultsThusFar);
}
throw e;
}));
}),
concatAll(), first((s: any) => !!s), catchError((e: any, _: any) => {
}));
},
of([] as MatchedSegment[]))
.pipe(
// Find the matched segment whose outlet matches the one we're looking for.
map(results => results.find(s => s.outlet === outlet)?.segment),
first((s): s is UrlSegmentGroup => s !== undefined),
catchError((e: any, _: any) => {
if (e instanceof EmptyError || e.name === 'EmptyError') {
if (this.noLeftoversInUrl(segmentGroup, segments, outlet)) {
return of(new UrlSegmentGroup([], {}));
@ -169,7 +187,8 @@ class ApplyRedirects {
throw new NoMatch(segmentGroup);
}
throw e;
}));
}),
);
}
private noLeftoversInUrl(segmentGroup: UrlSegmentGroup, segments: UrlSegment[], outlet: string):
@ -180,7 +199,9 @@ class ApplyRedirects {
private expandSegmentAgainstRoute(
ngModule: NgModuleRef<any>, segmentGroup: UrlSegmentGroup, routes: Route[], route: Route,
paths: UrlSegment[], outlet: string, allowRedirects: boolean): Observable<UrlSegmentGroup> {
if (getOutlet(route) !== outlet) {
// Empty string segments are special because multiple outlets can match a single path, i.e.
// `[{path: '', component: B}, {path: '', loadChildren: () => {}, outlet: "about"}]`
if (getOutlet(route) !== outlet && route.path !== '') {
return noMatch(segmentGroup);
}

View File

@ -7,9 +7,9 @@
*/
import {NgModuleRef} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {fakeAsync, TestBed, tick} from '@angular/core/testing';
import {Observable, of} from 'rxjs';
import {delay} from 'rxjs/operators';
import {delay, tap} from 'rxjs/operators';
import {applyRedirects} from '../src/apply_redirects';
import {LoadedRouterConfig, Route, Routes} from '../src/config';
@ -482,6 +482,89 @@ describe('applyRedirects', () => {
expect((config[0] as any)._loadedConfig).toBe(loadedConfig);
});
});
it('should load all matching configurations of empty path, including an auxiliary outlets',
fakeAsync(() => {
const loadedConfig =
new LoadedRouterConfig([{path: '', component: ComponentA}], testModule);
let loadCalls = 0;
let loaded: string[] = [];
const loader = {
load: (injector: any, p: Route) => {
loadCalls++;
return of(loadedConfig)
.pipe(
delay(100 * loadCalls),
tap(() => loaded.push(p.loadChildren! as string)),
);
}
};
const config: Routes =
[{path: '', loadChildren: 'root'}, {path: '', loadChildren: 'aux', outlet: 'popup'}];
applyRedirects(testModule.injector, <any>loader, serializer, tree(''), config).subscribe();
expect(loadCalls).toBe(1);
tick(100);
expect(loaded).toEqual(['root']);
tick(200);
expect(loadCalls).toBe(2);
expect(loaded).toEqual(['root', 'aux']);
}));
it('loads only the first match when two Routes with the same outlet have the same path', () => {
const loadedConfig = new LoadedRouterConfig([{path: '', component: ComponentA}], testModule);
let loadCalls = 0;
let loaded: string[] = [];
const loader = {
load: (injector: any, p: Route) => {
loadCalls++;
return of(loadedConfig)
.pipe(
tap(() => loaded.push(p.loadChildren! as string)),
);
}
};
const config: Routes =
[{path: 'a', loadChildren: 'first'}, {path: 'a', loadChildren: 'second'}];
applyRedirects(testModule.injector, <any>loader, serializer, tree('a'), config).subscribe();
expect(loadCalls).toBe(1);
expect(loaded).toEqual(['first']);
});
it('should load the configuration of empty root path if the entry is an aux outlet',
fakeAsync(() => {
const loadedConfig =
new LoadedRouterConfig([{path: '', component: ComponentA}], testModule);
let loaded: string[] = [];
const rootDelay = 100;
const auxDelay = 1;
const loader = {
load: (injector: any, p: Route) => {
const delayMs = p.loadChildren! as string === 'aux' ? auxDelay : rootDelay;
return of(loadedConfig)
.pipe(
delay(delayMs),
tap(() => loaded.push(p.loadChildren! as string)),
);
}
};
const config: Routes = [
// Define aux route first so it matches before the primary outlet
{path: 'modal', loadChildren: 'aux', outlet: 'popup'},
{path: '', loadChildren: 'root'},
];
applyRedirects(testModule.injector, <any>loader, serializer, tree('(popup:modal)'), config)
.subscribe();
tick(auxDelay);
expect(loaded).toEqual(['aux']);
tick(rootDelay);
expect(loaded).toEqual(['aux', 'root']);
}));
});
describe('empty paths', () => {
@ -754,6 +837,46 @@ describe('applyRedirects', () => {
});
});
describe('multiple matches with empty path named outlets', () => {
it('should work with redirects when other outlet comes before the one being activated', () => {
applyRedirects(
testModule.injector, null!, serializer, tree(''),
[
{
path: '',
children: [
{path: '', component: ComponentA, outlet: 'aux'},
{path: '', redirectTo: 'b', pathMatch: 'full'},
{path: 'b', component: ComponentB},
],
},
])
.subscribe(
(tree: UrlTree) => {
expect(tree.toString()).toEqual('/b');
},
() => {
fail('should not be reached');
});
});
it('should work when entry point is named outlet', () => {
applyRedirects(
testModule.injector, null!, serializer, tree('(popup:modal)'),
[
{path: '', component: ComponentA},
{path: 'modal', component: ComponentB, outlet: 'popup'},
])
.subscribe(
(tree: UrlTree) => {
expect(tree.toString()).toEqual('/(popup:modal)');
},
(e) => {
fail('should not be reached' + e.message);
});
});
});
describe('redirecting to named outlets', () => {
it('should work when using absolute redirects', () => {
checkRedirect(