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 atscott
parent 97475d7408
commit 926ffcd8ac
4 changed files with 206 additions and 31 deletions

View File

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

View File

@ -7,8 +7,8 @@
*/ */
import {Injector, NgModuleRef} from '@angular/core'; import {Injector, NgModuleRef} from '@angular/core';
import {defer, EmptyError, Observable, Observer, of} from 'rxjs'; import {defer, EmptyError, from, Observable, Observer, of} from 'rxjs';
import {catchError, concatAll, first, map, mergeMap, tap} from 'rxjs/operators'; import {catchError, combineAll, concatMap, first, map, mergeMap, tap} from 'rxjs/operators';
import {LoadedRouterConfig, Route, Routes} from './config'; import {LoadedRouterConfig, Route, Routes} from './config';
import {CanLoadFn} from './interfaces'; import {CanLoadFn} from './interfaces';
@ -17,6 +17,7 @@ import {RouterConfigLoader} from './router_config_loader';
import {defaultUrlMatcher, navigationCancelingError, Params, PRIMARY_OUTLET} from './shared'; import {defaultUrlMatcher, navigationCancelingError, Params, PRIMARY_OUTLET} from './shared';
import {UrlSegment, UrlSegmentGroup, UrlSerializer, UrlTree} from './url_tree'; import {UrlSegment, UrlSegmentGroup, UrlSerializer, UrlTree} from './url_tree';
import {forEach, waitForMap, wrapIntoObservable} from './utils/collection'; import {forEach, waitForMap, wrapIntoObservable} from './utils/collection';
import {getOutlet, groupRoutesByOutlet} from './utils/config';
import {isCanLoad, isFunction, isUrlTree} from './utils/type_guards'; import {isCanLoad, isFunction, isUrlTree} from './utils/type_guards';
class NoMatch { class NoMatch {
@ -148,28 +149,52 @@ class ApplyRedirects {
ngModule: NgModuleRef<any>, segmentGroup: UrlSegmentGroup, routes: Route[], ngModule: NgModuleRef<any>, segmentGroup: UrlSegmentGroup, routes: Route[],
segments: UrlSegment[], outlet: string, segments: UrlSegment[], outlet: string,
allowRedirects: boolean): Observable<UrlSegmentGroup> { allowRedirects: boolean): Observable<UrlSegmentGroup> {
return of(...routes).pipe( // We need to expand each outlet group independently to ensure that we not only load modules
map((r: any) => { // for routes matching the given `outlet`, but also those which will be activated because
const expanded$ = this.expandSegmentAgainstRoute( // their path is empty string. This can result in multiple outlets being activated at once.
ngModule, segmentGroup, routes, r, segments, outlet, allowRedirects); const routesByOutlet: Map<string, Route[]> = groupRoutesByOutlet(routes);
return expanded$.pipe(catchError((e: any) => { if (!routesByOutlet.has(outlet)) {
if (e instanceof NoMatch) { routesByOutlet.set(outlet, []);
// TODO(i): this return type doesn't match the declared Observable<UrlSegmentGroup> - }
// talk to Jason
return of(null) as any; const expandRoutes = (routes: Route[]) => {
return from(routes).pipe(
concatMap((r: Route) => {
const expanded$ = this.expandSegmentAgainstRoute(
ngModule, segmentGroup, routes, r, segments, outlet, allowRedirects);
return expanded$.pipe(catchError(e => {
if (e instanceof NoMatch) {
return of(null);
}
throw e;
}));
}),
first((s: UrlSegmentGroup|null): s is UrlSegmentGroup => s !== null),
catchError(e => {
if (e instanceof EmptyError || e.name === 'EmptyError') {
if (this.noLeftoversInUrl(segmentGroup, segments, outlet)) {
return of(new UrlSegmentGroup([], {}));
}
throw new NoMatch(segmentGroup);
} }
throw e; throw e;
})); }),
}), );
concatAll(), first((s: any) => !!s), catchError((e: any, _: any) => { };
if (e instanceof EmptyError || e.name === 'EmptyError') {
if (this.noLeftoversInUrl(segmentGroup, segments, outlet)) { const expansions = Array.from(routesByOutlet.entries()).map(([routeOutlet, routes]) => {
return of(new UrlSegmentGroup([], {})); const expanded = expandRoutes(routes);
} // Map all results from outlets we aren't activating to `null` so they can be ignored later
throw new NoMatch(segmentGroup); return routeOutlet === outlet ? expanded :
} expanded.pipe(map(() => null), catchError(() => of(null)));
throw e; });
})); return from(expansions)
.pipe(
combineAll(),
first(),
// Return only the expansion for the route outlet we are trying to activate.
map(results => results.find(result => result !== null)!),
);
} }
private noLeftoversInUrl(segmentGroup: UrlSegmentGroup, segments: UrlSegment[], outlet: string): private noLeftoversInUrl(segmentGroup: UrlSegmentGroup, segments: UrlSegment[], outlet: string):
@ -180,7 +205,9 @@ class ApplyRedirects {
private expandSegmentAgainstRoute( private expandSegmentAgainstRoute(
ngModule: NgModuleRef<any>, segmentGroup: UrlSegmentGroup, routes: Route[], route: Route, ngModule: NgModuleRef<any>, segmentGroup: UrlSegmentGroup, routes: Route[], route: Route,
paths: UrlSegment[], outlet: string, allowRedirects: boolean): Observable<UrlSegmentGroup> { 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); return noMatch(segmentGroup);
} }
@ -551,7 +578,3 @@ function isEmptyPathRedirect(
return r.path === '' && r.redirectTo !== undefined; return r.path === '' && r.redirectTo !== undefined;
} }
function getOutlet(route: Route): string {
return route.outlet || PRIMARY_OUTLET;
}

View File

@ -113,3 +113,21 @@ export function standardizeConfig(r: Route): Route {
} }
return c; return c;
} }
/** Returns of `Map` of outlet names to the `Route`s for that outlet. */
export function groupRoutesByOutlet(routes: Route[]): Map<string, Route[]> {
return routes.reduce((map, route) => {
const routeOutlet = getOutlet(route);
if (map.has(routeOutlet)) {
map.get(routeOutlet)!.push(route);
} else {
map.set(routeOutlet, [route]);
}
return map;
}, new Map<string, Route[]>());
}
/** Returns the `route.outlet` or PRIMARY_OUTLET if none exists. */
export function getOutlet(route: Route): string {
return route.outlet || PRIMARY_OUTLET;
}

