Skip to content

Commit c84d372

Browse files
committed
feat(router): Support wildcard params with segments trailing (angular#64737)
this adds support for both leading and trailing segments before/after wildcard route. Exposig the segments in a new _splat param would require a breaking change to the return value of the matchers. fixes angular#60821 PR Close angular#64737
1 parent b0c9c63 commit c84d372

File tree

4 files changed

+147
-31
lines changed

4 files changed

+147
-31
lines changed

packages/core/test/bundling/router/bundle.golden_symbols.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,6 @@
530530
"createUrlTreeFromSnapshot",
531531
"createViewBlueprint",
532532
"createViewRef",
533-
"createWildcardMatchResult",
534533
"cyclicDependencyError",
535534
"deactivateRouteAndItsChildren",
536535
"decode",
@@ -885,6 +884,7 @@
885884
"markedFeatures",
886885
"match",
887886
"matchMatrixKeySegments",
887+
"matchParts",
888888
"matchQueryParams",
889889
"matchSegments",
890890
"matchTemplateAttribute",

packages/router/src/shared.ts

Lines changed: 73 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,24 @@ export function convertToParamMap(params: Params): ParamMap {
117117
return new ParamsAsMap(params);
118118
}
119119

120+
function matchParts(
121+
routeParts: string[],
122+
urlSegments: UrlSegment[],
123+
posParams: {[key: string]: UrlSegment},
124+
): boolean {
125+
for (let i = 0; i < routeParts.length; i++) {
126+
const part = routeParts[i];
127+
const segment = urlSegments[i];
128+
const isParameter = part[0] === ':';
129+
if (isParameter) {
130+
posParams[part.substring(1)] = segment;
131+
} else if (part !== segment.path) {
132+
return false;
133+
}
134+
}
135+
return true;
136+
}
137+
120138
/**
121139
* Matches the route configuration (`route`) against the actual URL (`segments`).
122140
*
@@ -138,34 +156,73 @@ export function defaultUrlMatcher(
138156
route: Route,
139157
): UrlMatchResult | null {
140158
const parts = route.path!.split('/');
159+
const wildcardIndex = parts.indexOf('**');
160+
if (wildcardIndex === -1) {
161+
// No wildcard, use original logic
162+
if (parts.length > segments.length) {
163+
// The actual URL is shorter than the config, no match
164+
return null;
165+
}
141166

142-
if (parts.length > segments.length) {
143-
// The actual URL is shorter than the config, no match
167+
if (
168+
route.pathMatch === 'full' &&
169+
(segmentGroup.hasChildren() || parts.length < segments.length)
170+
) {
171+
// The config is longer than the actual URL but we are looking for a full match, return null
172+
return null;
173+
}
174+
175+
const posParams: {[key: string]: UrlSegment} = {};
176+
const consumed = segments.slice(0, parts.length);
177+
if (!matchParts(parts, consumed, posParams)) {
178+
return null;
179+
}
180+
return {consumed, posParams};
181+
}
182+
183+
// Path has a wildcard.
184+
if (wildcardIndex !== parts.lastIndexOf('**')) {
185+
// We do not support more than one wildcard segment in the path
144186
return null;
145187
}
146188

189+
const pre = parts.slice(0, wildcardIndex);
190+
const post = parts.slice(wildcardIndex + 1);
191+
192+
if (pre.length + post.length > segments.length) {
193+
// The actual URL is shorter than the config, no match
194+
return null;
195+
}
147196
if (
148-
route.pathMatch === 'full' &&
149-
(segmentGroup.hasChildren() || parts.length < segments.length)
197+
// If the wildcard is not at the end of the path, it must match at least one segment.
198+
// e.g. `foo/**/bar` does not match `foo/bar`.
199+
wildcardIndex > -1 &&
200+
pre.length > 0 &&
201+
post.length > 0 &&
202+
pre.length + post.length === segments.length
150203
) {
204+
return null;
205+
}
206+
207+
if (route.pathMatch === 'full' && segmentGroup.hasChildren() && route.path !== '**') {
151208
// The config is longer than the actual URL but we are looking for a full match, return null
152209
return null;
153210
}
154211

155212
const posParams: {[key: string]: UrlSegment} = {};
156213

157-
// Check each config part against the actual URL
158-
for (let index = 0; index < parts.length; index++) {
159-
const part = parts[index];
160-
const segment = segments[index];
161-
const isParameter = part[0] === ':';
162-
if (isParameter) {
163-
posParams[part.substring(1)] = segment;
164-
} else if (part !== segment.path) {
165-
// The actual URL part does not match the config, no match
166-
return null;
167-
}
214+
// Match the segments before the wildcard
215+
if (!matchParts(pre, segments.slice(0, pre.length), posParams)) {
216+
return null;
217+
}
218+
// Match the segments after the wildcard
219+
if (!matchParts(post, segments.slice(segments.length - post.length), posParams)) {
220+
return null;
168221
}
169222

170-
return {consumed: segments.slice(0, parts.length), posParams};
223+
// TODO(atscott): put the wildcard segments into a _splat param.
224+
// this would require a breaking change to the UrlMatchResult to allow UrlSegment[]
225+
// since the splat could be multiple segments.
226+
227+
return {consumed: segments, posParams};
171228
}

packages/router/src/utils/config_matching.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,6 @@ export function match(
6060
route: Route,
6161
segments: UrlSegment[],
6262
): MatchResult {
63-
if (route.path === '**') {
64-
return createWildcardMatchResult(segments);
65-
}
66-
6763
if (route.path === '') {
6864
if (route.pathMatch === 'full' && (segmentGroup.hasChildren() || segments.length > 0)) {
6965
return {...noMatch};
@@ -101,16 +97,6 @@ export function match(
10197
};
10298
}
10399

104-
function createWildcardMatchResult(segments: UrlSegment[]): MatchResult {
105-
return {
106-
matched: true,
107-
parameters: segments.length > 0 ? last(segments)!.parameters : {},
108-
consumedSegments: segments,
109-
remainingSegments: [],
110-
positionalParamSegments: {},
111-
};
112-
}
113-
114100
export function split(
115101
segmentGroup: UrlSegmentGroup,
116102
consumedSegments: UrlSegment[],

packages/router/test/recognize.spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,79 @@ describe('recognize', () => {
585585
const s = await recognize([{path: '**', component: ComponentA}], 'a/b/c/d;a1=11');
586586
checkActivatedRoute(s.root.firstChild!, 'a/b/c/d', {a1: '11'}, ComponentA);
587587
});
588+
589+
it(`should match '**' with pathMatch: 'full' to a non-empty path`, async () => {
590+
const s = await recognize([{path: '**', pathMatch: 'full', component: ComponentA}], 'a/b/c');
591+
checkActivatedRoute(s.root.firstChild!, 'a/b/c', {}, ComponentA);
592+
});
593+
594+
it(`should match '**' with pathMatch: 'full' to an empty path`, async () => {
595+
const s = await recognize([{path: '**', pathMatch: 'full', component: ComponentA}], '');
596+
checkActivatedRoute(s.root.firstChild!, '', {}, ComponentA);
597+
});
598+
599+
// Note that we do not support named children under a wildcard, though we _could_ potentially do this
600+
// as long as the children are all named outlets (non-primary). The primary outlet would be consumed by the wildcard.
601+
// This test is to ensure we do not break the matcher completely when there are children under a wildcard.
602+
it(`should match '**' with pathMatch: 'full' even when there are named outlets`, async () => {
603+
const s = await recognize(
604+
[{path: '**', pathMatch: 'full', component: ComponentA}],
605+
'a/(aux:c)',
606+
);
607+
checkActivatedRoute(s.root.firstChild!, 'a', {}, ComponentA);
608+
});
609+
610+
it('should support segments after a wildcard', async () => {
611+
const s = await recognize(
612+
[
613+
{
614+
path: 'a',
615+
component: ComponentA,
616+
children: [
617+
{
618+
path: '**/b',
619+
component: ComponentB,
620+
},
621+
],
622+
},
623+
],
624+
'a/1/2/b',
625+
);
626+
const a = s.root.firstChild!;
627+
checkActivatedRoute(a, 'a', {}, ComponentA);
628+
629+
const wildcard = a.firstChild!;
630+
checkActivatedRoute(wildcard, '1/2/b', {}, ComponentB);
631+
});
632+
633+
describe('with segments after', () => {
634+
const recognizer = (url: string) => {
635+
const config = [
636+
{
637+
path: 'foo/**/bar',
638+
component: ComponentA,
639+
},
640+
];
641+
return recognize(config, url);
642+
};
643+
644+
it('matches a url with one segment for the wildcard', async () => {
645+
const s = await recognizer('foo/a/bar');
646+
checkActivatedRoute(s.root.firstChild!, 'foo/a/bar', {}, ComponentA);
647+
});
648+
649+
it('matches a url with multiple segments for the wildcard', async () => {
650+
const s = await recognizer('foo/a/b/c/bar');
651+
checkActivatedRoute(s.root.firstChild!, 'foo/a/b/c/bar', {}, ComponentA);
652+
});
653+
it('does not match a url with no segments for the wildcard', async () => {
654+
await expectAsync(recognizer('foo/bar')).toBeRejected();
655+
});
656+
657+
it('does not match a url with a wrong suffix', async () => {
658+
await expectAsync(recognizer('foo/a/b/baz')).toBeRejected();
659+
});
660+
});
588661
});
589662

590663
describe('componentless routes', () => {

0 commit comments

Comments
 (0)