fix(router): apply redirects should match named outlets with empty path parents (#40029)

There are two parts to this commit:
1. Revert the changes from #38379. This change had an incomplete view of
how things worked and also diverged the implementations of
`applyRedirects` and `recognize` even more.
2. Apply the fixes from the `recognize` algorithm to ensure that named
outlets with empty path parents can be matched. This change also passes
all the tests that were added in #38379 with the added benefit of being
a more complete fix that stays in-line with the `recognize` algorithm.
This was made possible by using the same approach for `split` by
always creating segments for empty path matches (previously, this was
only done in `applyRedirects` if there was a `redirectTo` value). At the
end of the expansions, we need to squash all empty segments so that
serializing the final `UrlTree` returns the same result as before.

Fixes #39952
Fixes #10726
Closes #30410

PR Close #40029
This commit is contained in:
Andrew Scott 2020-11-30 11:56:27 -08:00 committed by Joey Perrott
parent 5842467134
commit e43f7e26fe
9 changed files with 280 additions and 138 deletions

View File

@ -39,7 +39,7 @@
"master": { "master": {
"uncompressed": { "uncompressed": {
"runtime-es2015": 2285, "runtime-es2015": 2285,
"main-es2015": 241837, "main-es2015": 240909,
"polyfills-es2015": 36709, "polyfills-es2015": 36709,
"5-es2015": 745 "5-es2015": 745
} }
@ -49,7 +49,7 @@
"master": { "master": {
"uncompressed": { "uncompressed": {
"runtime-es2015": 2289, "runtime-es2015": 2289,
"main-es2015": 218507, "main-es2015": 217591,
"polyfills-es2015": 36723, "polyfills-es2015": 36723,
"5-es2015": 781 "5-es2015": 781
} }
@ -66,4 +66,4 @@
} }
} }
} }
} }

View File