View File

@ -7,9 +7,9 @@
*/ */
import {NgModuleRef} from '@angular/core'; 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 {Observable, of} from 'rxjs';
import {delay} from 'rxjs/operators'; import {delay, tap} from 'rxjs/operators';
import {applyRedirects} from '../src/apply_redirects'; import {applyRedirects} from '../src/apply_redirects';
import {LoadedRouterConfig, Route, Routes} from '../src/config'; import {LoadedRouterConfig, Route, Routes} from '../src/config';
@ -482,6 +482,88 @@ describe('applyRedirects', () => {
expect((config[0] as any)._loadedConfig).toBe(loadedConfig); 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(2);
tick(100);
expect(loaded).toEqual(['root']);
tick(100);
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', () => { describe('empty paths', () => {
@ -754,6 +836,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', () => { describe('redirecting to named outlets', () => {
it('should work when using absolute redirects', () => { it('should work when using absolute redirects', () => {
checkRedirect( checkRedirect(
@ -794,6 +916,18 @@ describe('applyRedirects', () => {
}); });
}); });
}); });
// internal failure b/165719418
it('does not fail with large configs', () => {
const config: Routes = [];
for (let i = 0; i < 400; i++) {
config.push({path: 'no_match', component: ComponentB});
}
config.push({path: 'match', component: ComponentA});
applyRedirects(testModule.injector, null!, serializer, tree('match'), config).forEach(r => {
expectTreeToBe(r, 'match');
});
});
}); });
function checkRedirect(config: Routes, url: string, callback: any): void { function checkRedirect(config: Routes, url: string, callback: any): void {