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:
parent
a9f8deb173
commit
3966bcc5d9
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
Loading…
Reference in New Issue