Skip to content

Commit 5cd43cf

Browse files
committed
fix(@angular/ssr): support getPrerenderParams for wildcard routes
Handle `getPrerenderParams` return values when used with wildcard route paths. Supports returning an array of path segments (e.g., `['category', '123']`) for `**` routes. This enables more flexible prerendering configurations in server routes. Example: ```ts { path: '/product/**', renderMode: RenderMode.Prerender, async getPrerenderParams() { return [['category', '1'], ['category', '2']]; } } ``` Closes #30035
1 parent f669657 commit 5cd43cf

File tree

4 files changed

+143
-29
lines changed

4 files changed

+143
-29
lines changed

Diff for: goldens/public-api/angular/ssr/index.api.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export interface ServerRoutePrerender extends Omit<ServerRouteCommon, 'status'>
6363
// @public
6464
export interface ServerRoutePrerenderWithParams extends Omit<ServerRoutePrerender, 'fallback'> {
6565
fallback?: PrerenderFallback;
66-
getPrerenderParams: () => Promise<Record<string, string>[]>;
66+
getPrerenderParams: () => Promise<(string[] | Record<string, string>)[]>;
6767
}
6868

6969
// @public

Diff for: packages/angular/ssr/src/routes/ng-routes.ts

+38-14
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,7 @@ async function* handleSSGRoute(
382382

383383
const { route: currentRoutePath, fallback, ...meta } = metadata;
384384
const getPrerenderParams = 'getPrerenderParams' in meta ? meta.getPrerenderParams : undefined;
385+
const isCatchAllRoute = currentRoutePath.endsWith('**');
385386

386387
if ('getPrerenderParams' in meta) {
387388
delete meta['getPrerenderParams'];
@@ -391,7 +392,10 @@ async function* handleSSGRoute(
391392
meta.redirectTo = resolveRedirectTo(currentRoutePath, redirectTo);
392393
}
393394

394-
if (!URL_PARAMETER_REGEXP.test(currentRoutePath)) {
395+
if (
396+
(isCatchAllRoute && !getPrerenderParams) ||
397+
(!isCatchAllRoute && !URL_PARAMETER_REGEXP.test(currentRoutePath))
398+
) {
395399
// Route has no parameters
396400
yield {
397401
...meta,
@@ -415,7 +419,9 @@ async function* handleSSGRoute(
415419

416420
if (serverConfigRouteTree) {
417421
// Automatically resolve dynamic parameters for nested routes.
418-
const catchAllRoutePath = joinUrlParts(currentRoutePath, '**');
422+
const catchAllRoutePath = isCatchAllRoute
423+
? currentRoutePath
424+
: joinUrlParts(currentRoutePath, '**');
419425
const match = serverConfigRouteTree.match(catchAllRoutePath);
420426
if (match && match.renderMode === RenderMode.Prerender && !('getPrerenderParams' in match)) {
421427
serverConfigRouteTree.insert(catchAllRoutePath, {
@@ -429,20 +435,38 @@ async function* handleSSGRoute(
429435
const parameters = await runInInjectionContext(parentInjector, () => getPrerenderParams());
430436
try {
431437
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') {
438+
const isParamsArray = Array.isArray(params);
439+
440+
if (isParamsArray) {
441+
if (!isCatchAllRoute) {
436442
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.',
443+
`The 'getPrerenderParams' function for the '${stripLeadingSlash(currentRoutePath)}' ` +
444+
`route returned an array '${JSON.stringify(params)}', which is not valid for catch-all routes.`,
441445
);
442446
}
447+
} else if (isCatchAllRoute) {
448+
throw new Error(
449+
`The 'getPrerenderParams' function for the '${stripLeadingSlash(currentRoutePath)}' ` +
450+
`route returned an object '${JSON.stringify(params)}', which is not valid for parameterized routes.`,
451+
);
452+
}
443453

444-
return value;
445-
});
454+
const routeWithResolvedParams = isParamsArray
455+
? currentRoutePath.replace('**', params.join('/'))
456+
: currentRoutePath.replace(URL_PARAMETER_REGEXP, (match) => {
457+
const parameterName = match.slice(1);
458+
const value = params[parameterName];
459+
if (typeof value !== 'string') {
460+
throw new Error(
461+
`The 'getPrerenderParams' function defined for the '${stripLeadingSlash(currentRoutePath)}' route ` +
462+
`returned a non-string value for parameter '${parameterName}'. ` +
463+
`Please make sure the 'getPrerenderParams' function returns values for all parameters ` +
464+
'specified in this route.',
465+
);
466+
}
467+
468+
return value;
469+
});
446470

447471
yield {
448472
...meta,
@@ -530,9 +554,9 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi
530554
continue;
531555
}
532556

533-
if (path.includes('*') && 'getPrerenderParams' in metadata) {
557+
if ('getPrerenderParams' in metadata && (path.includes('/*/') || path.endsWith('/*'))) {
534558
errors.push(
535-
`Invalid '${path}' route configuration: 'getPrerenderParams' cannot be used with a '*' or '**' route.`,
559+
`Invalid '${path}' route configuration: 'getPrerenderParams' cannot be used with a '*' route.`,
536560
);
537561
continue;
538562
}

Diff for: packages/angular/ssr/src/routes/route-config.ts

+28-3
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,17 @@ 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-
* @returns A Promise resolving to an array where each element is an object with string keys (representing URL parameter names)
146-
* and string values (representing the corresponding values for those parameters in the route path).
145+
* @returns A Promise resolving to an array of values that define route parameters for prerendering:
146+
*
147+
* - If the route `path` contains named parameters (e.g., `/product/:id`), each element should be an object
148+
* where keys are parameter names and values are their corresponding values.
149+
* Example: `{ id: '123' }` results in `/product/123`.
150+
*
151+
* - If the route `path` uses a catch-all (`**`) or wildcard structure (e.g., `/product/**`), each element should
152+
* be a string array representing full path segments.
153+
* Example: `['category', '123']` results in `/product/category/123`.
154+
*
155+
* The array returned determines the number of prerendered routes generated for a given configuration.
147156
*
148157
* @example
149158
* ```typescript
@@ -160,8 +169,24 @@ export interface ServerRoutePrerenderWithParams extends Omit<ServerRoutePrerende
160169
* },
161170
* ];
162171
* ```
172+
*
173+
* @example
174+
* ```typescript
175+
* export const serverRouteConfig: ServerRoutes[] = [
176+
* {
177+
* path: '/product/**',
178+
* renderMode: RenderMode.Prerender,
179+
* async getPrerenderParams() {
180+
* const productService = inject(ProductService);
181+
* const ids = await productService.getIds(); // Assuming this returns ['1', '2', '3']
182+
*
183+
* return ids.map(id => (['category', id])); // Generates paths like: [['category', '1'], ['category', '2'], ['category', '3']]
184+
* },
185+
* },
186+
* ];
187+
* ```
163188
*/
164-
getPrerenderParams: () => Promise<Record<string, string>[]>;
189+
getPrerenderParams: () => Promise<(string[] | Record<string, string>)[]>;
165190
}
166191

167192
/**

Diff for: packages/angular/ssr/test/routes/ng-routes_spec.ts

+76-11
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,12 @@ describe('extractRoutesAndCreateRouteTree', () => {
6868
);
6969
});
7070

71-
it("should error when 'getPrerenderParams' is used with a '**' route", async () => {
71+
it("should error when 'getPrerenderParams' is used with a '*' route", async () => {
7272
setAngularAppTestingManifest(
73-
[{ path: 'home', component: DummyComponent }],
73+
[{ path: 'invalid/:id', component: DummyComponent }],
7474
[
7575
{
76-
path: '**',
76+
path: 'invalid/*',
7777
renderMode: RenderMode.Prerender,
7878
getPrerenderParams() {
7979
return Promise.resolve([]);
@@ -84,27 +84,54 @@ describe('extractRoutesAndCreateRouteTree', () => {
8484

8585
const { errors } = await extractRoutesAndCreateRouteTree({ url });
8686
expect(errors[0]).toContain(
87-
"Invalid '**' route configuration: 'getPrerenderParams' cannot be used with a '*' or '**' route.",
87+
"Invalid 'invalid/*' route configuration: 'getPrerenderParams' cannot be used with a '*' route.",
8888
);
8989
});
9090

91-
it("should error when 'getPrerenderParams' is used with a '*' route", async () => {
91+
it("should throw an error when 'getPrerenderParams' returns an array for a parameterized ':param' route", async () => {
9292
setAngularAppTestingManifest(
9393
[{ path: 'invalid/:id', component: DummyComponent }],
9494
[
9595
{
96-
path: 'invalid/*',
96+
path: 'invalid/:param',
9797
renderMode: RenderMode.Prerender,
98-
getPrerenderParams() {
99-
return Promise.resolve([]);
98+
async getPrerenderParams() {
99+
return [['1']];
100100
},
101101
},
102102
],
103103
);
104104

105-
const { errors } = await extractRoutesAndCreateRouteTree({ url });
105+
const { errors } = await extractRoutesAndCreateRouteTree({
106+
url,
107+
invokeGetPrerenderParams: true,
108+
});
109+
expect(errors[0]).toContain(
110+
`The 'getPrerenderParams' function for the 'invalid/:id' route returned an array '["1"]', which is not valid for catch-all routes.`,
111+
);
112+
});
113+
114+
it("should throw an error when 'getPrerenderParams' returns an object for a parameterized catch-all route", async () => {
115+
setAngularAppTestingManifest(
116+
[{ path: 'invalid/**', component: DummyComponent }],
117+
[
118+
{
119+
path: 'invalid/**',
120+
renderMode: RenderMode.Prerender,
121+
async getPrerenderParams() {
122+
return [{ param: '1' }];
123+
},
124+
},
125+
],
126+
);
127+
128+
const { errors } = await extractRoutesAndCreateRouteTree({
129+
url,
130+
invokeGetPrerenderParams: true,
131+
});
106132
expect(errors[0]).toContain(
107-
"Invalid 'invalid/*' route configuration: 'getPrerenderParams' cannot be used with a '*' or '**' route.",
133+
`The 'getPrerenderParams' function for the 'invalid/**' route returned an object '{"param":"1"}'` +
134+
`, which is not valid for parameterized routes.`,
108135
);
109136
});
110137

@@ -259,7 +286,7 @@ describe('extractRoutesAndCreateRouteTree', () => {
259286
]);
260287
});
261288

262-
it('should resolve parameterized routes for SSG and not add a fallback route if fallback is None', async () => {
289+
it('should resolve parameterized routes for SSG add a fallback route if fallback is Server', async () => {
263290
setAngularAppTestingManifest(
264291
[
265292
{ path: 'home', component: DummyComponent },
@@ -296,6 +323,44 @@ describe('extractRoutesAndCreateRouteTree', () => {
296323
]);
297324
});
298325

326+
it('should resolve catch all routes for SSG and not add a fallback route if fallback is Server', async () => {
327+
setAngularAppTestingManifest(
328+
[
329+
{ path: 'home', component: DummyComponent },
330+
{ path: 'user/**', component: DummyComponent },
331+
],
332+
[
333+
{
334+
path: 'user/**',
335+
renderMode: RenderMode.Prerender,
336+
fallback: PrerenderFallback.Server,
337+
async getPrerenderParams() {
338+
return [
339+
['joe', 'role', 'admin'],
340+
['jane', 'role', 'writer'],
341+
];
342+
},
343+
},
344+
{ path: '**', renderMode: RenderMode.Server },
345+
],
346+
);
347+
348+
const { routeTree, errors } = await extractRoutesAndCreateRouteTree({
349+
url,
350+
invokeGetPrerenderParams: true,
351+
});
352+
expect(errors).toHaveSize(0);
353+
expect(routeTree.toObject()).toEqual([
354+
{ route: '/home', renderMode: RenderMode.Server },
355+
{ route: '/user/joe/role/admin', renderMode: RenderMode.Prerender },
356+
{
357+
route: '/user/jane/role/writer',
358+
renderMode: RenderMode.Prerender,
359+
},
360+
{ route: '/user/**', renderMode: RenderMode.Server },
361+
]);
362+
});
363+
299364
it('should extract nested redirects that are not explicitly defined.', async () => {
300365
setAngularAppTestingManifest(
301366
[

0 commit comments

Comments
 (0)