Skip to content

Commit 698d16e

Browse files
committed
fix(@angular/ssr): support getPrerenderParams for wildcard routes
Handle `getPrerenderParams` return values when used with wildcard route paths, including support for combined routes like `/product/:id/**`. Supports returning an array of path segments (e.g., `['category', '123']`) for `**` routes and dynamic segments combined with catch-all routes. This enables more flexible prerendering configurations in server routes, including handling specific paths such as `/product/1/laptop/123`. Example: ```ts { path: '/product/:id/**', renderMode: RenderMode.Prerender, async getPrerenderParams() { return [ { id: '1', '**': '/laptop/123' }, { id: '2', '**': '/laptop/456' } ]; } } ``` Closes #30035
1 parent ad77837 commit 698d16e

File tree

3 files changed

+98
-40
lines changed

3 files changed

+98
-40
lines changed

packages/angular/ssr/src/routes/ng-routes.ts

+44-18
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ interface Route extends AngularRoute {
4646
*/
4747
const MODULE_PRELOAD_MAX = 10;
4848

49+
/**
50+
* Regular expression to match a catch-all route pattern in a URL path,
51+
* specifically one that ends with '/**'.
52+
*/
53+
const CATCH_ALL_REGEXP = /\/(\*\*)$/;
54+
4955
/**
5056
* Regular expression to match segments preceded by a colon in a string.
5157
*/
@@ -391,7 +397,8 @@ async function* handleSSGRoute(
391397
meta.redirectTo = resolveRedirectTo(currentRoutePath, redirectTo);
392398
}
393399

394-
if (!URL_PARAMETER_REGEXP.test(currentRoutePath)) {
400+
const isCatchAllRoute = CATCH_ALL_REGEXP.test(currentRoutePath);
401+
if (!isCatchAllRoute && !URL_PARAMETER_REGEXP.test(currentRoutePath)) {
395402
// Route has no parameters
396403
yield {
397404
...meta,
@@ -415,7 +422,9 @@ async function* handleSSGRoute(
415422

416423
if (serverConfigRouteTree) {
417424
// Automatically resolve dynamic parameters for nested routes.
418-
const catchAllRoutePath = joinUrlParts(currentRoutePath, '**');
425+
const catchAllRoutePath = isCatchAllRoute
426+
? currentRoutePath
427+
: joinUrlParts(currentRoutePath, '**');
419428
const match = serverConfigRouteTree.match(catchAllRoutePath);
420429
if (match && match.renderMode === RenderMode.Prerender && !('getPrerenderParams' in match)) {
421430
serverConfigRouteTree.insert(catchAllRoutePath, {
@@ -429,20 +438,9 @@ async function* handleSSGRoute(
429438
const parameters = await runInInjectionContext(parentInjector, () => getPrerenderParams());
430439
try {
431440
for (const params of parameters) {
432-
const routeWithResolvedParams = currentRoutePath.replace(URL_PARAMETER_REGEXP, (match) => {
433-
const parameterName = match.slice(1);
434-
const value = params[parameterName];
435-
if (typeof value !== 'string') {
436-
throw new Error(
437-
`The 'getPrerenderParams' function defined for the '${stripLeadingSlash(currentRoutePath)}' route ` +
438-
`returned a non-string value for parameter '${parameterName}'. ` +
439-
`Please make sure the 'getPrerenderParams' function returns values for all parameters ` +
440-
'specified in this route.',
441-
);
442-
}
443-
444-
return value;
445-
});
441+
const routeWithResolvedParams = currentRoutePath
442+
.replace(URL_PARAMETER_REGEXP, handlePrerenderParamsReplacement(params, currentRoutePath))
443+
.replace(CATCH_ALL_REGEXP, handlePrerenderParamsReplacement(params, currentRoutePath));
446444

447445
yield {
448446
...meta,
@@ -473,6 +471,34 @@ async function* handleSSGRoute(
473471
}
474472
}
475473

474+
/**
475+
* Creates a replacer function used for substituting parameter placeholders in a route path
476+
* with their corresponding values provided in the `params` object.
477+
*
478+
* @param params - An object mapping parameter names to their string values.
479+
* @param currentRoutePath - The current route path, used for constructing error messages.
480+
* @returns A function that replaces a matched parameter placeholder (e.g., ':id') with its corresponding value.
481+
*/
482+
function handlePrerenderParamsReplacement(
483+
params: Record<string, string>,
484+
currentRoutePath: string,
485+
): (substring: string, ...args: unknown[]) => string {
486+
return (match) => {
487+
const parameterName = match.slice(1);
488+
const value = params[parameterName];
489+
if (typeof value !== 'string') {
490+
throw new Error(
491+
`The 'getPrerenderParams' function defined for the '${stripLeadingSlash(currentRoutePath)}' route ` +
492+
`returned a non-string value for parameter '${parameterName}'. ` +
493+
`Please make sure the 'getPrerenderParams' function returns values for all parameters ` +
494+
'specified in this route.',
495+
);
496+
}
497+
498+
return parameterName === '**' ? `/${value}` : value;
499+
};
500+
}
501+
476502
/**
477503
* Resolves the `redirectTo` property for a given route.
478504
*
@@ -530,9 +556,9 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi
530556
continue;
531557
}
532558

533-
if (path.includes('*') && 'getPrerenderParams' in metadata) {
559+
if ('getPrerenderParams' in metadata && (path.includes('/*/') || path.endsWith('/*'))) {
534560
errors.push(
535-
`Invalid '${path}' route configuration: 'getPrerenderParams' cannot be used with a '*' or '**' route.`,
561+
`Invalid '${path}' route configuration: 'getPrerenderParams' cannot be used with a '*' route.`,
536562
);
537563
continue;
538564
}

packages/angular/ssr/src/routes/route-config.ts

+14
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ export interface ServerRoutePrerenderWithParams extends Omit<ServerRoutePrerende
142142
* A function that returns a Promise resolving to an array of objects, each representing a route path with URL parameters.
143143
* This function runs in the injector context, allowing access to Angular services and dependencies.
144144
*
145+
* It also works for catch-all routes (e.g., `/**`), where the parameter name will be `**` and the return value will be
146+
* the segments of the path, such as `/foo/bar`. These routes can also be combined, e.g., `/product/:id/**`,
147+
* where both a parameterized segment (`:id`) and a catch-all segment (`**`) can be used together to handle more complex paths.
148+
*
145149
* @returns A Promise resolving to an array where each element is an object with string keys (representing URL parameter names)
146150
* and string values (representing the corresponding values for those parameters in the route path).
147151
*
@@ -158,6 +162,16 @@ export interface ServerRoutePrerenderWithParams extends Omit<ServerRoutePrerende
158162
* return ids.map(id => ({ id })); // Generates paths like: [{ id: '1' }, { id: '2' }, { id: '3' }]
159163
* },
160164
* },
165+
* {
166+
* path: '/product/:id/**',
167+
* renderMode: RenderMode.Prerender,
168+
* async getPrerenderParams() {
169+
* return [
170+
* { id: '1', '**': '/laptop/123' },
171+
* { id: '2', '**': '/laptop/456' }
172+
* ]; // Generates paths like: [{ id: '1', '**': '/laptop/123' }, { id: '2', '**': '/laptop/456' }]
173+
* },
174+
* },
161175
* ];
162176
* ```
163177
*/

packages/angular/ssr/test/routes/ng-routes_spec.ts

+40-22
Original file line numberDiff line numberDiff line change
@@ -68,26 +68,6 @@ describe('extractRoutesAndCreateRouteTree', () => {
6868
);
6969
});
7070

71-
it("should error when 'getPrerenderParams' is used with a '**' route", async () => {
72-
setAngularAppTestingManifest(
73-
[{ path: 'home', component: DummyComponent }],
74-
[
75-
{
76-
path: '**',
77-
renderMode: RenderMode.Prerender,
78-
getPrerenderParams() {
79-
return Promise.resolve([]);
80-
},
81-
},
82-
],
83-
);
84-
85-
const { errors } = await extractRoutesAndCreateRouteTree({ url });
86-
expect(errors[0]).toContain(
87-
"Invalid '**' route configuration: 'getPrerenderParams' cannot be used with a '*' or '**' route.",
88-
);
89-
});
90-
9171
it("should error when 'getPrerenderParams' is used with a '*' route", async () => {
9272
setAngularAppTestingManifest(
9373
[{ path: 'invalid/:id', component: DummyComponent }],
@@ -104,7 +84,7 @@ describe('extractRoutesAndCreateRouteTree', () => {
10484

10585
const { errors } = await extractRoutesAndCreateRouteTree({ url });
10686
expect(errors[0]).toContain(
107-
"Invalid 'invalid/*' route configuration: 'getPrerenderParams' cannot be used with a '*' or '**' route.",
87+
"Invalid 'invalid/*' route configuration: 'getPrerenderParams' cannot be used with a '*' route.",
10888
);
10989
});
11090

@@ -259,7 +239,7 @@ describe('extractRoutesAndCreateRouteTree', () => {
259239
]);
260240
});
261241

262-
it('should resolve parameterized routes for SSG and not add a fallback route if fallback is None', async () => {
242+
it('should resolve parameterized routes for SSG add a fallback route if fallback is Server', async () => {
263243
setAngularAppTestingManifest(
264244
[
265245
{ path: 'home', component: DummyComponent },
@@ -296,6 +276,44 @@ describe('extractRoutesAndCreateRouteTree', () => {
296276
]);
297277
});
298278

279+
it('should resolve catch all routes for SSG and not add a fallback route if fallback is Server', async () => {
280+
setAngularAppTestingManifest(
281+
[
282+
{ path: 'home', component: DummyComponent },
283+
{ path: 'user/:name/**', component: DummyComponent },
284+
],
285+
[
286+
{
287+
path: 'user/:name/**',
288+
renderMode: RenderMode.Prerender,
289+
fallback: PrerenderFallback.Server,
290+
async getPrerenderParams() {
291+
return [
292+
{ name: 'joe', '**': 'role/admin' },
293+
{ name: 'jane', '**': 'role/writer' },
294+
];
295+
},
296+
},
297+
{ path: '**', renderMode: RenderMode.Server },
298+
],
299+
);
300+
301+
const { routeTree, errors } = await extractRoutesAndCreateRouteTree({
302+
url,
303+
invokeGetPrerenderParams: true,
304+
});
305+
expect(errors).toHaveSize(0);
306+
expect(routeTree.toObject()).toEqual([
307+
{ route: '/home', renderMode: RenderMode.Server },
308+
{ route: '/user/joe/role/admin', renderMode: RenderMode.Prerender },
309+
{
310+
route: '/user/jane/role/writer',
311+
renderMode: RenderMode.Prerender,
312+
},
313+
{ route: '/user/*/**', renderMode: RenderMode.Server },
314+
]);
315+
});
316+
299317
it('should extract nested redirects that are not explicitly defined.', async () => {
300318
setAngularAppTestingManifest(
301319
[

0 commit comments

Comments
 (0)