Skip to content

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

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

Merged
merged 1 commit into from
Apr 23, 2025
Merged
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
66 changes: 48 additions & 18 deletions packages/angular/ssr/src/routes/ng-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ interface Route extends AngularRoute {
*/
const MODULE_PRELOAD_MAX = 10;

/**
* Regular expression to match a catch-all route pattern in a URL path,
* specifically one that ends with '/**'.
*/
const CATCH_ALL_REGEXP = /\/(\*\*)$/;

/**
* Regular expression to match segments preceded by a colon in a string.
*/
Expand Down Expand Up @@ -391,7 +397,11 @@ async function* handleSSGRoute(
meta.redirectTo = resolveRedirectTo(currentRoutePath, redirectTo);
}

if (!URL_PARAMETER_REGEXP.test(currentRoutePath)) {
const isCatchAllRoute = CATCH_ALL_REGEXP.test(currentRoutePath);
if (
(isCatchAllRoute && !getPrerenderParams) ||
(!isCatchAllRoute && !URL_PARAMETER_REGEXP.test(currentRoutePath))
) {
// Route has no parameters
yield {
...meta,
Expand All @@ -415,7 +425,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 +441,10 @@ 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') {
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;
});
const replacer = handlePrerenderParamsReplacement(params, currentRoutePath);
const routeWithResolvedParams = currentRoutePath
.replace(URL_PARAMETER_REGEXP, replacer)
.replace(CATCH_ALL_REGEXP, replacer);

yield {
...meta,
Expand Down Expand Up @@ -473,6 +475,34 @@ async function* handleSSGRoute(
}
}

/**
* Creates a replacer function used for substituting parameter placeholders in a route path
* with their corresponding values provided in the `params` object.
*
* @param params - An object mapping parameter names to their string values.
* @param currentRoutePath - The current route path, used for constructing error messages.
* @returns A function that replaces a matched parameter placeholder (e.g., ':id') with its corresponding value.
*/
function handlePrerenderParamsReplacement(
params: Record<string, string>,
currentRoutePath: string,
): (substring: string, ...args: unknown[]) => string {
return (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 parameterName === '**' ? `/${value}` : value;
};
}

/**
* Resolves the `redirectTo` property for a given route.
*
Expand Down Expand Up @@ -530,9 +560,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
16 changes: 15 additions & 1 deletion packages/angular/ssr/src/routes/route-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ 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.
*
* It also works for catch-all routes (e.g., `/**`), where the parameter name will be `**` and the return value will be
* the segments of the path, such as `/foo/bar`. These routes can also be combined, e.g., `/product/:id/**`,
* where both a parameterized segment (`:id`) and a catch-all segment (`**`) can be used together to handle more complex paths.
*
* @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).
*
Expand All @@ -159,7 +163,17 @@ export interface ServerRoutePrerenderWithParams extends Omit<ServerRoutePrerende
* const productService = inject(ProductService);
* const ids = await productService.getIds(); // Assuming this returns ['1', '2', '3']
*
* return ids.map(id => ({ id })); // Generates paths like: [{ id: '1' }, { id: '2' }, { id: '3' }]
* return ids.map(id => ({ id })); // Generates paths like: ['product/1', 'product/2', 'product/3']
* },
* },
* {
* path: '/product/:id/**',
* renderMode: RenderMode.Prerender,
* async getPrerenderParams() {
* return [
* { id: '1', '**': 'laptop/3' },
* { id: '2', '**': 'laptop/4' }
* ]; // Generates paths like: ['product/1/laptop/3', 'product/2/laptop/4']
* },
* },
* ];
Expand Down
62 changes: 40 additions & 22 deletions packages/angular/ssr/test/routes/ng-routes_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,26 +68,6 @@ describe('extractRoutesAndCreateRouteTree', () => {
);
});

it("should error when 'getPrerenderParams' is used with a '**' route", async () => {
setAngularAppTestingManifest(
[{ path: 'home', component: DummyComponent }],
[
{
path: '**',
renderMode: RenderMode.Prerender,
getPrerenderParams() {
return Promise.resolve([]);
},
},
],
);

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

it("should error when 'getPrerenderParams' is used with a '*' route", async () => {
setAngularAppTestingManifest(
[{ path: 'invalid/:id', component: DummyComponent }],
Expand All @@ -104,7 +84,7 @@ describe('extractRoutesAndCreateRouteTree', () => {

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

Expand Down Expand Up @@ -259,7 +239,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 +276,44 @@ describe('extractRoutesAndCreateRouteTree', () => {
]);
});

it('should resolve catch all routes for SSG and add a fallback route if fallback is Server', async () => {
setAngularAppTestingManifest(
[
{ path: 'home', component: DummyComponent },
{ path: 'user/:name/**', component: DummyComponent },
],
[
{
path: 'user/:name/**',
renderMode: RenderMode.Prerender,
fallback: PrerenderFallback.Server,
async getPrerenderParams() {
return [
{ name: 'joe', '**': 'role/admin' },
{ name: '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