Skip to content

fix(@angular/ssr): support getPrerenderParams for wildcard routes #30072

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion goldens/public-api/angular/ssr/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export interface ServerRoutePrerender extends Omit<ServerRouteCommon, 'status'>
// @public
export interface ServerRoutePrerenderWithParams extends Omit<ServerRoutePrerender, 'fallback'> {
fallback?: PrerenderFallback;
getPrerenderParams: () => Promise<Record<string, string>[]>;
getPrerenderParams: () => Promise<(string[] | Record<string, string>)[]>;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another option would be to add a different option something like getPrerenderPath or getPrerenderSegments

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO, reusing getPrerenderParams is reasonable. I think the intent behind this function is still the same whether it's filling in :foo or **. Though I'm a little worried that supporting the two in an xor fashion might be confusing for developers. If we can find a reasonable way to support :foo/**, then using a single function LGTM.

}

// @public
Expand Down
52 changes: 38 additions & 14 deletions packages/angular/ssr/src/routes/ng-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ async function* handleSSGRoute(

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

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

if (!URL_PARAMETER_REGEXP.test(currentRoutePath)) {
if (
(isCatchAllRoute && !getPrerenderParams) ||
(!isCatchAllRoute && !URL_PARAMETER_REGEXP.test(currentRoutePath))
) {
// Route has no parameters
yield {
...meta,
Expand All @@ -415,7 +419,9 @@ async function* handleSSGRoute(

if (serverConfigRouteTree) {
// Automatically resolve dynamic parameters for nested routes.
const catchAllRoutePath = joinUrlParts(currentRoutePath, '**');
const catchAllRoutePath = isCatchAllRoute
? currentRoutePath
: joinUrlParts(currentRoutePath, '**');
const match = serverConfigRouteTree.match(catchAllRoutePath);
if (match && match.renderMode === RenderMode.Prerender && !('getPrerenderParams' in match)) {
serverConfigRouteTree.insert(catchAllRoutePath, {
Expand All @@ -429,20 +435,38 @@ async function* handleSSGRoute(
const parameters = await runInInjectionContext(parentInjector, () => getPrerenderParams());
try {
for (const params of parameters) {
const routeWithResolvedParams = currentRoutePath.replace(URL_PARAMETER_REGEXP, (match) => {
const parameterName = match.slice(1);
const value = params[parameterName];
if (typeof value !== 'string') {
const isParamsArray = Array.isArray(params);

if (isParamsArray) {
if (!isCatchAllRoute) {
throw new Error(
`The 'getPrerenderParams' function defined for the '${stripLeadingSlash(currentRoutePath)}' route ` +
`returned a non-string value for parameter '${parameterName}'. ` +
`Please make sure the 'getPrerenderParams' function returns values for all parameters ` +
'specified in this route.',
`The 'getPrerenderParams' function for the '${stripLeadingSlash(currentRoutePath)}' ` +
`route returned an array '${JSON.stringify(params)}', which is not valid for catch-all routes.`,
);
}
} else if (isCatchAllRoute) {
throw new Error(
`The 'getPrerenderParams' function for the '${stripLeadingSlash(currentRoutePath)}' ` +
`route returned an object '${JSON.stringify(params)}', which is not valid for parameterized routes.`,
);
}

return value;
});
const routeWithResolvedParams = isParamsArray
? currentRoutePath.replace('**', params.join('/'))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider: What if I render ['foo/bar']?

I remember we had a discussion on this previously, do we need to escape / characters returned by getPrerenderParams? Or did we decide not to do that?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No we are not escaping any character. foo/bar will be rendered it's a valid URL.

: currentRoutePath.replace(URL_PARAMETER_REGEXP, (match) => {
const parameterName = match.slice(1);
const value = params[parameterName];
if (typeof value !== 'string') {
throw new Error(
`The 'getPrerenderParams' function defined for the '${stripLeadingSlash(currentRoutePath)}' route ` +
`returned a non-string value for parameter '${parameterName}'. ` +
`Please make sure the 'getPrerenderParams' function returns values for all parameters ` +
'specified in this route.',
);
}

return value;
});

yield {
...meta,
Expand Down Expand Up @@ -530,9 +554,9 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi
continue;
}

if (path.includes('*') && 'getPrerenderParams' in metadata) {
if ('getPrerenderParams' in metadata && (path.includes('/*/') || path.endsWith('/*'))) {
errors.push(
`Invalid '${path}' route configuration: 'getPrerenderParams' cannot be used with a '*' or '**' route.`,
`Invalid '${path}' route configuration: 'getPrerenderParams' cannot be used with a '*' route.`,
);
continue;
}
Expand Down
31 changes: 28 additions & 3 deletions packages/angular/ssr/src/routes/route-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,17 @@ export interface ServerRoutePrerenderWithParams extends Omit<ServerRoutePrerende
* A function that returns a Promise resolving to an array of objects, each representing a route path with URL parameters.
* This function runs in the injector context, allowing access to Angular services and dependencies.
*
* @returns A Promise resolving to an array where each element is an object with string keys (representing URL parameter names)
* and string values (representing the corresponding values for those parameters in the route path).
* @returns A Promise resolving to an array of values that define route parameters for prerendering:
*
* - If the route `path` contains named parameters (e.g., `/product/:id`), each element should be an object
* where keys are parameter names and values are their corresponding values.
* Example: `{ id: '123' }` results in `/product/123`.
*
* - If the route `path` uses a catch-all (`**`) or wildcard structure (e.g., `/product/**`), each element should
* be a string array representing full path segments.
* Example: `['category', '123']` results in `/product/category/123`.
*
* The array returned determines the number of prerendered routes generated for a given configuration.
*
* @example
* ```typescript
Expand All @@ -160,8 +169,24 @@ export interface ServerRoutePrerenderWithParams extends Omit<ServerRoutePrerende
* },
* ];
* ```
*
* @example
* ```typescript
* export const serverRouteConfig: ServerRoutes[] = [
* {
* path: '/product/**',
* renderMode: RenderMode.Prerender,
* async getPrerenderParams() {
* const productService = inject(ProductService);
* const ids = await productService.getIds(); // Assuming this returns ['1', '2', '3']
*
* return ids.map(id => (['category', id])); // Generates paths like: [['category', '1'], ['category', '2'], ['category', '3']]
* },
* },
* ];
* ```
*/
getPrerenderParams: () => Promise<Record<string, string>[]>;
getPrerenderParams: () => Promise<(string[] | Record<string, string>)[]>;
}