@ -1457,6 +1457,9 @@
{ {
"name": "handleError" "name": "handleError"
}, },
{
"name": "hasEmptyPathConfig"
},
{ {
"name": "hasParentInjector" "name": "hasParentInjector"
}, },
@ -1542,13 +1545,13 @@
"name": "isDirectiveHost" "name": "isDirectiveHost"
}, },
{ {
"name": "isEmptyPathRedirect" "name": "isFunction"
}, },
{ {
"name": "isFunction" "name": "isFunction"
}, },
{ {
"name": "isFunction" "name": "isImmediateMatch"
}, },
{ {
"name": "isInCheckNoChangesMode" "name": "isInCheckNoChangesMode"
@ -1646,9 +1649,6 @@
{ {
"name": "map" "name": "map"
}, },
{
"name": "mapChildrenIntoArray"
},
{ {
"name": "markAsComponentHost" "name": "markAsComponentHost"
}, },
@ -1682,9 +1682,6 @@
{ {
"name": "mergeMap" "name": "mergeMap"
}, },
{
"name": "mergeTrivialChildren"
},
{ {
"name": "modules" "name": "modules"
}, },
@ -1706,6 +1703,9 @@
{ {
"name": "navigationCancelingError" "name": "navigationCancelingError"
}, },
{
"name": "newObservableError"
},
{ {
"name": "nextBindingIndex" "name": "nextBindingIndex"
}, },
@ -1715,6 +1715,12 @@
{ {
"name": "ngOnChangesSetInput" "name": "ngOnChangesSetInput"
}, },
{
"name": "noLeftoversInUrl"
},
{
"name": "noMatch"
},
{ {
"name": "noMatch" "name": "noMatch"
}, },
@ -1838,6 +1844,9 @@
{ {
"name": "saveNameToExportMap" "name": "saveNameToExportMap"
}, },
{
"name": "scan"
},
{ {
"name": "scheduleArray" "name": "scheduleArray"
}, },
@ -1910,9 +1919,15 @@
{ {
"name": "shouldSearchParent" "name": "shouldSearchParent"
}, },
{
"name": "sortByMatchingOutlets"
},
{ {
"name": "split" "name": "split"
}, },
{
"name": "squashSegmentGroup"
},
{ {
"name": "standardizeConfig" "name": "standardizeConfig"
}, },

View File

@ -8,7 +8,7 @@
import {Injector, NgModuleRef} from '@angular/core'; import {Injector, NgModuleRef} from '@angular/core';
import {EmptyError, from, Observable, Observer, of} from 'rxjs'; import {EmptyError, from, Observable, Observer, of} from 'rxjs';
import {catchError, combineAll, concatMap, first, map, mergeMap, tap} from 'rxjs/operators'; import {catchError, concatMap, first, last, map, mergeMap, scan, tap} from 'rxjs/operators';
import {LoadedRouterConfig, Route, Routes} from './config'; import {LoadedRouterConfig, Route, Routes} from './config';
import {CanLoadFn} from './interfaces'; import {CanLoadFn} from './interfaces';
@ -16,9 +16,9 @@ import {prioritizedGuardValue} from './operators/prioritized_guard_value';
import {RouterConfigLoader} from './router_config_loader'; import {RouterConfigLoader} from './router_config_loader';
import {navigationCancelingError, Params, PRIMARY_OUTLET} from './shared'; import {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, wrapIntoObservable} from './utils/collection';
import {getOutlet, groupRoutesByOutlet} from './utils/config'; import {getOutlet, sortByMatchingOutlets} from './utils/config';
import {match, noLeftoversInUrl, split} from './utils/config_matching'; import {isImmediateMatch, match, noLeftoversInUrl, split} from './utils/config_matching';
import {isCanLoad, isFunction, isUrlTree} from './utils/type_guards'; import {isCanLoad, isFunction, isUrlTree} from './utils/type_guards';
class NoMatch { class NoMatch {
@ -78,8 +78,17 @@ class ApplyRedirects {
} }
apply(): Observable<UrlTree> { apply(): Observable<UrlTree> {
const splitGroup = split(this.urlTree.root, [], [], this.config).segmentGroup;
// TODO(atscott): creating a new segment removes the _sourceSegment _segmentIndexShift, which is
// only necessary to prevent failures in tests which assert exact object matches. The `split` is
// now shared between `applyRedirects` and `recognize` but only the `recognize` step needs these
// properties. Before the implementations were merged, the `applyRedirects` would not assign
// them. We should be able to remove this logic as a "breaking change" but should do some more
// investigation into the failures first.
const rootSegmentGroup = new UrlSegmentGroup(splitGroup.segments, splitGroup.children);
const expanded$ = const expanded$ =
this.expandSegmentGroup(this.ngModule, this.config, this.urlTree.root, PRIMARY_OUTLET); this.expandSegmentGroup(this.ngModule, this.config, rootSegmentGroup, PRIMARY_OUTLET);
const urlTrees$ = expanded$.pipe(map((rootSegmentGroup: UrlSegmentGroup) => { const urlTrees$ = expanded$.pipe(map((rootSegmentGroup: UrlSegmentGroup) => {
return this.createUrlTree( return this.createUrlTree(
squashSegmentGroup(rootSegmentGroup), this.urlTree.queryParams, this.urlTree.fragment!); squashSegmentGroup(rootSegmentGroup), this.urlTree.queryParams, this.urlTree.fragment!);
@ -143,74 +152,73 @@ class ApplyRedirects {
private expandChildren( private expandChildren(
ngModule: NgModuleRef<any>, routes: Route[], ngModule: NgModuleRef<any>, routes: Route[],
segmentGroup: UrlSegmentGroup): Observable<{[name: string]: UrlSegmentGroup}> { segmentGroup: UrlSegmentGroup): Observable<{[name: string]: UrlSegmentGroup}> {
return waitForMap( // Expand outlets one at a time, starting with the primary outlet. We need to do it this way
segmentGroup.children, // because an absolute redirect from the primary outlet takes precedence.
(childOutlet, child) => this.expandSegmentGroup(ngModule, routes, child, childOutlet)); const childOutlets: string[] = [];
for (const child of Object.keys(segmentGroup.children)) {
if (child === 'primary') {
childOutlets.unshift(child);
} else {
childOutlets.push(child);
}
}
return from(childOutlets)
.pipe(
concatMap(childOutlet => {
const child = segmentGroup.children[childOutlet];
// Sort the routes so routes with outlets that match the the segment appear
// first, followed by routes for other outlets, which might match if they have an
// empty path.
const sortedRoutes = sortByMatchingOutlets(routes, childOutlet);
return this.expandSegmentGroup(ngModule, sortedRoutes, child, childOutlet)
.pipe(map(s => ({segment: s, outlet: childOutlet})));
}),
scan(
(children, expandedChild) => {
children[expandedChild.outlet] = expandedChild.segment;
return children;
},
{} as {[outlet: string]: UrlSegmentGroup}),
last(),
);
} }
private expandSegment( private expandSegment(
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> {
// We need to expand each outlet group independently to ensure that we not only load modules return from(routes).pipe(
// for routes matching the given `outlet`, but also those which will be activated because concatMap((r: any) => {
// their path is empty string. This can result in multiple outlets being activated at once. const expanded$ = this.expandSegmentAgainstRoute(
const routesByOutlet: Map<string, Route[]> = groupRoutesByOutlet(routes); ngModule, segmentGroup, routes, r, segments, outlet, allowRedirects);
if (!routesByOutlet.has(outlet)) { return expanded$.pipe(catchError((e: any) => {
routesByOutlet.set(outlet, []); if (e instanceof NoMatch) {
} return of(null);
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 (noLeftoversInUrl(segmentGroup, segments, outlet)) {
return of(new UrlSegmentGroup([], {}));
}
throw new NoMatch(segmentGroup);
} }
throw e; throw e;
}), }));
); }),
}; first((s): s is UrlSegmentGroup => !!s), catchError((e: any, _: any) => {
if (e instanceof EmptyError || e.name === 'EmptyError') {
const expansions = Array.from(routesByOutlet.entries()).map(([routeOutlet, routes]) => { if (noLeftoversInUrl(segmentGroup, segments, outlet)) {
const expanded = expandRoutes(routes); return of(new UrlSegmentGroup([], {}));
// Map all results from outlets we aren't activating to `null` so they can be ignored later }
return routeOutlet === outlet ? expanded : throw new NoMatch(segmentGroup);
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 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> {
// Empty string segments are special because multiple outlets can match a single path, i.e. if (!isImmediateMatch(route, segmentGroup, paths, outlet)) {
// `[{path: '', component: B}, {path: '', loadChildren: () => {}, outlet: "about"}]`
if (getOutlet(route) !== outlet && route.path !== '') {
return noMatch(segmentGroup); return noMatch(segmentGroup);
} }
if (route.redirectTo === undefined) { if (route.redirectTo === undefined) {
return this.matchSegmentAgainstRoute(ngModule, segmentGroup, route, paths); return this.matchSegmentAgainstRoute(ngModule, segmentGroup, route, paths, outlet);
} }
if (allowRedirects && this.allowRedirects) { if (allowRedirects && this.allowRedirects) {
@ -269,7 +277,7 @@ class ApplyRedirects {
private matchSegmentAgainstRoute( private matchSegmentAgainstRoute(
ngModule: NgModuleRef<any>, rawSegmentGroup: UrlSegmentGroup, route: Route, ngModule: NgModuleRef<any>, rawSegmentGroup: UrlSegmentGroup, route: Route,
segments: UrlSegment[]): Observable<UrlSegmentGroup> { segments: UrlSegment[], outlet: string): Observable<UrlSegmentGroup> {
if (route.path === '**') { if (route.path === '**') {
if (route.loadChildren) { if (route.loadChildren) {
return this.configLoader.load(ngModule.injector, route) return this.configLoader.load(ngModule.injector, route)
@ -292,16 +300,11 @@ class ApplyRedirects {
const childModule = routerConfig.module; const childModule = routerConfig.module;
const childConfig = routerConfig.routes; const childConfig = routerConfig.routes;
const {segmentGroup, slicedSegments} = const {segmentGroup: splitSegmentGroup, slicedSegments} =
split(rawSegmentGroup, consumedSegments, rawSlicedSegments, childConfig); split(rawSegmentGroup, consumedSegments, rawSlicedSegments, childConfig);
// TODO(atscott): clearing the source segment and segment index shift is only necessary to // See comment on the other call to `split` about why this is necessary.
// prevent failures in tests which assert exact object matches. The `split` is now shared const segmentGroup =
// between applyRedirects and recognize and only the `recognize` step needs these properties. new UrlSegmentGroup(splitSegmentGroup.segments, splitSegmentGroup.children);
// Before the implementations were merged, the applyRedirects would not assign them.
// We should be able to remove this logic as a "breaking change" but should do some more
// investigation into the failures first.
segmentGroup._sourceSegment = undefined;
segmentGroup._segmentIndexShift = undefined;
if (slicedSegments.length === 0 && segmentGroup.hasChildren()) { if (slicedSegments.length === 0 && segmentGroup.hasChildren()) {
const expanded$ = this.expandChildren(childModule, childConfig, segmentGroup); const expanded$ = this.expandChildren(childModule, childConfig, segmentGroup);
@ -313,8 +316,10 @@ class ApplyRedirects {
return of(new UrlSegmentGroup(consumedSegments, {})); return of(new UrlSegmentGroup(consumedSegments, {}));
} }
const matchedOnOutlet = getOutlet(route) === outlet;
const expanded$ = this.expandSegment( const expanded$ = this.expandSegment(
childModule, segmentGroup, childConfig, slicedSegments, PRIMARY_OUTLET, true); childModule, segmentGroup, childConfig, slicedSegments,
matchedOnOutlet ? PRIMARY_OUTLET : outlet, true);
return expanded$.pipe( return expanded$.pipe(
map((cs: UrlSegmentGroup) => map((cs: UrlSegmentGroup) =>
new UrlSegmentGroup(consumedSegments.concat(cs.segments), cs.children))); new UrlSegmentGroup(consumedSegments.concat(cs.segments), cs.children)));
@ -473,7 +478,6 @@ class ApplyRedirects {
} }
} }
/** /**
* When possible, merges the primary outlet child into the parent `UrlSegmentGroup`. * When possible, merges the primary outlet child into the parent `UrlSegmentGroup`.
* *

View File

@ -14,7 +14,7 @@ import {ActivatedRouteSnapshot, inheritedParamsDataResolve, ParamsInheritanceStr
import {PRIMARY_OUTLET} from './shared'; import {PRIMARY_OUTLET} from './shared';
import {UrlSegment, UrlSegmentGroup, UrlTree} from './url_tree'; import {UrlSegment, UrlSegmentGroup, UrlTree} from './url_tree';
import {last} from './utils/collection'; import {last} from './utils/collection';
import {getOutlet} from './utils/config'; import {getOutlet, sortByMatchingOutlets} from './utils/config';
import {isImmediateMatch, match, noLeftoversInUrl, split} from './utils/config_matching'; import {isImmediateMatch, match, noLeftoversInUrl, split} from './utils/config_matching';
import {TreeNode} from './utils/tree'; import {TreeNode} from './utils/tree';
@ -64,6 +64,8 @@ export class Recognizer {
return null; return null;
} }
// Use Object.freeze to prevent readers of the Router state from modifying it outside of a
// navigation, resulting in the router being out of sync with the browser.
const root = new ActivatedRouteSnapshot( const root = new ActivatedRouteSnapshot(
[], Object.freeze({}), Object.freeze({...this.urlTree.queryParams}), this.urlTree.fragment!, [], Object.freeze({}), Object.freeze({...this.urlTree.queryParams}), this.urlTree.fragment!,
{}, PRIMARY_OUTLET, this.rootComponentType, null, this.urlTree.root, -1, {}); {}, PRIMARY_OUTLET, this.rootComponentType, null, this.urlTree.root, -1, {});
@ -108,8 +110,7 @@ export class Recognizer {
const child = segmentGroup.children[childOutlet]; const child = segmentGroup.children[childOutlet];
// Sort the config so that routes with outlets that match the one being activated appear // Sort the config so that routes with outlets that match the one being activated appear
// first, followed by routes for other outlets, which might match if they have an empty path. // first, followed by routes for other outlets, which might match if they have an empty path.
const sortedConfig = config.filter(r => getOutlet(r) === childOutlet); const sortedConfig = sortByMatchingOutlets(config, childOutlet);
sortedConfig.push(...config.filter(r => getOutlet(r) !== childOutlet));
const outletChildren = this.processSegmentGroup(sortedConfig, child, childOutlet); const outletChildren = this.processSegmentGroup(sortedConfig, child, childOutlet);
if (outletChildren === null) { if (outletChildren === null) {
// Configs must match all segment children so because we did not find a match for this // Configs must match all segment children so because we did not find a match for this
@ -182,6 +183,9 @@ export class Recognizer {
const {segmentGroup, slicedSegments} = split( const {segmentGroup, slicedSegments} = split(
rawSegment, consumedSegments, rawSlicedSegments, rawSegment, consumedSegments, rawSlicedSegments,
// Filter out routes with redirectTo because we are trying to create activated route
// snapshots and don't handle redirects here. That should have been done in
// `applyRedirects`.
childConfig.filter(c => c.redirectTo === undefined), this.relativeLinkResolution); childConfig.filter(c => c.redirectTo === undefined), this.relativeLinkResolution);
if (slicedSegments.length === 0 && segmentGroup.hasChildren()) { if (slicedSegments.length === 0 && segmentGroup.hasChildren()) {
@ -234,6 +238,11 @@ function getChildConfig(route: Route): Route[] {
return []; return [];
} }
function hasEmptyPathConfig(node: TreeNode<ActivatedRouteSnapshot>) {
const config = node.value.routeConfig;
return config && config.path === '' && config.redirectTo === undefined;
}
/** /**
* Finds `TreeNode`s with matching empty path route configs and merges them into `TreeNode` with the * Finds `TreeNode`s with matching empty path route configs and merges them into `TreeNode` with the
* children from each duplicate. This is necessary because different outlets can match a single * children from each duplicate. This is necessary because different outlets can match a single
@ -243,13 +252,8 @@ function mergeEmptyPathMatches(nodes: Array<TreeNode<ActivatedRouteSnapshot>>):
Array<TreeNode<ActivatedRouteSnapshot>> { Array<TreeNode<ActivatedRouteSnapshot>> {
const result: Array<TreeNode<ActivatedRouteSnapshot>> = []; const result: Array<TreeNode<ActivatedRouteSnapshot>> = [];
function hasEmptyConfig(node: TreeNode<ActivatedRouteSnapshot>) {
const config = node.value.routeConfig;
return config && config.path === '' && config.redirectTo === undefined;
}
for (const node of nodes) { for (const node of nodes) {
if (!hasEmptyConfig(node)) { if (!hasEmptyPathConfig(node)) {
result.push(node); result.push(node);
continue; continue;
} }

View File

@ -83,32 +83,6 @@ export function forEach<K, V>(map: {[key: string]: V}, callback: (v: V, k: strin
} }
} }
export function waitForMap<A, B>(
obj: {[k: string]: A}, fn: (k: string, a: A) => Observable<B>): Observable<{[k: string]: B}> {
if (Object.keys(obj).length === 0) {
return of({});
}
const waitHead: Observable<B>[] = [];
const waitTail: Observable<B>[] = [];
const res: {[k: string]: B} = {};
forEach(obj, (a: A, k: string) => {
const mapped = fn(k, a).pipe(map((r: B) => res[k] = r));
if (k === PRIMARY_OUTLET) {
waitHead.push(mapped);
} else {
waitTail.push(mapped);
}
});
// Closure compiler has problem with using spread operator here. So we use "Array.concat".
// Note that we also need to cast the new promise because TypeScript cannot infer the type
// when calling the "of" function through "Function.apply"
return (of.apply(null, waitHead.concat(waitTail)) as Observable<Observable<B>>)
.pipe(concatAll(), lastValue(), map(() => res));
}
export function wrapIntoObservable<T>(value: T|Promise<T>|Observable<T>): Observable<T> { export function wrapIntoObservable<T>(value: T|Promise<T>|Observable<T>): Observable<T> {
if (isObservable(value)) { if (isObservable(value)) {
return value; return value;

View File

@ -123,20 +123,17 @@ 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. */ /** Returns the `route.outlet` or PRIMARY_OUTLET if none exists. */
export function getOutlet(route: Route): string { export function getOutlet(route: Route): string {
return route.outlet || PRIMARY_OUTLET; return route.outlet || PRIMARY_OUTLET;
} }
/**
* Sorts the `routes` such that the ones with an outlet matching `outletName` come first.
* The order of the configs is otherwise preserved.
*/
export function sortByMatchingOutlets(routes: Routes, outletName: string): Routes {
const sortedConfig = routes.filter(r => getOutlet(r) === outletName);
sortedConfig.push(...routes.filter(r => getOutlet(r) !== outletName));
return sortedConfig;
}

View File

@ -21,7 +21,7 @@ export interface MatchResult {
positionalParamSegments: {[k: string]: UrlSegment}; positionalParamSegments: {[k: string]: UrlSegment};
} }
const noMatch = { const noMatch: MatchResult = {
matched: false, matched: false,
consumedSegments: [], consumedSegments: [],
lastChild: 0, lastChild: 0,
@ -183,9 +183,8 @@ export function isImmediateMatch(
} }
if (route.path === '**') { if (route.path === '**') {
return true; return true;
} else {
return match(rawSegment, route, segments).matched;
} }
return match(rawSegment, route, segments).matched;
} }
export function noLeftoversInUrl( export function noLeftoversInUrl(

View File

@ -8,6 +8,8 @@
import {NgModuleRef} from '@angular/core'; import {NgModuleRef} from '@angular/core';
import {fakeAsync, TestBed, tick} from '@angular/core/testing'; import {fakeAsync, TestBed, tick} from '@angular/core/testing';
import {ActivatedRouteSnapshot} from '@angular/router';
import {TreeNode} from '@angular/router/src/utils/tree';
import {Observable, of} from 'rxjs'; import {Observable, of} from 'rxjs';
import {delay, tap} from 'rxjs/operators'; import {delay, tap} from 'rxjs/operators';
@ -504,10 +506,11 @@ describe('applyRedirects', () => {
[{path: '', loadChildren: 'root'}, {path: '', loadChildren: 'aux', outlet: 'popup'}]; [{path: '', loadChildren: 'root'}, {path: '', loadChildren: 'aux', outlet: 'popup'}];
applyRedirects(testModule.injector, <any>loader, serializer, tree(''), config).subscribe(); applyRedirects(testModule.injector, <any>loader, serializer, tree(''), config).subscribe();
expect(loadCalls).toBe(2); expect(loadCalls).toBe(1);
tick(100); tick(100);
expect(loaded).toEqual(['root']); expect(loaded).toEqual(['root']);
tick(100); expect(loadCalls).toBe(2);
tick(200);
expect(loaded).toEqual(['root', 'aux']); expect(loaded).toEqual(['root', 'aux']);
})); }));
@ -560,9 +563,8 @@ describe('applyRedirects', () => {
applyRedirects(testModule.injector, <any>loader, serializer, tree('(popup:modal)'), config) applyRedirects(testModule.injector, <any>loader, serializer, tree('(popup:modal)'), config)
.subscribe(); .subscribe();
tick(auxDelay); tick(auxDelay);
expect(loaded).toEqual(['aux']);
tick(rootDelay); tick(rootDelay);
expect(loaded).toEqual(['aux', 'root']); expect(loaded.sort()).toEqual(['aux', 'root'].sort());
})); }));
}); });
@ -681,6 +683,108 @@ describe('applyRedirects', () => {
}); });
}); });
describe('aux split after empty path parent', () => {
it('should work with non-empty auxiliary path', () => {
checkRedirect(
[{
path: '',
children: [
{path: 'a', component: ComponentA},
{path: 'c', component: ComponentC, outlet: 'aux'},
{path: 'b', redirectTo: 'c', outlet: 'aux'}
]
}],
'(aux:b)', (t: UrlTree) => {
expectTreeToBe(t, '(aux:c)');
});
});
it('should work with empty auxiliary path', () => {
checkRedirect(
[{
path: '',
children: [
{path: 'a', component: ComponentA},
{path: 'c', component: ComponentC, outlet: 'aux'},
{path: '', redirectTo: 'c', outlet: 'aux'}
]
}],
'', (t: UrlTree) => {
expectTreeToBe(t, '(aux:c)');
});
});
it('should work with empty auxiliary path and matching primary', () => {
checkRedirect(
[{
path: '',
children: [
{path: 'a', component: ComponentA},
{path: 'c', component: ComponentC, outlet: 'aux'},
{path: '', redirectTo: 'c', outlet: 'aux'}
]
}],
'a', (t: UrlTree) => {
expect(t.toString()).toEqual('/a(aux:c)');
});
});
it('should work with aux outlets adjacent to and children of empty path at once', () => {
checkRedirect(
[
{
path: '',
component: ComponentA,
children: [{path: 'b', outlet: 'b', component: ComponentB}]
},
{path: 'c', outlet: 'c', component: ComponentC}
],
'(b:b//c:c)', (t: UrlTree) => {
expect(t.toString()).toEqual('/(b:b//c:c)');
});
});
it('should work with children outlets within two levels of empty parents', () => {
checkRedirect(
[{
path: '',
component: ComponentA,
children: [{
path: '',
component: ComponentB,
children: [
{path: 'd', outlet: 'aux', redirectTo: 'c'},
{path: 'c', outlet: 'aux', component: ComponentC}
]
}]
}],
'(aux:d)', (t: UrlTree) => {
expect(t.toString()).toEqual('/(aux:c)');
});
});
it('does not persist a primary segment beyond the boundary of a named outlet match', () => {
const config: Routes = [
{
path: '',
component: ComponentA,
outlet: 'aux',
children: [{path: 'b', component: ComponentB, redirectTo: '/c'}]
},
{path: 'c', component: ComponentC}
];
applyRedirects(testModule.injector, null!, serializer, tree('/b'), config)
.subscribe(
(_) => {
throw 'Should not be reached';
},
e => {
expect(e.message).toEqual(`Cannot match any routes. URL Segment: 'b'`);
});
});
});
describe('split at the end (no right child)', () => { describe('split at the end (no right child)', () => {
it('should create a new child (non-terminal)', () => { it('should create a new child (non-terminal)', () => {
checkRedirect( checkRedirect(
@ -844,7 +948,8 @@ describe('applyRedirects', () => {
{ {
path: '', path: '',
children: [ children: [
{path: '', component: ComponentA, outlet: 'aux'}, {path: '', outlet: 'aux', redirectTo: 'b'},
{path: 'b', component: ComponentA, outlet: 'aux'},
{path: '', redirectTo: 'b', pathMatch: 'full'}, {path: '', redirectTo: 'b', pathMatch: 'full'},
{path: 'b', component: ComponentB}, {path: 'b', component: ComponentB},
], ],
@ -852,13 +957,40 @@ describe('applyRedirects', () => {
]) ])
.subscribe( .subscribe(
(tree: UrlTree) => { (tree: UrlTree) => {
expect(tree.toString()).toEqual('/b'); expect(tree.toString()).toEqual('/b(aux:b)');
expect(tree.root.children['primary'].toString()).toEqual('b');
expect(tree.root.children['aux']).toBeDefined();
expect(tree.root.children['aux'].toString()).toEqual('b');
}, },
() => { () => {
fail('should not be reached'); fail('should not be reached');
}); });
}); });
it('should prevent empty named outlets from appearing in leaves, resulting in odd tree url',
() => {
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', () => { it('should work when entry point is named outlet', () => {
applyRedirects( applyRedirects(
testModule.injector, null!, serializer, tree('(popup:modal)'), testModule.injector, null!, serializer, tree('(popup:modal)'),

View File

@ -1095,6 +1095,22 @@ describe('Integration', () => {
expect(fixture.nativeElement).toHaveText('team 22 [ user victor, right: simple ]'); expect(fixture.nativeElement).toHaveText('team 22 [ user victor, right: simple ]');
}))); })));
it('should support secondary routes as child of empty path parent',
fakeAsync(inject([Router], (router: Router) => {
const fixture = createRoot(router, RootCmp);
router.resetConfig([{
path: '',
component: TeamCmp,
children: [{path: 'simple', component: SimpleCmp, outlet: 'right'}]
}]);
router.navigateByUrl('/(right:simple)');
advance(fixture);
expect(fixture.nativeElement).toHaveText('team [ , right: simple ]');
})));
it('should deactivate outlets', fakeAsync(inject([Router], (router: Router) => { it('should deactivate outlets', fakeAsync(inject([Router], (router: Router) => {
const fixture = createRoot(router, RootCmp); const fixture = createRoot(router, RootCmp);
@ -1688,13 +1704,14 @@ describe('Integration', () => {
data: {one: 1}, data: {one: 1},
resolve: {two: 'resolveTwo'}, resolve: {two: 'resolveTwo'},
children: [ children: [
{path: '', data: {three: 3}, resolve: {four: 'resolveFour'}, component: RouteCmp}, { {path: '', data: {three: 3}, resolve: {four: 'resolveFour'}, component: RouteCmp},
{
path: '', path: '',
data: {five: 5}, data: {five: 5},
resolve: {six: 'resolveSix'}, resolve: {six: 'resolveSix'},
component: RouteCmp, component: RouteCmp,
outlet: 'right' outlet: 'right'
} },
] ]
}]); }]);