Skip to content

Commit 501f092

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 angular#30035 (cherry picked from commit cb3446e)
1 parent 36d2644 commit 501f092

File tree

3 files changed

+103
-41
lines changed

3 files changed

+103
-41
lines changed

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

+48-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,11 @@ 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 (
402+
(isCatchAllRoute && !getPrerenderParams) ||
403+
(!isCatchAllRoute && !URL_PARAMETER_REGEXP.test(currentRoutePath))
404+
) {
395405
// Route has no parameters
396406
yield {
397407
...meta,
@@ -415,7 +425,9 @@ async function* handleSSGRoute(
415425

416426
if (serverConfigRouteTree) {
417427
// Automatically resolve dynamic parameters for nested routes.
418-
const catchAllRoutePath = joinUrlParts(currentRoutePath, '**');
428+
const catchAllRoutePath = isCatchAllRoute
429+
? currentRoutePath
430+
: joinUrlParts(currentRoutePath, '**');
419431
const match = serverConfigRouteTree.match(catchAllRoutePath);
420432
if (match && match.renderMode === RenderMode.Prerender && !('getPrerenderParams' in match)) {
421433
serverConfigRouteTree.insert(catchAllRoutePath, {
@@ -429,20 +441,10 @@ async function* handleSSGRoute(
429441
const parameters = await runInInjectionContext(parentInjector, () => getPrerenderParams());
430442
try {
431443
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-
});
444+
const replacer = handlePrerenderParamsReplacement(params, currentRoutePath);
445+
const routeWithResolvedParams = currentRoutePath
446+
.replace(URL_PARAMETER_REGEXP, replacer)
447+
.replace(CATCH_ALL_REGEXP, replacer);
446448

447449
yield {
448450
...meta,
@@ -473,6 +475,34 @@ async function* handleSSGRoute(
473475
}
474476
}
475477

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

533-
if (path.includes('*') && 'getPrerenderParams' in metadata) {
563+
if ('getPrerenderParams' in metadata && (path.includes('/*/') || path.endsWith('/*'))) {
534564
errors.push(
535-
`Invalid '${path}' route configuration: 'getPrerenderParams' cannot be used with a '*' or '**' route.`,
565+
`Invalid '${path}' route configuration: 'getPrerenderParams' cannot be used with a '*' route.`,
536566
);
537567
continue;
538568
}

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

+15-1
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ export interface ServerRoutePrerenderWithParams extends Omit<ServerRoutePrerende
146146
* A function that returns a Promise resolving to an array of objects, each representing a route path with URL parameters.
147147
* This function runs in the injector context, allowing access to Angular services and dependencies.
148148
*
149+
* It also works for catch-all routes (e.g., `/**`), where the parameter name will be `**` and the return value will be
150+
* the segments of the path, such as `/foo/bar`. These routes can also be combined, e.g., `/product/:id/**`,
151+
* where both a parameterized segment (`:id`) and a catch-all segment (`**`) can be used together to handle more complex paths.
152+
*
149153
* @returns A Promise resolving to an array where each element is an object with string keys (representing URL parameter names)
150154
* and string values (representing the corresponding values for those parameters in the route path).
151155
*
@@ -159,7 +163,17 @@ export interface ServerRoutePrerenderWithParams extends Omit<ServerRoutePrerende
159163
* const productService = inject(ProductService);
160164
* const ids = await productService.getIds(); // Assuming this returns ['1', '2', '3']
161165
*
162-
* return ids.map(id => ({ id })); // Generates paths like: [{ id: '1' }, { id: '2' }, { id: '3' }]
166+
* return ids.map(id => ({ id })); // Generates paths like: ['product/1', 'product/2', 'product/3']
167+
* },
168+
* },
169+
* {
170+
* path: '/product/:id/**',
171+
* renderMode: RenderMode.Prerender,
172+
* async getPrerenderParams() {
173+
* return [
174+
* { id: '1', '**': 'laptop/3' },
175+
* { id: '2', '**': 'laptop/4' }
176+
* ]; // Generates paths like: ['product/1/laptop/3', 'product/2/laptop/4']
163177
* },
164178
* },
165179
* ];

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 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)