diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index 41e4f62f84f0..b40002eb93ba 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -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. */ @@ -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, @@ -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, { @@ -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, @@ -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, + 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. * @@ -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; } diff --git a/packages/angular/ssr/src/routes/route-config.ts b/packages/angular/ssr/src/routes/route-config.ts index a5fb6709e4e5..bcd791a7c5c4 100644 --- a/packages/angular/ssr/src/routes/route-config.ts +++ b/packages/angular/ssr/src/routes/route-config.ts @@ -142,6 +142,10 @@ export interface ServerRoutePrerenderWithParams extends Omit ({ 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'] * }, * }, * ]; diff --git a/packages/angular/ssr/test/routes/ng-routes_spec.ts b/packages/angular/ssr/test/routes/ng-routes_spec.ts index 291ce74708ab..0f797fc14e24 100644 --- a/packages/angular/ssr/test/routes/ng-routes_spec.ts +++ b/packages/angular/ssr/test/routes/ng-routes_spec.ts @@ -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 }], @@ -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.", ); }); @@ -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 }, @@ -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( [