Skip to content

Commit 9585353

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']]; } } ```
1 parent eeba3a8 commit 9585353

File tree

4 files changed

+103
-39
lines changed

4 files changed

+103
-39
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

+35-14
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ async function* handleSSGRoute(
391391
meta.redirectTo = resolveRedirectTo(currentRoutePath, redirectTo);
392392
}
393393

394-
if (!URL_PARAMETER_REGEXP.test(currentRoutePath)) {
394+
if (!currentRoutePath.includes('**') && !URL_PARAMETER_REGEXP.test(currentRoutePath)) {
395395
// Route has no parameters
396396
yield {
397397
...meta,
@@ -415,7 +415,9 @@ async function* handleSSGRoute(
415415

416416
if (serverConfigRouteTree) {
417417
// Automatically resolve dynamic parameters for nested routes.
418-
const catchAllRoutePath = joinUrlParts(currentRoutePath, '**');
418+
const catchAllRoutePath = currentRoutePath.endsWith('**')
419+
? currentRoutePath
420+
: joinUrlParts(currentRoutePath, '**');
419421
const match = serverConfigRouteTree.match(catchAllRoutePath);
420422
if (match && match.renderMode === RenderMode.Prerender && !('getPrerenderParams' in match)) {
421423
serverConfigRouteTree.insert(catchAllRoutePath, {
@@ -429,20 +431,39 @@ async function* handleSSGRoute(
429431
const parameters = await runInInjectionContext(parentInjector, () => getPrerenderParams());
430432
try {
431433
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') {
434+
const isWildcardRoute = currentRoutePath.includes('**');
435+
const isParamsArray = Array.isArray(params);
436+
437+
if (isParamsArray) {
438+
if (!isWildcardRoute) {
436439
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.',
440+
`The 'getPrerenderParams' function for the '${stripLeadingSlash(currentRoutePath)}' ` +
441+
`route returned an array '${JSON.stringify(params)}', which is not valid for catch-all routes.`,
441442
);
442443
}
444+
} else if (isWildcardRoute) {
445+
throw new Error(
446+
`The 'getPrerenderParams' function for the '${stripLeadingSlash(currentRoutePath)}' ` +
447+
`route returned an object '${JSON.stringify(params)}', which is not valid for parameterized routes.`,
448+
);
449+
}
443450

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

447468
yield {
448469
...meta,
@@ -530,9 +551,9 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi
530551
continue;
531552
}
532553

533-
if (path.includes('*') && 'getPrerenderParams' in metadata) {
554+
if (!path.includes('**') && path.includes('*') && 'getPrerenderParams' in metadata) {
534555
errors.push(
535-
`Invalid '${path}' route configuration: 'getPrerenderParams' cannot be used with a '*' or '**' route.`,
556+
`Invalid '${path}' route configuration: 'getPrerenderParams' cannot be used with a '*' route.`,
536557
);
537558
continue;
538559
}

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

+39-21
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

@@ -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/**', component: DummyComponent },
284+
],
285+
[
286+
{
287+
path: 'user/**',
288+
renderMode: RenderMode.Prerender,
289+
fallback: PrerenderFallback.Server,
290+
async getPrerenderParams() {
291+
return [
292+
['joe', 'role', 'admin'],
293+
['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)