From a77eaddbdeb1c2fc21f2bb97b22c0b76d0561c48 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:47:00 +0000 Subject: [PATCH 1/2] fix(@angular/ssr): preserve trailing slashes in query and fragment of redirect URLs In redirect URLs, trailing slashes in query parameters or hash fragments should be preserved, while the path portion before the query or fragment continues to be normalized. Fixes #34511 --- packages/angular/ssr/src/utils/url.ts | 23 +++++++++++++++-- packages/angular/ssr/test/app_spec.ts | 22 ++++++++++++++++ .../angular/ssr/test/routes/ng-routes_spec.ts | 25 +++++++++++++++++++ packages/angular/ssr/test/utils/url_spec.ts | 12 +++++++++ 4 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/angular/ssr/src/utils/url.ts b/packages/angular/ssr/src/utils/url.ts index f2a2aa776b9e..648b8f6ef478 100644 --- a/packages/angular/ssr/src/utils/url.ts +++ b/packages/angular/ssr/src/utils/url.ts @@ -93,13 +93,16 @@ export function addTrailingSlash(url: string): string { * ```js * joinUrlParts('path/', '/to/resource'); // '/path/to/resource' * joinUrlParts('/path/', 'to/resource'); // '/path/to/resource' + * joinUrlParts('/base/', '/login/?next=/a/'); // '/base/login?next=/a/' * joinUrlParts('', ''); // '/' * ``` */ export function joinUrlParts(...parts: string[]): string { const normalizedParts: string[] = []; + let suffix = ''; - for (const part of parts) { + for (let i = 0, len = parts.length; i < len; i++) { + const part = parts[i]; if (part === '') { // Skip any empty parts continue; @@ -108,6 +111,22 @@ export function joinUrlParts(...parts: string[]): string { let start = 0; let end = part.length; + if (i === len - 1) { + const queryIndex = part.indexOf('?'); + const hashIndex = part.indexOf('#'); + const suffixIndex = + queryIndex === -1 + ? hashIndex + : hashIndex === -1 + ? queryIndex + : Math.min(queryIndex, hashIndex); + + if (suffixIndex !== -1) { + suffix = part.slice(suffixIndex); + end = suffixIndex; + } + } + // Use "Pointers" to avoid intermediate slices while (start < end && part[start] === '/') { start++; @@ -122,7 +141,7 @@ export function joinUrlParts(...parts: string[]): string { } } - return addLeadingSlash(normalizedParts.join('/')); + return addLeadingSlash(normalizedParts.join('/')) + suffix; } /** diff --git a/packages/angular/ssr/test/app_spec.ts b/packages/angular/ssr/test/app_spec.ts index e389124c853c..ee9fb9dcc1a5 100644 --- a/packages/angular/ssr/test/app_spec.ts +++ b/packages/angular/ssr/test/app_spec.ts @@ -96,6 +96,14 @@ describe('AngularServerApp', () => { path: 'redirect-query-placeholder', redirectTo: '/home?q=:term&id=:missing&static=value', }, + { + path: 'redirect-query-trailing-slash', + redirectTo: '/home?next=/a/', + }, + { + path: 'redirect-fragment-trailing-slash', + redirectTo: '/home#section/', + }, { path: 'redirect-to-function', redirectTo: () => 'home', @@ -282,6 +290,20 @@ describe('AngularServerApp', () => { expect(response?.status).toBe(302); }); + it('should preserve trailing slashes in query parameters and fragments of redirect targets', async () => { + const queryResponse = await app.handle( + new Request('http://localhost/redirect-query-trailing-slash'), + ); + expect(queryResponse?.headers.get('location')).toBe('/home?next=/a/'); + expect(queryResponse?.status).toBe(302); + + const fragmentResponse = await app.handle( + new Request('http://localhost/redirect-fragment-trailing-slash'), + ); + expect(fragmentResponse?.headers.get('location')).toBe('/home#section/'); + expect(fragmentResponse?.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 dc98403416b4..0c799f016203 100644 --- a/packages/angular/ssr/test/routes/ng-routes_spec.ts +++ b/packages/angular/ssr/test/routes/ng-routes_spec.ts @@ -812,6 +812,31 @@ describe('extractRoutesAndCreateRouteTree', () => { ]); }); + it('should preserve trailing slashes in query parameters and fragments of absolute redirect targets', async () => { + setAngularAppTestingManifest( + [ + { path: 'query-slash', redirectTo: '/login?next=/a/' }, + { path: 'fragment-slash', redirectTo: '/login#section/' }, + { path: 'path-and-query-slash', redirectTo: '/login/?next=/a/' }, + { path: 'login', component: DummyComponent }, + ], + [{ path: '**', renderMode: RenderMode.Server }], + ); + + const { routeTree, errors } = await extractRoutesAndCreateRouteTree({ url }); + expect(errors).toHaveSize(0); + expect(routeTree.toObject()).toEqual([ + { route: '/query-slash', renderMode: RenderMode.Server, redirectTo: '/login?next=/a/' }, + { route: '/fragment-slash', renderMode: RenderMode.Server, redirectTo: '/login#section/' }, + { + route: '/path-and-query-slash', + renderMode: RenderMode.Server, + redirectTo: '/login?next=/a/', + }, + { route: '/login', renderMode: RenderMode.Server }, + ]); + }); + it('should not resolve parameterized routes for SSG when `invokeGetPrerenderParams` is false', async () => { setAngularAppTestingManifest( [ diff --git a/packages/angular/ssr/test/utils/url_spec.ts b/packages/angular/ssr/test/utils/url_spec.ts index 8e22edb7226f..473026f7c420 100644 --- a/packages/angular/ssr/test/utils/url_spec.ts +++ b/packages/angular/ssr/test/utils/url_spec.ts @@ -112,6 +112,18 @@ describe('URL Utils', () => { it('should handle parts containing only slashes', () => { expect(joinUrlParts('//', '///')).toBe('/'); }); + + it('should preserve trailing slashes in query parameters and fragments', () => { + expect(joinUrlParts('/', '/login?next=/a/')).toBe('/login?next=/a/'); + expect(joinUrlParts('/', '/login#section/')).toBe('/login#section/'); + expect(joinUrlParts('/base/', '/foo/?next=/a/#section/')).toBe('/base/foo?next=/a/#section/'); + expect(joinUrlParts('/base/', '/login/#section?next=/a/')).toBe( + '/base/login#section?next=/a/', + ); + expect(joinUrlParts('/base/', '/?next=/a/')).toBe('/base?next=/a/'); + expect(joinUrlParts('/', '/?next=/a/')).toBe('/?next=/a/'); + expect(joinUrlParts('', '///evil.com?a=/')).toBe('/evil.com?a=/'); + }); }); describe('stripIndexHtmlFromURL', () => { From 740fef82f2894c29a7705adb8b9f1c8d65b1d1c1 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:09:21 +0000 Subject: [PATCH 2/2] fixup! fix(@angular/ssr): preserve trailing slashes in query and fragment of redirect URLs --- packages/angular/ssr/src/utils/url.ts | 9 +++++++-- packages/angular/ssr/test/utils/url_spec.ts | 3 +++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/angular/ssr/src/utils/url.ts b/packages/angular/ssr/src/utils/url.ts index 648b8f6ef478..57a194aa3323 100644 --- a/packages/angular/ssr/src/utils/url.ts +++ b/packages/angular/ssr/src/utils/url.ts @@ -101,7 +101,12 @@ export function joinUrlParts(...parts: string[]): string { const normalizedParts: string[] = []; let suffix = ''; - for (let i = 0, len = parts.length; i < len; i++) { + let lastIndex = parts.length - 1; + while (lastIndex >= 0 && parts[lastIndex] === '') { + lastIndex--; + } + + for (let i = 0; i <= lastIndex; i++) { const part = parts[i]; if (part === '') { // Skip any empty parts @@ -111,7 +116,7 @@ export function joinUrlParts(...parts: string[]): string { let start = 0; let end = part.length; - if (i === len - 1) { + if (i === lastIndex) { const queryIndex = part.indexOf('?'); const hashIndex = part.indexOf('#'); const suffixIndex = diff --git a/packages/angular/ssr/test/utils/url_spec.ts b/packages/angular/ssr/test/utils/url_spec.ts index 473026f7c420..3625ae9b85e8 100644 --- a/packages/angular/ssr/test/utils/url_spec.ts +++ b/packages/angular/ssr/test/utils/url_spec.ts @@ -123,6 +123,9 @@ describe('URL Utils', () => { expect(joinUrlParts('/base/', '/?next=/a/')).toBe('/base?next=/a/'); expect(joinUrlParts('/', '/?next=/a/')).toBe('/?next=/a/'); expect(joinUrlParts('', '///evil.com?a=/')).toBe('/evil.com?a=/'); + expect(joinUrlParts('/login/?next=/a/', '')).toBe('/login?next=/a/'); + expect(joinUrlParts('/base/', '/login/?next=/a/', '', '')).toBe('/base/login?next=/a/'); + expect(joinUrlParts('/login/#section/', '')).toBe('/login#section/'); }); });