From 816ad70c6a4abb63af76ef6639016db708adba9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Sch=C3=A4ublin?= <11610660+bschaeublin@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:48:02 +0200 Subject: [PATCH 1/2] fix(@angular/ssr): resolve relative redirects against the parent route When a route's own path has several segments, such as `old/page`, only its last segment was replaced when resolving a relative `redirectTo` on the server. A redirect to `new/page` was therefore sent to `/old/new/page`, while the router navigates to `/new/page` because it replaces all segments matched by the route. The server now does the same. This changes the redirect target of relative redirects on routes whose path has more than one segment. Fixes #34300 --- packages/angular/ssr/src/routes/ng-routes.ts | 29 +++-- packages/angular/ssr/test/app_spec.ts | 11 +- .../angular/ssr/test/routes/ng-routes_spec.ts | 118 ++++++++++++++++++ 3 files changed, 144 insertions(+), 14 deletions(-) diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index 63313b875d34..915d7c2b9a47 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -146,6 +146,7 @@ type EntryPointToBrowserMapping = AngularAppManifest['entryPointToBrowserMapping async function* handleRoute(options: { metadata: ServerConfigRouteTreeNodeMetadata; currentRoutePath: string; + parentRoute: string; route: Route; compiler: Compiler; parentInjector: Injector; @@ -158,6 +159,7 @@ async function* handleRoute(options: { const { metadata, currentRoutePath, + parentRoute, route, compiler, parentInjector, @@ -176,6 +178,7 @@ async function* handleRoute(options: { yield* handleSSGRoute( serverConfigRouteTree, typeof redirectTo === 'string' ? redirectTo : undefined, + parentRoute, metadata, parentInjector, invokeGetPrerenderParams, @@ -191,7 +194,7 @@ async function* handleRoute(options: { } else if (typeof redirectTo === 'string') { yield { ...metadata, - redirectTo: resolveRedirectTo(metadata.route, redirectTo), + redirectTo: resolveRedirectTo(parentRoute, redirectTo), relativeRedirect: redirectTo[0] !== '/' ? true : undefined, }; } else { @@ -393,6 +396,7 @@ function appendPreloadToMetadata( * * @param serverConfigRouteTree - The tree representing the server's routing setup. * @param redirectTo - Optional path to redirect to, if specified. + * @param parentRoute - The path of the parent route. * @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. @@ -402,6 +406,7 @@ function appendPreloadToMetadata( async function* handleSSGRoute( serverConfigRouteTree: RouteTree | undefined, redirectTo: string | undefined, + parentRoute: string, metadata: ServerConfigRouteTreeNodeMetadata, parentInjector: Injector, invokeGetPrerenderParams: boolean, @@ -421,7 +426,7 @@ async function* handleSSGRoute( } if (redirectTo !== undefined) { - meta.redirectTo = resolveRedirectTo(currentRoutePath, redirectTo); + meta.redirectTo = resolveRedirectTo(parentRoute, redirectTo); meta.relativeRedirect = redirectTo[0] !== '/' ? true : undefined; } @@ -480,7 +485,10 @@ async function* handleSSGRoute( redirectTo: redirectTo === undefined ? undefined - : resolveRedirectTo(routeWithResolvedParams, redirectTo), + : resolveRedirectTo( + parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, replacer), + redirectTo, + ), }; } } catch (error) { @@ -535,27 +543,24 @@ function handlePrerenderParamsReplacement( * Resolves the `redirectTo` property for a given route. * * This function processes the `redirectTo` property to ensure that it correctly - * resolves relative to the current route path. If `redirectTo` is an absolute path, - * it is returned as is. If it is a relative path, it is resolved based on the current route path, + * resolves relative to the parent route path. If `redirectTo` is an absolute path, + * it is returned as is. If it is a relative path, it is resolved based on the parent route path, * and, like in the Angular router, its query and fragment are ignored. * - * @param routePath - The current route path. + * @param parentRoute - The path of the parent route. * @param redirectTo - The target path for redirection. * @returns The resolved redirect path as a string. */ -function resolveRedirectTo(routePath: string, redirectTo: string): string { +function resolveRedirectTo(parentRoute: string, redirectTo: string): string { if (redirectTo[0] === '/') { // If the redirectTo path is absolute, return it as is. return redirectTo; } - // Resolve relative redirectTo based on the current route path. - const segments = routePath.replace(URL_PARAMETER_GLOBAL_REGEXP, '*').split('/'); - segments.pop(); // Remove the last segment to make it relative. - + // Match Angular router behavior: replace all segments matched by the route. const [path] = redirectTo.split(/[?#]/, 1); - return joinUrlParts(...segments, path); + return joinUrlParts(parentRoute.replace(URL_PARAMETER_GLOBAL_REGEXP, '*'), path); } /** diff --git a/packages/angular/ssr/test/app_spec.ts b/packages/angular/ssr/test/app_spec.ts index 91e847f0bf80..18c735d8cf34 100644 --- a/packages/angular/ssr/test/app_spec.ts +++ b/packages/angular/ssr/test/app_spec.ts @@ -89,6 +89,7 @@ describe('AngularServerApp', () => { { path: 'redirect/:param/relative', redirectTo: 'home' }, { path: 'redirect/absolute', redirectTo: '/home' }, { path: 'redirect-with-query', redirectTo: 'home?param=value' }, + { path: 'redirect-nested/:param', children: [{ path: 'old', redirectTo: 'new' }] }, { path: 'redirect-to-function', redirectTo: () => 'home', @@ -207,13 +208,19 @@ describe('AngularServerApp', () => { it('should correctly handle relative nested redirects', async () => { const response = await app.handle(new Request('http://localhost/redirect/relative')); - expect(response?.headers.get('location')).toBe('/redirect/home'); + expect(response?.headers.get('location')).toBe('/home'); expect(response?.status).toBe(302); }); it('should correctly handle relative nested redirects with parameter', async () => { const response = await app.handle(new Request('http://localhost/redirect/param/relative')); - expect(response?.headers.get('location')).toBe('/redirect/param/home'); + expect(response?.headers.get('location')).toBe('/home'); + expect(response?.status).toBe(302); + }); + + it('should correctly handle relative redirects of parameterized parent routes', async () => { + const response = await app.handle(new Request('http://localhost/redirect-nested/x/old')); + expect(response?.headers.get('location')).toBe('/redirect-nested/x/new'); expect(response?.status).toBe(302); }); diff --git a/packages/angular/ssr/test/routes/ng-routes_spec.ts b/packages/angular/ssr/test/routes/ng-routes_spec.ts index f26d6fb4913c..1af823f8f2ba 100644 --- a/packages/angular/ssr/test/routes/ng-routes_spec.ts +++ b/packages/angular/ssr/test/routes/ng-routes_spec.ts @@ -399,6 +399,96 @@ describe('extractRoutesAndCreateRouteTree', () => { { route: '/*/thing', renderMode: RenderMode.Server }, ]); }); + + it('should resolve multi-segment relative redirects of prerendered routes', async () => { + setAngularAppTestingManifest( + [ + { + path: ':param', + children: [ + { path: 'old/page', redirectTo: 'new/page' }, + { path: 'new/page', component: DummyComponent }, + ], + }, + ], + [ + { + path: ':param/old/page', + renderMode: RenderMode.Prerender, + async getPrerenderParams() { + return [{ param: 'some' }]; + }, + }, + { path: '**', renderMode: RenderMode.Server }, + ], + ); + + const { routeTree, errors } = await extractRoutesAndCreateRouteTree({ + url, + invokeGetPrerenderParams: true, + includePrerenderFallbackRoutes: true, + }); + + expect(errors).toHaveSize(0); + expect(routeTree.toObject()).toEqual([ + { route: '/*', renderMode: RenderMode.Server }, + { + route: '/*/old/page', + renderMode: RenderMode.Server, + redirectTo: '/*/new/page', + relativeRedirect: true, + }, + { route: '/*/new/page', renderMode: RenderMode.Server }, + { + route: '/some/old/page', + renderMode: RenderMode.Prerender, + redirectTo: '/some/new/page', + relativeRedirect: true, + }, + ]); + }); + + it('should resolve relative redirects of prerendered catch-all routes', async () => { + setAngularAppTestingManifest( + [ + { path: 'docs/**', redirectTo: 'new' }, + { path: 'new', component: DummyComponent }, + ], + [ + { + path: 'docs/**', + renderMode: RenderMode.Prerender, + async getPrerenderParams() { + return [{ '**': 'a/b' }]; + }, + }, + { path: '**', renderMode: RenderMode.Server }, + ], + ); + + const { routeTree, errors } = await extractRoutesAndCreateRouteTree({ + url, + invokeGetPrerenderParams: true, + includePrerenderFallbackRoutes: true, + }); + + expect(errors).toHaveSize(0); + expect(routeTree.toObject()).toEqual([ + { + route: '/docs/a/b', + renderMode: RenderMode.Prerender, + redirectTo: '/new', + relativeRedirect: true, + }, + { + route: '/docs/**', + renderMode: RenderMode.Server, + redirectTo: '/new', + relativeRedirect: true, + }, + { route: '/new', renderMode: RenderMode.Server }, + ]); + }); }); it('should extract routes with a route level matcher', async () => { @@ -562,6 +652,34 @@ describe('extractRoutesAndCreateRouteTree', () => { ]); }); + it('should resolve multi-segment relative redirects against the parent route', async () => { + setAngularAppTestingManifest( + [ + { + path: ':param', + children: [ + { path: 'old/page', redirectTo: 'new/page' }, + { path: 'new/page', component: DummyComponent }, + ], + }, + ], + [{ path: '**', renderMode: RenderMode.Server }], + ); + + const { routeTree, errors } = await extractRoutesAndCreateRouteTree({ url }); + expect(errors).toHaveSize(0); + expect(routeTree.toObject()).toEqual([ + { route: '/*', renderMode: RenderMode.Server }, + { + route: '/*/old/page', + renderMode: RenderMode.Server, + redirectTo: '/*/new/page', + relativeRedirect: true, + }, + { route: '/*/new/page', renderMode: RenderMode.Server }, + ]); + }); + it('should not mark absolute redirects as relative', async () => { setAngularAppTestingManifest( [ From e9bfb523c67eb28479d8979e3139fd0fafebe98b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Sch=C3=A4ublin?= <11610660+bschaeublin@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:03:19 +0200 Subject: [PATCH 2/2] fixup! fix(@angular/ssr): resolve relative redirects against the parent route --- packages/angular/ssr/test/app_spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/angular/ssr/test/app_spec.ts b/packages/angular/ssr/test/app_spec.ts index 18c735d8cf34..8d8ee9add00b 100644 --- a/packages/angular/ssr/test/app_spec.ts +++ b/packages/angular/ssr/test/app_spec.ts @@ -206,13 +206,13 @@ describe('AngularServerApp', () => { expect(response?.status).toBe(302); }); - it('should correctly handle relative nested redirects', async () => { + it('should correctly handle relative redirects on multi-segment routes', async () => { const response = await app.handle(new Request('http://localhost/redirect/relative')); expect(response?.headers.get('location')).toBe('/home'); expect(response?.status).toBe(302); }); - it('should correctly handle relative nested redirects with parameter', async () => { + it('should correctly handle relative redirects on multi-segment routes with parameter', async () => { const response = await app.handle(new Request('http://localhost/redirect/param/relative')); expect(response?.headers.get('location')).toBe('/home'); expect(response?.status).toBe(302); @@ -224,7 +224,7 @@ describe('AngularServerApp', () => { expect(response?.status).toBe(302); }); - it('should correctly handle absolute nested redirects', async () => { + it('should correctly handle absolute redirects on multi-segment routes', async () => { const response = await app.handle(new Request('http://localhost/redirect/absolute')); expect(response?.headers.get('location')).toBe('/home'); expect(response?.status).toBe(302);