Skip to content

Commit 68f0858

Browse files
committed
fix(router): Ensure named outlets with empty path parents are recognized (angular#40029)
This commit updates the `recognize` algorithm to work with named outlets which have empty path parents. For example, given the following config ``` const routes = [ { path: '', children: [ {path: 'a', outlet: 'aux', component: AuxComponent} ]} ]; ``` The url `/(aux:a)` should match this config. In order to do so, we need to allow the children of `UrlSegmentGroup`s to match a `Route` config for a different outlet (in this example, the `primary`) when it's an empty path. This should also *only* happen if we were unable to find a match for the outlet in the level above. That is, the matching strategy is to find the first `Route` in the list which _matches the given outlet_. If we are unable to do that, then we allow empty paths from other outlets to match and try to find some child there whose outlet matches our segment. PR Close angular#40029
1 parent 60098a1 commit 68f0858

2 files changed

Lines changed: 185 additions & 11 deletions

File tree

‎packages/router/src/recognize.ts‎

Lines changed: 105 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,20 +89,42 @@ export class Recognizer {
8989
return this.processSegment(config, segmentGroup, segmentGroup.segments, outlet);
9090
}
9191

92+
/**
93+
* Matches every child outlet in the `segmentGroup` to a `Route` in the config. Returns `null` if
94+
* we cannot find a match for _any_ of the children.
95+
*
96+
* @param config - The `Routes` to match against
97+
* @param segmentGroup - The `UrlSegmentGroup` whose children need to be matched against the
98+
* config.
99+
*/
92100
processChildren(config: Route[], segmentGroup: UrlSegmentGroup):
93101
TreeNode<ActivatedRouteSnapshot>[]|null {
94102
const children: Array<TreeNode<ActivatedRouteSnapshot>> = [];
95103
for (const childOutlet of Object.keys(segmentGroup.children)) {
96104
const child = segmentGroup.children[childOutlet];
97-
const outletChildren = this.processSegmentGroup(config, child, childOutlet);
105+
// Sort the config so that routes with outlets that match the one being activated appear
106+
// first, followed by routes for other outlets, which might match if they have an empty path.
107+
const sortedConfig = config.filter(r => getOutlet(r) === childOutlet);
108+
sortedConfig.push(...config.filter(r => getOutlet(r) !== childOutlet));
109+
const outletChildren = this.processSegmentGroup(sortedConfig, child, childOutlet);
98110
if (outletChildren === null) {
111+
// Configs must match all segment children so because we did not find a match for this
112+
// outlet, return `null`.
99113
return null;
100114
}
101115
children.push(...outletChildren);
102116
}
103-
checkOutletNameUniqueness(children);
104-
sortActivatedRouteSnapshots(children);
105-
return children;
117+
// Because we may have matched two outlets to the same empty path segment, we can have multiple
118+
// activated results for the same outlet. We should merge the children of these results so the
119+
// final return value is only one `TreeNode` per outlet.
120+
const mergedChildren = mergeEmptyPathMatches(children);
121+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
122+
// This should really never happen - we are only taking the first match for each outlet and
123+
// merge the empty path matches.
124+
checkOutletNameUniqueness(mergedChildren);
125+
}
126+
sortActivatedRouteSnapshots(mergedChildren);
127+
return mergedChildren;
106128
}
107129