/**
Expand Down
87 changes: 76 additions & 11 deletions packages/angular/ssr/test/routes/ng-routes_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,12 @@ describe('extractRoutesAndCreateRouteTree', () => {
);
});

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

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

it("should error when 'getPrerenderParams' is used with a '*' route", async () => {
it("should throw an error when 'getPrerenderParams' returns an array for a parameterized ':param' route", async () => {
setAngularAppTestingManifest(
[{ path: 'invalid/:id', component: DummyComponent }],
[
{
path: 'invalid/*',
path: 'invalid/:param',
renderMode: RenderMode.Prerender,
getPrerenderParams() {
return Promise.resolve([]);
async getPrerenderParams() {
return [['1']];
},
},
],
);

const { errors } = await extractRoutesAndCreateRouteTree({ url });
const { errors } = await extractRoutesAndCreateRouteTree({
url,
invokeGetPrerenderParams: true,
});
expect(errors[0]).toContain(
`The 'getPrerenderParams' function for the 'invalid/:id' route returned an array '["1"]', which is not valid for catch-all routes.`,
);
});

it("should throw an error when 'getPrerenderParams' returns an object for a parameterized catch-all route", async () => {
setAngularAppTestingManifest(
[{ path: 'invalid/**', component: DummyComponent }],
[
{
path: 'invalid/**',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
return [{ param: '1' }];
},
},
],
);

const { errors } = await extractRoutesAndCreateRouteTree({
url,
invokeGetPrerenderParams: true,
});
expect(errors[0]).toContain(
"Invalid 'invalid/*' route configuration: 'getPrerenderParams' cannot be used with a '*' or '**' route.",
`The 'getPrerenderParams' function for the 'invalid/**' route returned an object '{"param":"1"}'` +
`, which is not valid for parameterized routes.`,
);
});

Expand Down Expand Up @@ -259,7 +286,7 @@ describe('extractRoutesAndCreateRouteTree', () => {
]);
});

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

it('should resolve catch all routes for SSG and not add a fallback route if fallback is Server', async () => {
setAngularAppTestingManifest(
[
{ path: 'home', component: DummyComponent },
{ path: 'user/**', component: DummyComponent },
],
[
{
path: 'user/**',
renderMode: RenderMode.Prerender,
fallback: PrerenderFallback.Server,
async getPrerenderParams() {
return [
['joe', 'role', 'admin'],
['jane', 'role', 'writer'],
];
},
},
{ path: '**', renderMode: RenderMode.Server },
],
);

const { routeTree, errors } = await extractRoutesAndCreateRouteTree({
url,
invokeGetPrerenderParams: true,
});
expect(errors).toHaveSize(0);
expect(routeTree.toObject()).toEqual([
{ route: '/home', renderMode: RenderMode.Server },
{ route: '/user/joe/role/admin', renderMode: RenderMode.Prerender },
{
route: '/user/jane/role/writer',
renderMode: RenderMode.Prerender,
},
{ route: '/user/**', renderMode: RenderMode.Server },
]);
});

it('should extract nested redirects that are not explicitly defined.', async () => {
setAngularAppTestingManifest(
[
Expand Down
Loading