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);
|
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):
|
processChildren(config: Route[], segmentGroup: UrlSegmentGroup):
|
||||||
TreeNode<ActivatedRouteSnapshot>[]|null {
|
TreeNode<ActivatedRouteSnapshot>[]|null {
|
||||||
const children: Array<TreeNode<ActivatedRouteSnapshot>> = [];
|
const children: Array<TreeNode<ActivatedRouteSnapshot>> = [];
|
||||||
for (const childOutlet of Object.keys(segmentGroup.children)) {
|
for (const childOutlet of Object.keys(segmentGroup.children)) {
|
||||||
const child = segmentGroup.children[childOutlet];
|
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) {
|
if (outletChildren === null) {
|
||||||
|
// Configs must match all segment children so because we did not find a match for this
|
||||||
|
// outlet, return `null`.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
children.push(...outletChildren);
|
children.push(...outletChildren);
|
||||||
}
|
}
|
||||||
checkOutletNameUniqueness(children);
|
// Because we may have matched two outlets to the same empty path segment, we can have multiple
|
||||||
sortActivatedRouteSnapshots(children);
|
// activated results for the same outlet. We should merge the children of these results so the
|
||||||
return children;
|
// 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(
|
processSegment(
|
||||||
|
@ -129,9 +151,7 @@ export class Recognizer {
|
||||||
processSegmentAgainstRoute(
|
processSegmentAgainstRoute(
|
||||||
route: Route, rawSegment: UrlSegmentGroup, segments: UrlSegment[],
|
route: Route, rawSegment: UrlSegmentGroup, segments: UrlSegment[],
|
||||||
outlet: string): TreeNode<ActivatedRouteSnapshot>[]|null {
|
outlet: string): TreeNode<ActivatedRouteSnapshot>[]|null {
|
||||||
if (route.redirectTo) return null;
|
if (!isImmediateMatch(route, rawSegment, segments, outlet)) return null;
|
||||||
|
|
||||||
if ((route.outlet || PRIMARY_OUTLET) !== outlet) return null;
|
|
||||||
|
|
||||||
let snapshot: ActivatedRouteSnapshot;
|
let snapshot: ActivatedRouteSnapshot;
|
||||||
let consumedSegments: UrlSegment[] = [];
|
let consumedSegments: UrlSegment[] = [];
|
||||||
|
@ -141,8 +161,9 @@ export class Recognizer {
|
||||||
const params = segments.length > 0 ? last(segments)!.parameters : {};
|
const params = segments.length > 0 ? last(segments)!.parameters : {};
|
||||||
snapshot = new ActivatedRouteSnapshot(
|
snapshot = new ActivatedRouteSnapshot(
|
||||||
segments, params, Object.freeze({...this.urlTree.queryParams}), this.urlTree.fragment!,
|
segments, params, Object.freeze({...this.urlTree.queryParams}), this.urlTree.fragment!,
|
||||||
getData(route), outlet, route.component!, route, getSourceSegmentGroup(rawSegment),
|
getData(route), getOutlet(route), route.component!, route,
|
||||||
getPathIndexShift(rawSegment) + segments.length, getResolve(route));
|
getSourceSegmentGroup(rawSegment), getPathIndexShift(rawSegment) + segments.length,
|
||||||
|
getResolve(route));
|
||||||
} else {
|
} else {
|
||||||
const result: MatchResult|null = match(rawSegment, route, segments);
|
const result: MatchResult|null = match(rawSegment, route, segments);
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
|
@ -153,7 +174,7 @@ export class Recognizer {
|
||||||
|
|
||||||
snapshot = new ActivatedRouteSnapshot(
|
snapshot = new ActivatedRouteSnapshot(
|
||||||
consumedSegments, result.parameters, Object.freeze({...this.urlTree.queryParams}),
|
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),
|
getSourceSegmentGroup(rawSegment),
|
||||||
getPathIndexShift(rawSegment) + consumedSegments.length, getResolve(route));
|
getPathIndexShift(rawSegment) + consumedSegments.length, getResolve(route));
|
||||||
}
|
}
|
||||||
|
@ -175,7 +196,17 @@ export class Recognizer {
|
||||||
return [new TreeNode<ActivatedRouteSnapshot>(snapshot, [])];
|
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) {
|
if (children === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -234,6 +265,37 @@ function match(segmentGroup: UrlSegmentGroup, route: Route, segments: UrlSegment
|
||||||
return {consumedSegments: res.consumed, lastChild: res.consumed.length, parameters};
|
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 {
|
function checkOutletNameUniqueness(nodes: TreeNode<ActivatedRouteSnapshot>[]): void {
|
||||||
const names: {[k: string]: ActivatedRouteSnapshot} = {};
|
const names: {[k: string]: ActivatedRouteSnapshot} = {};
|
||||||
nodes.forEach(n => {
|
nodes.forEach(n => {
|
||||||
|
@ -364,3 +426,35 @@ function getData(route: Route): Data {
|
||||||
function getResolve(route: Route): ResolveData {
|
function getResolve(route: Route): ResolveData {
|
||||||
return route.resolve || {};
|
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);
|
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', () => {
|
describe('wildcards', () => {
|
||||||
|
|
Loading…
Reference in New Issue