diff --git a/goldens/public-api/angular/ssr/index.api.md b/goldens/public-api/angular/ssr/index.api.md index 81764fcc1f62..66ec1d0f5986 100644 --- a/goldens/public-api/angular/ssr/index.api.md +++ b/goldens/public-api/angular/ssr/index.api.md @@ -63,7 +63,7 @@ export interface ServerRoutePrerender extends Omit // @public export interface ServerRoutePrerenderWithParams extends Omit { fallback?: PrerenderFallback; - getPrerenderParams: () => Promise[]>; + getPrerenderParams: () => Promise<(string[] | Record)[]>; } // @public diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index 41e4f62f84f0..608036f74604 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -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']; @@ -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, @@ -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, { @@ -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('/')) + : 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, @@ -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; } diff --git a/packages/angular/ssr/src/routes/route-config.ts b/packages/angular/ssr/src/routes/route-config.ts index a5fb6709e4e5..be0453372172 100644 --- a/packages/angular/ssr/src/routes/route-config.ts +++ b/packages/angular/ssr/src/routes/route-config.ts @@ -142,8 +142,17 @@ export interface ServerRoutePrerenderWithParams extends Omit (['category', id])); // Generates paths like: [['category', '1'], ['category', '2'], ['category', '3']] + * }, + * }, + * ]; + * ``` */ - getPrerenderParams: () => Promise[]>; + getPrerenderParams: () => Promise<(string[] | Record)[]>; } /** diff --git a/packages/angular/ssr/test/routes/ng-routes_spec.ts b/packages/angular/ssr/test/routes/ng-routes_spec.ts index 291ce74708ab..788cb13ce886 100644 --- a/packages/angular/ssr/test/routes/ng-routes_spec.ts +++ b/packages/angular/ssr/test/routes/ng-routes_spec.ts @@ -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([]); @@ -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.`, ); }); @@ -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 }, @@ -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( [