fix(router): Ensure named outlets with empty path parents are recognized (#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 #40029
This commit is contained in:
Andrew Scott 2020-11-25 17:27:48 -08:00 committed by Joey Perrott
parent a9f8deb173
commit 3966bcc5d9
2 changed files with 185 additions and 11 deletions

View File

@ -89,20 +89,42 @@ export class Recognizer {
return this.processSegment(config, segmentGroup, segmentGroup.segments, outlet);
}
/**
* Matches every child outlet in the `segmentGroup` to a `Route` in the config. Returns `null` if
* we cannot find a match for _any_ of the children.
*
* @param config - The `Routes` to match against
* @param segmentGroup - The `UrlSegmentGroup` whose children need to be matched against the
* config.
*/
processChildren(config: Route[], segmentGroup: UrlSegmentGroup):
TreeNode<ActivatedRouteSnapshot>[]|null {
const children: Array<TreeNode<ActivatedRouteSnapshot>> = [];
for (const childOutlet of Object.keys(segmentGroup.children)) {
const child = segmentGroup.children[childOutlet];
const outletChildren = this.processSegmentGroup(config, child, childOutlet);
// 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.
const sortedConfig = config.filter(r => getOutlet(r) === childOutlet);
sortedConfig.push(...config.filter(r => getOutlet(r) !== childOutlet));
const outletChildren = this.processSegmentGroup(sortedConfig, child, childOutlet);
if (outletChildren === null) {
// Configs must match all segment children so because we did not find a match for this
// outlet, return `null`.
return null;
}
children.push(...outletChildren);
}
checkOutletNameUniqueness(children);
sortActivatedRouteSnapshots(children);
return children;
// Because we may have matched two outlets to the same empty path segment, we can have multiple
// activated results for the same outlet. We should merge the children of these results so the
// final return value is only one `TreeNode` per outlet.
const mergedChildren = mergeEmptyPathMatches(children);
if (typeof ngDevMode === 'undefined' || ngDevMode) {
// This should really never happen - we are only taking the first match for each outlet and
// merge the empty path matches.
checkOutletNameUniqueness(mergedChildren);
}
sortActivatedRouteSnapshots(mergedChildren);
return mergedChildren;
}
processSegment(
@ -129,9 +151,7 @@ export class Recognizer {
processSegmentAgainstRoute(
route: Route, rawSegment: UrlSegmentGroup, segments: UrlSegment[],
outlet: string): TreeNode<ActivatedRouteSnapshot>[]|null {
if (route.redirectTo) return null;
if ((route.outlet || PRIMARY_OUTLET) !== outlet) return null;
if (!isImmediateMatch(route, rawSegment, segments, outlet)) return null;
let snapshot: ActivatedRouteSnapshot;
let consumedSegments: UrlSegment[] = [];
@ -141,8 +161,9 @@ export class Recognizer {
const params = segments.length > 0 ? last(segments)!.parameters : {};
snapshot = new ActivatedRouteSnapshot(
segments, params, Object.freeze({...this.urlTree.queryParams}), this.urlTree.fragment!,
getData(route), outlet, route.component!, route, getSourceSegmentGroup(rawSegment),
getPathIndexShift(rawSegment) + segments.length, getResolve(route));
getData(route), getOutlet(route), route.component!, route,
getSourceSegmentGroup(rawSegment), getPathIndexShift(rawSegment) + segments.length,
getResolve(route));
} else {
const result: MatchResult|null = match(rawSegment, route, segments);
if (result === null) {
@ -153,7 +174,7 @@ export class Recognizer {
snapshot = new ActivatedRouteSnapshot(
consumedSegments, result.parameters, Object.freeze({...this.urlTree.queryParams}),
this.urlTree.fragment!, getData(route), outlet, route.component!, route,
this.urlTree.fragment!, getData(route), getOutlet(route), route.component!, route,
getSourceSegmentGroup(rawSegment),
getPathIndexShift(rawSegment) + consumedSegments.length, getResolve(route));
}
@ -175,7 +196,17 @@ export class Recognizer {
return [new TreeNode<ActivatedRouteSnapshot>(snapshot, [])];
}
const children = this.processSegment(childConfig, segmentGroup, slicedSegments, PRIMARY_OUTLET);
const matchedOnOutlet = getOutlet(route) === outlet;
// If we matched a config due to empty path match on a different outlet, we need to continue
// passing the current outlet for the segment rather than switch to PRIMARY.
// Note that we switch to primary when we have a match because outlet configs look like this:
// {path: 'a', outlet: 'a', children: [
// {path: 'b', component: B},
// {path: 'c', component: C},
// ]}
// Notice that the children of the named outlet are configured with the primary outlet
const children = this.processSegment(
childConfig, segmentGroup, slicedSegments, matchedOnOutlet ? PRIMARY_OUTLET : outlet);
if (children === null) {
return null;
}
@ -234,6 +265,37 @@ function match(segmentGroup: UrlSegmentGroup, route: Route, segments: UrlSegment
return {consumedSegments: res.consumed, lastChild: res.consumed.length, parameters};
}
/**
* 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
* empty path route config and the results need to then be merged.
*/
function mergeEmptyPathMatches(nodes: Array<TreeNode<ActivatedRouteSnapshot>>):
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) {
if (!hasEmptyConfig(node)) {
result.push(node);
continue;
}
const duplicateEmptyPathNode =
result.find(resultNode => node.value.routeConfig === resultNode.value.routeConfig);
if (duplicateEmptyPathNode !== undefined) {
duplicateEmptyPathNode.children.push(...node.children);
} else {
result.push(node);
}
}
return result;
}
function checkOutletNameUniqueness(nodes: TreeNode<ActivatedRouteSnapshot>[]): void {
const names: {[k: string]: ActivatedRouteSnapshot} = {};
nodes.forEach(n => {
@ -364,3 +426,35 @@ function getData(route: Route): Data {
function getResolve(route: Route): ResolveData {
return route.resolve || {};
}
/**
* Determines if `route` is a path match for the `rawSegment`, `segments`, and `outlet` without
* verifying that its children are a full match for the remainder of the `rawSegment` children as
* well.
*/
function isImmediateMatch(
route: Route, rawSegment: UrlSegmentGroup, segments: UrlSegment[], outlet: string): boolean {
if (route.redirectTo) {
return false;
}
// We allow matches to empty paths when the outlets differ so we can match a url like `/(b:b)` to
// a config like
// * `{path: '', children: [{path: 'b', outlet: 'b'}]}`
// or even
// * `{path: '', outlet: 'a', children: [{path: 'b', outlet: 'b'}]`
//
// The exception here is when the segment outlet is for the primary outlet. This would
// result in a match inside the named outlet because all children there are written as primary
// outlets. So we need to prevent child named outlet matches in a url like `/b` in a config like
// * `{path: '', outlet: 'x' children: [{path: 'b'}]}`
// This should only match if the url is `/(x:b)`.
if (getOutlet(route) !== outlet &&
(outlet === PRIMARY_OUTLET || !emptyPathMatch(rawSegment, segments, route))) {
return false;
}
if (route.path === '**') {
return true;
} else {
return match(rawSegment, route, segments) !== null;
}
}

View File

@ -567,6 +567,86 @@ describe('recognize', () => {
checkActivatedRoute(c[1].firstChild!, 'e', {}, ComponentE);
});
});
describe('with outlets', () => {
it('should work when outlet is a child of empty path parent', () => {
const s = recognize(
[{
path: '',
component: ComponentA,
children: [{path: 'b', outlet: 'b', component: ComponentB}]
}],
'(b:b)');
checkActivatedRoute(s.root.children[0], '', {}, ComponentA);
checkActivatedRoute(s.root.children[0].children[0], 'b', {}, ComponentB, 'b');
});
it('should work for outlets adjacent to empty path', () => {
const s = recognize(
[
{
path: '',
component: ComponentA,
children: [{path: '', component: ComponentC}],
},
{path: 'b', outlet: 'b', component: ComponentB}
],
'(b:b)');
const [primaryChild, outletChild] = s.root.children;
checkActivatedRoute(primaryChild, '', {}, ComponentA);
checkActivatedRoute(outletChild, 'b', {}, ComponentB, 'b');
checkActivatedRoute(primaryChild.children[0], '', {}, ComponentC);
});
it('should work with named outlets both adjecent to and as a child of empty path', () => {
const s = recognize(
[
{
path: '',
component: ComponentA,
children: [{path: 'b', outlet: 'b', component: ComponentB}]
},
{path: 'c', outlet: 'c', component: ComponentC}
],
'(b:b//c:c)');
checkActivatedRoute(s.root.children[0], '', {}, ComponentA);
checkActivatedRoute(s.root.children[1], 'c', {}, ComponentC, 'c');
checkActivatedRoute(s.root.children[0].children[0], 'b', {}, ComponentB, 'b');
});
it('should work with children outlets within two levels of empty parents', () => {
const s = recognize(
[{
path: '',
component: ComponentA,
children: [{
path: '',
component: ComponentB,
children: [{path: 'c', outlet: 'c', component: ComponentC}],
}]
}],
'(c:c)');
checkActivatedRoute(s.root.children[0], '', {}, ComponentA);
checkActivatedRoute(s.root.children[0].children[0], '', {}, ComponentB);
checkActivatedRoute(s.root.children[0].children[0].children[0], 'c', {}, ComponentC, 'c');
});
it('should not persist a primary segment beyond the boundary of a named outlet match', () => {
const s = new Recognizer(
RootComponent,
[
{
path: '',
component: ComponentA,
outlet: 'a',
children: [{path: 'b', component: ComponentB}],
},
],
tree('/b'), '/b', 'emptyOnly', 'corrected')
.recognize();
expect(s).toBeNull();
});
});
});
describe('wildcards', () => {