108130
processSegment(
@@ -129,9 +151,7 @@ export class Recognizer {
129151
processSegmentAgainstRoute(
130152
route: Route, rawSegment: UrlSegmentGroup, segments: UrlSegment[],
131153
outlet: string): TreeNode<ActivatedRouteSnapshot>[]|null {
132-
if (route.redirectTo) return null;
133-
134-
if ((route.outlet || PRIMARY_OUTLET) !== outlet) return null;
154+
if (!isImmediateMatch(route, rawSegment, segments, outlet)) return null;
135155

136156
let snapshot: ActivatedRouteSnapshot;
137157
let consumedSegments: UrlSegment[] = [];
@@ -141,8 +161,9 @@ export class Recognizer {
141161
const params = segments.length > 0 ? last(segments)!.parameters : {};
142162
snapshot = new ActivatedRouteSnapshot(
143163
segments, params, Object.freeze({...this.urlTree.queryParams}), this.urlTree.fragment!,
144-
getData(route), outlet, route.component!, route, getSourceSegmentGroup(rawSegment),
145-
getPathIndexShift(rawSegment) + segments.length, getResolve(route));
164+
getData(route), getOutlet(route), route.component!, route,
165+
getSourceSegmentGroup(rawSegment), getPathIndexShift(rawSegment) + segments.length,
166+
getResolve(route));
146167
} else {
147168
const result: MatchResult|null = match(rawSegment, route, segments);
148169
if (result === null) {
@@ -153,7 +174,7 @@ export class Recognizer {
153174

154175
snapshot = new ActivatedRouteSnapshot(
155176
consumedSegments, result.parameters, Object.freeze({...this.urlTree.queryParams}),
156-
this.urlTree.fragment!, getData(route), outlet, route.component!, route,
177+
this.urlTree.fragment!, getData(route), getOutlet(route), route.component!, route,
157178
getSourceSegmentGroup(rawSegment),
158179
getPathIndexShift(rawSegment) + consumedSegments.length, getResolve(route));
159180
}
@@ -175,7 +196,17 @@ export class Recognizer {
175196
return [new TreeNode<ActivatedRouteSnapshot>(snapshot, [])];
176197
}
177198

178-
const children = this.processSegment(childConfig, segmentGroup, slicedSegments, PRIMARY_OUTLET);
199+
const matchedOnOutlet = getOutlet(route) === outlet;
200+
// If we matched a config due to empty path match on a different outlet, we need to continue
201+
// passing the current outlet for the segment rather than switch to PRIMARY.
202+
// Note that we switch to primary when we have a match because outlet configs look like this:
203+
// {path: 'a', outlet: 'a', children: [
204+
// {path: 'b', component: B},
205+
// {path: 'c', component: C},
206+
// ]}
207+
// Notice that the children of the named outlet are configured with the primary outlet
208+
const children = this.processSegment(
209+
childConfig, segmentGroup, slicedSegments, matchedOnOutlet ? PRIMARY_OUTLET : outlet);
179210
if (children === null) {
180211
return null;
181212
}
@@ -234,6 +265,37 @@ function match(segmentGroup: UrlSegmentGroup, route: Route, segments: UrlSegment
234265
return {consumedSegments: res.consumed, lastChild: res.consumed.length, parameters};
235266
}
236267

268+
/**
269+
* Finds `TreeNode`s with matching empty path route configs and merges them into `TreeNode` with the
270+
* children from each duplicate. This is necessary because different outlets can match a single
271+
* empty path route config and the results need to then be merged.
272+
*/
273+
function mergeEmptyPathMatches(nodes: Array<TreeNode<ActivatedRouteSnapshot>>):
274+
Array<TreeNode<ActivatedRouteSnapshot>> {
275+
const result: Array<TreeNode<ActivatedRouteSnapshot>> = [];
276+
277+
function hasEmptyConfig(node: TreeNode<ActivatedRouteSnapshot>) {
278+
const config = node.value.routeConfig;
279+
return config && config.path === '' && config.redirectTo === undefined;
280+
}
281+
282+
for (const node of nodes) {
283+
if (!hasEmptyConfig(node)) {
284+
result.push(node);
285+
continue;
286+
}
287+
288+
const duplicateEmptyPathNode =
289+
result.find(resultNode => node.value.routeConfig === resultNode.value.routeConfig);
290+
if (duplicateEmptyPathNode !== undefined) {
291+
duplicateEmptyPathNode.children.push(...node.children);
292+
} else {
293+
result.push(node);
294+
}
295+
}
296+
return result;
297+
}
298+
237299
function checkOutletNameUniqueness(nodes: TreeNode<ActivatedRouteSnapshot>[]): void {
238300
const names: {[k: string]: ActivatedRouteSnapshot} = {};
239301
nodes.forEach(n => {
@@ -364,3 +426,35 @@ function getData(route: Route): Data {
364426
function getResolve(route: Route): ResolveData {
365427
return route.resolve || {};
366428
}
429+
430+
/**
431+
* Determines if `route` is a path match for the `rawSegment`, `segments`, and `outlet` without
432+
* verifying that its children are a full match for the remainder of the `rawSegment` children as
433+
* well.
434+
*/
435+
function isImmediateMatch(
436+
route: Route, rawSegment: UrlSegmentGroup, segments: UrlSegment[], outlet: string): boolean {
437+
if (route.redirectTo) {
438+
return false;
439+
}
440+
// We allow matches to empty paths when the outlets differ so we can match a url like `/(b:b)` to
441+
// a config like
442+
// * `{path: '', children: [{path: 'b', outlet: 'b'}]}`
443+
// or even
444+
// * `{path: '', outlet: 'a', children: [{path: 'b', outlet: 'b'}]`
445+
//
446+
// The exception here is when the segment outlet is for the primary outlet. This would
447+
// result in a match inside the named outlet because all children there are written as primary
448+
// outlets. So we need to prevent child named outlet matches in a url like `/b` in a config like
449+
// * `{path: '', outlet: 'x' children: [{path: 'b'}]}`
450+
// This should only match if the url is `/(x:b)`.
451+
if (getOutlet(route) !== outlet &&
452+
(outlet === PRIMARY_OUTLET || !emptyPathMatch(rawSegment, segments, route))) {
453+
return false;
454+
}
455+
if (route.path === '**') {
456+
return true;
457+
} else {
458+
return match(rawSegment, route, segments) !== null;
459+
}
460+
}

‎packages/router/test/recognize.spec.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,86 @@ describe('recognize', () => {
567567
checkActivatedRoute(c[1].firstChild!, 'e', {}, ComponentE);
568568
});
569569
});
570+
571+
describe('with outlets', () => {
572+
it('should work when outlet is a child of empty path parent', () => {
573+
const s = recognize(
574+
[{
575+
path: '',
576+
component: ComponentA,
577+
children: [{path: 'b', outlet: 'b', component: ComponentB}]
578+
}],
579+
'(b:b)');
580+
checkActivatedRoute(s.root.children[0], '', {}, ComponentA);
581+
checkActivatedRoute(s.root.children[0].children[0], 'b', {}, ComponentB, 'b');
582+
});
583+
584+
it('should work for outlets adjacent to empty path', () => {
585+
const s = recognize(
586+
[
587+
{
588+
path: '',
589+
component: ComponentA,
590+
children: [{path: '', component: ComponentC}],
591+
},
592+
{path: 'b', outlet: 'b', component: ComponentB}
593+
],
594+
'(b:b)');
595+
const [primaryChild, outletChild] = s.root.children;
596+
checkActivatedRoute(primaryChild, '', {}, ComponentA);
597+
checkActivatedRoute(outletChild, 'b', {}, ComponentB, 'b');
598+
checkActivatedRoute(primaryChild.children[0], '', {}, ComponentC);
599+
});
600+
601+
it('should work with named outlets both adjecent to and as a child of empty path', () => {
602+
const s = recognize(
603+
[
604+
{
605+
path: '',
606+
component: ComponentA,
607+
children: [{path: 'b', outlet: 'b', component: ComponentB}]
608+
},
609+
{path: 'c', outlet: 'c', component: ComponentC}
610+
],
611+
'(b:b//c:c)');
612+
checkActivatedRoute(s.root.children[0], '', {}, ComponentA);
613+
checkActivatedRoute(s.root.children[1], 'c', {}, ComponentC, 'c');
614+
checkActivatedRoute(s.root.children[0].children[0], 'b', {}, ComponentB, 'b');
615+
});
616+
617+
it('should work with children outlets within two levels of empty parents', () => {
618+
const s = recognize(
619+
[{
620+
path: '',
621+
component: ComponentA,
622+
children: [{
623+
path: '',
624+
component: ComponentB,
625+
children: [{path: 'c', outlet: 'c', component: ComponentC}],
626+
}]
627+
}],
628+
'(c:c)');
629+
checkActivatedRoute(s.root.children[0], '', {}, ComponentA);
630+
checkActivatedRoute(s.root.children[0].children[0], '', {}, ComponentB);
631+
checkActivatedRoute(s.root.children[0].children[0].children[0], 'c', {}, ComponentC, 'c');
632+
});
633+
634+
it('should not persist a primary segment beyond the boundary of a named outlet match', () => {
635+
const s = new Recognizer(
636+
RootComponent,
637+
[
638+
{
639+
path: '',
640+
component: ComponentA,
641+
outlet: 'a',
642+
children: [{path: 'b', component: ComponentB}],
643+
},
644+
],
645+
tree('/b'), '/b', 'emptyOnly', 'corrected')
646+
.recognize();
647+
expect(s).toBeNull();
648+
});
649+
});
570650
});
571651

572652
describe('wildcards', () => {

0 commit comments

Comments
 (0)