diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index 69d5f9e6e100..dd2873abc2dc 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -24,7 +24,12 @@ import { ServerRouter } from './routes/router'; import { AngularBootstrap, renderAngular } from './utils/ng'; import { promiseWithAbort } from './utils/promise'; import { createRedirectResponse } from './utils/redirect'; -import { buildPathWithParams, joinUrlParts, stripLeadingSlash } from './utils/url'; +import { + buildPathWithParams, + buildQueryWithParams, + joinUrlParts, + stripLeadingSlash, +} from './utils/url'; import { getFirstHeaderValue } from './utils/validation'; /** @@ -174,7 +179,7 @@ export class AngularServerApp { // Match Angular router behavior: relative redirects keep the query string. return createRedirectResponse( - relativeRedirect ? location + url.search : location, + relativeRedirect ? location + url.search : buildQueryWithParams(location, url.searchParams), status, headers, ); diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index 816155c4df01..4176560b88ba 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -147,6 +147,7 @@ async function* handleRoute(options: { metadata: ServerConfigRouteTreeNodeMetadata; currentRoutePath: string; parentRoute: string; + baseHref: string; route: Route; compiler: Compiler; parentInjector: Injector; @@ -160,6 +161,7 @@ async function* handleRoute(options: { metadata, currentRoutePath, parentRoute, + baseHref, route, compiler, parentInjector, @@ -179,8 +181,9 @@ async function* handleRoute(options: { if (metadata.renderMode === RenderMode.Prerender) { for await (const result of handleSSGRoute( serverConfigRouteTree, - typeof redirectTo === 'string' ? redirectTo : undefined, + route, parentRoute, + baseHref, metadata, parentInjector, invokeGetPrerenderParams, @@ -200,9 +203,18 @@ async function* handleRoute(options: { `Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`, }; } else if (typeof redirectTo === 'string') { + // Reference the route parameters by their position in the URL (see `buildPathWithParams`). + const routeSegments = joinUrlParts(baseHref, currentRoutePath).split('/'); parentRoutes.set(metadata.route, { ...metadata, - redirectTo: resolveRedirectTo(parentRoute, redirectTo), + redirectTo: resolveRedirectTo( + parentRoute, + replaceRedirectParams( + route, + redirectTo, + (parameter) => `*${routeSegments.lastIndexOf(parameter)}`, + ), + ), relativeRedirect: redirectTo[0] !== '/' ? true : undefined, }); } else { @@ -290,6 +302,7 @@ async function* traverseRoutesConfig(options: { compiler: Compiler; parentInjector: Injector; parentRoute: string; + baseHref: string; serverConfigRouteTree?: RouteTree; invokeGetPrerenderParams: boolean; includePrerenderFallbackRoutes: boolean; @@ -421,8 +434,9 @@ function appendPreloadToMetadata( * all parameterized paths, returning any errors encountered. * * @param serverConfigRouteTree - The tree representing the server's routing setup. - * @param redirectTo - Optional path to redirect to, if specified. + * @param route - The route configuration. * @param parentRoute - The path of the parent route. + * @param baseHref - The base href of the application. * @param metadata - The metadata associated with the route tree node. * @param parentInjector - The dependency injection container for the parent route. * @param invokeGetPrerenderParams - A flag indicating whether to invoke the `getPrerenderParams` function. @@ -431,8 +445,9 @@ function appendPreloadToMetadata( */ async function* handleSSGRoute( serverConfigRouteTree: RouteTree | undefined, - redirectTo: string | undefined, + route: Route, parentRoute: string, + baseHref: string, metadata: ServerConfigRouteTreeNodeMetadata, parentInjector: Injector, invokeGetPrerenderParams: boolean, @@ -451,8 +466,18 @@ async function* handleSSGRoute( delete meta['getPrerenderParams']; } + const redirectTo = typeof route.redirectTo === 'string' ? route.redirectTo : undefined; if (redirectTo !== undefined) { - meta.redirectTo = resolveRedirectTo(parentRoute, redirectTo); + // Reference the route parameters by their position in the URL (see `buildPathWithParams`). + const routeSegments = joinUrlParts(baseHref, currentRoutePath).split('/'); + meta.redirectTo = resolveRedirectTo( + parentRoute, + replaceRedirectParams( + route, + redirectTo, + (parameter) => `*${routeSegments.lastIndexOf(parameter)}`, + ), + ); meta.relativeRedirect = redirectTo[0] !== '/' ? true : undefined; } @@ -513,7 +538,11 @@ async function* handleSSGRoute( ? undefined : resolveRedirectTo( parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, replacer), - redirectTo, + replaceRedirectParams( + route, + redirectTo, + (parameter) => params[parameter.slice(1)], + ), ), }; } @@ -589,6 +618,48 @@ function resolveRedirectTo(parentRoute: string, redirectTo: string): string { return joinUrlParts(parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, '*'), path); } +/** + * Replaces the route parameters, such as `:id`, in the path of `redirectTo`. + * + * Like in the Angular router, only whole path segments starting with `:` are parameters, and only + * the parameters defined in the path of the redirecting route itself can be used. + * + * @param route - The redirecting route. + * @param redirectTo - The target path for redirection. + * @param resolve - A function that returns the replacement for a parameter, such as `:id`. + * @returns The redirect path with its route parameters replaced. + * @throws If `redirectTo` uses a parameter that is not defined in the path of the route. + */ +function replaceRedirectParams( + route: Route, + redirectTo: string, + resolve: (parameter: string) => string, +): string { + if (route.matcher) { + // The parameters of a custom matcher are only known at runtime. + return redirectTo; + } + + const routeSegments = route.path?.split('/') ?? []; + const [path] = redirectTo.split(/[?#]/, 1); + const resolvedPath = path + .split('/') + .map((segment) => { + if (segment[0] !== ':') { + return segment; + } + + if (!routeSegments.includes(segment)) { + throw new Error(`Cannot redirect to '${redirectTo}'. Cannot find '${segment}'.`); + } + + return resolve(segment); + }) + .join('/'); + + return resolvedPath + redirectTo.slice(path.length); +} + /** * Builds a server configuration route tree from the given server routes configuration. * @@ -742,6 +813,7 @@ export async function getRoutesFromAngularRouterConfig( compiler, parentInjector: injector, parentRoute: '', + baseHref, serverConfigRouteTree, invokeGetPrerenderParams, includePrerenderFallbackRoutes, diff --git a/packages/angular/ssr/src/utils/url.ts b/packages/angular/ssr/src/utils/url.ts index d5e7c9ac2814..016ecece91cb 100644 --- a/packages/angular/ssr/src/utils/url.ts +++ b/packages/angular/ssr/src/utils/url.ts @@ -152,12 +152,18 @@ export function stripIndexHtmlFromURL(url: URL): URL { return url; } +/** + * Matches a `*` placeholder that makes up a whole path segment. + */ +const POSITIONAL_PLACEHOLDER_REGEXP = /^\*(\d+)$/; + /** * Resolves `*` placeholders in a path template by mapping them to corresponding segments * from a base path. This is useful for constructing paths dynamically based on a given base path. * * The function processes the `toPath` string, replacing each `*` placeholder with - * the corresponding segment from the `fromPath`. If the `toPath` contains no placeholders, + * the corresponding segment from the `fromPath`, and each `*` placeholder with the + * segment at position `n` of the `fromPath`. If the `toPath` contains no placeholders, * it is returned as-is. Invalid `toPath` formats (not starting with `/`) will throw an error. * * @param toPath - A path template string that may contain `*` placeholders. Each `*` is replaced @@ -176,6 +182,10 @@ export function stripIndexHtmlFromURL(url: URL): URL { * const resolvedPath = buildPathWithParams('/*\/details', '/123/abc'); * console.log(resolvedPath); // Outputs: '/123/details' * + * // Example with a positional placeholder + * const positionalPath = buildPathWithParams('/user/*1', '/123/abc'); + * console.log(positionalPath); // Outputs: '/user/123' + * * // Example with a static path * const staticPath = buildPathWithParams('/static/path', '/base/unused'); * console.log(staticPath); // Outputs: '/static/path' @@ -190,17 +200,76 @@ export function buildPathWithParams(toPath: string, fromPath: string): string { throw new Error(`Invalid fromPath: The string must start with a '/'. Received: '${fromPath}'`); } - if (!toPath.includes('/*')) { + const [path] = toPath.split(/[?#]/, 1); + if (!path.includes('/*')) { return toPath; } const fromPathParts = fromPath.split('/'); - const toPathParts = toPath.split('/'); + const toPathParts = path.split('/'); const resolvedParts = toPathParts.map((part, index) => - toPathParts[index] === '*' ? fromPathParts[index] : part, + part === '*' + ? fromPathParts[index] + : part.replace(POSITIONAL_PLACEHOLDER_REGEXP, (_, position) => fromPathParts[position]), ); - return joinUrlParts(...resolvedParts); + return joinUrlParts(...resolvedParts) + toPath.slice(path.length); +} + +/** + * Resolves `:name` placeholders in the query of a URL, like the Angular router does for redirects. + * + * Each query parameter whose value is a `:name` placeholder gets the values of the `name` + * query parameter of `fromParams`, or `undefined` if `fromParams` has no such parameter. + * Like in the Angular router, placeholders of repeated query parameters are not resolved. + * + * @param toUrl - A URL whose query may contain `:name` placeholders. + * @param fromParams - The query parameters that provide the values for the placeholders. + * @returns The URL with its query placeholders resolved. + * + * @example + * ```typescript + * buildQueryWithParams('/find?q=:x', new URLSearchParams('x=cats')); // returns '/find?q=cats' + * ``` + */ +export function buildQueryWithParams(toUrl: string, fromParams: URLSearchParams): string { + const [, path, query, fragment] = /^([^?#]*)\?([^#]*)(.*)$/.exec(toUrl) ?? []; + if (query === undefined) { + return toUrl; + } + + const params = query.split('&'); + const names = params.map((param) => param.split('=', 1)[0]); + const resolvedQuery = params + .map((param, index) => { + const name = names[index]; + const value = param.slice(name.length + 1); + if (value[0] !== ':' || names.indexOf(name) !== names.lastIndexOf(name)) { + return param; + } + + const sourceValues = fromParams.getAll(value.slice(1)); + + return (sourceValues.length ? sourceValues : ['undefined']) + .map((sourceValue) => `${name}=${encodeQueryValue(sourceValue)}`) + .join('&'); + }) + .join('&'); + + return `${path}?${resolvedQuery}${fragment}`; +} + +/** + * Encodes a query parameter value like the Angular router (`DefaultUrlSerializer`), which keeps + * `@`, `:`, `$`, `,` and `;` as is. + * + * @param value - The query parameter value to encode. + * @returns The encoded query parameter value. + */ +function encodeQueryValue(value: string): string { + return encodeURIComponent(value).replace(/%(40|3A|24|2C|3B)/g, (match) => + decodeURIComponent(match), + ); } const MATRIX_PARAMS_REGEX = /;[^/]+/g; diff --git a/packages/angular/ssr/test/app_spec.ts b/packages/angular/ssr/test/app_spec.ts index 68e3ed2eb1c2..e389124c853c 100644 --- a/packages/angular/ssr/test/app_spec.ts +++ b/packages/angular/ssr/test/app_spec.ts @@ -90,6 +90,12 @@ describe('AngularServerApp', () => { { path: 'redirect/absolute', redirectTo: '/home' }, { path: 'redirect-with-query', redirectTo: 'home?param=value' }, { path: 'redirect-nested/:param', children: [{ path: 'old', redirectTo: 'new' }] }, + { path: 'redirect-param/:a/:b', redirectTo: '/home/:b/:a' }, + { path: 'redirect-param-relative/:id', redirectTo: 'target/:id' }, + { + path: 'redirect-query-placeholder', + redirectTo: '/home?q=:term&id=:missing&static=value', + }, { path: 'redirect-to-function', redirectTo: () => 'home', @@ -252,6 +258,30 @@ describe('AngularServerApp', () => { expect(response?.status).toBe(302); }); + it('should resolve route parameters in absolute redirect targets', async () => { + const response = await app.handle(new Request('http://localhost/redirect-param/x/y')); + expect(response?.headers.get('location')).toBe('/home/y/x'); + expect(response?.status).toBe(302); + }); + + it('should resolve route parameters in relative redirect targets', async () => { + const response = await app.handle( + new Request('http://localhost/redirect-param-relative/7?filter=test'), + ); + expect(response?.headers.get('location')).toBe('/target/7?filter=test'); + expect(response?.status).toBe(302); + }); + + it('should resolve query parameters in absolute redirect targets', async () => { + const response = await app.handle( + new Request('http://localhost/redirect-query-placeholder?term=cats&term=dogs'), + ); + expect(response?.headers.get('location')).toBe( + '/home?q=cats&q=dogs&id=undefined&static=value', + ); + expect(response?.status).toBe(302); + }); + it('should handle request abortion gracefully', async () => { const controller = new AbortController(); const request = new Request('http://localhost/home', { signal: controller.signal }); diff --git a/packages/angular/ssr/test/routes/ng-routes_spec.ts b/packages/angular/ssr/test/routes/ng-routes_spec.ts index 93b8e446ccb4..dc98403416b4 100644 --- a/packages/angular/ssr/test/routes/ng-routes_spec.ts +++ b/packages/angular/ssr/test/routes/ng-routes_spec.ts @@ -489,6 +489,38 @@ describe('extractRoutesAndCreateRouteTree', () => { { route: '/new', renderMode: RenderMode.Server }, ]); }); + + it('should resolve route parameters of redirect targets of prerendered routes', async () => { + setAngularAppTestingManifest( + [ + { path: 'user/:id', redirectTo: '/profile/:id' }, + { path: 'profile/:id', component: DummyComponent }, + ], + [ + { + path: 'user/:id', + renderMode: RenderMode.Prerender, + async getPrerenderParams() { + return [{ id: '7' }]; + }, + }, + { path: '**', renderMode: RenderMode.Server }, + ], + ); + + const { routeTree, errors } = await extractRoutesAndCreateRouteTree({ + url, + invokeGetPrerenderParams: true, + includePrerenderFallbackRoutes: true, + }); + + expect(errors).toHaveSize(0); + expect(routeTree.toObject()).toEqual([ + { route: '/user/7', renderMode: RenderMode.Prerender, redirectTo: '/profile/7' }, + { route: '/user/*', renderMode: RenderMode.Server, redirectTo: '/profile/*2' }, + { route: '/profile/*', renderMode: RenderMode.Server }, + ]); + }); }); it('should extract routes with a route level matcher', async () => { @@ -652,6 +684,52 @@ describe('extractRoutesAndCreateRouteTree', () => { ]); }); + it('should reference route parameters of redirect targets by their position', async () => { + setAngularAppTestingManifest( + [ + { + path: ':lang', + children: [ + { path: 'user/:id', redirectTo: 'profile/:id' }, + { path: 'profile/:id', component: DummyComponent }, + ], + }, + { path: 'search/:term', redirectTo: '/find/:term' }, + ], + [{ path: '**', renderMode: RenderMode.Server }], + ); + + const { routeTree, errors } = await extractRoutesAndCreateRouteTree({ url }); + expect(errors).toHaveSize(0); + expect(routeTree.toObject()).toEqual([ + { route: '/*', renderMode: RenderMode.Server }, + { + route: '/*/user/*', + renderMode: RenderMode.Server, + redirectTo: '/*/profile/*3', + relativeRedirect: true, + }, + { route: '/*/profile/*', renderMode: RenderMode.Server }, + { route: '/search/*', renderMode: RenderMode.Server, redirectTo: '/find/*2' }, + ]); + }); + + it('should error when a redirect target uses a parameter not defined by the route', async () => { + setAngularAppTestingManifest( + [ + { + path: ':lang', + children: [{ path: 'user/:id', redirectTo: 'profile/:lang' }], + }, + ], + [{ path: '**', renderMode: RenderMode.Server }], + ); + + const { errors } = await extractRoutesAndCreateRouteTree({ url }); + expect(errors).toHaveSize(1); + expect(errors[0]).toContain(`Cannot redirect to 'profile/:lang'. Cannot find ':lang'.`); + }); + it('should resolve multi-segment relative redirects against the parent route', async () => { setAngularAppTestingManifest( [ diff --git a/packages/angular/ssr/test/utils/url_spec.ts b/packages/angular/ssr/test/utils/url_spec.ts index a108c7ff1df6..e8dc93ccf2a2 100644 --- a/packages/angular/ssr/test/utils/url_spec.ts +++ b/packages/angular/ssr/test/utils/url_spec.ts @@ -10,6 +10,7 @@ import { addLeadingSlash, addTrailingSlash, buildPathWithParams, + buildQueryWithParams, joinUrlParts, stripIndexHtmlFromURL, stripLeadingSlash, @@ -184,6 +185,15 @@ describe('URL Utils', () => { expect(result.toString()).toBe('/base/path/details'); }); + it('should replace positional placeholders with the segments at those positions', () => { + expect(buildPathWithParams('/user/*2/*1', '/a/b')).toBe('/user/b/a'); + }); + + it('should replace a positional placeholder followed by a query or fragment', () => { + expect(buildPathWithParams('/user/*1?returnTo=/a//b/', '/a')).toBe('/user/a?returnTo=/a//b/'); + expect(buildPathWithParams('/user/*1#top', '/a')).toBe('/user/a#top'); + }); + it('should throw an error if the toPath does not start with "/"', () => { const fromPath = '/base/path'; const toPath = 'details'; @@ -195,6 +205,27 @@ describe('URL Utils', () => { }); }); + describe('buildQueryWithParams', () => { + it('should replace query placeholders with the values of the source parameters', () => { + const fromParams = new URLSearchParams('term=big cats&term=a:b@c&page=2'); + expect(buildQueryWithParams('/search?q=:term&p=:page&static=a=:b#top', fromParams)).toBe( + '/search?q=big%20cats&q=a:b@c&p=2&static=a=:b#top', + ); + }); + + it('should set query parameters whose source parameter is missing to undefined', () => { + const fromParams = new URLSearchParams(); + expect(buildQueryWithParams('/search?q=:term&static=1', fromParams)).toBe( + '/search?q=undefined&static=1', + ); + }); + + it('should not replace placeholders of repeated query parameters', () => { + const fromParams = new URLSearchParams('a=1&b=2'); + expect(buildQueryWithParams('/search?q=:a&q=:b', fromParams)).toBe('/search?q=:a&q=:b'); + }); + }); + describe('stripMatrixParams', () => { it('should remove a single matrix parameter', () => { expect(stripMatrixParams('/path;param=value')).toBe('/path');