diff --git a/packages/angular/ssr/src/utils/url.ts b/packages/angular/ssr/src/utils/url.ts index f2a2aa776b9e..57a194aa3323 100644 --- a/packages/angular/ssr/src/utils/url.ts +++ b/packages/angular/ssr/src/utils/url.ts @@ -93,13 +93,21 @@ 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) { + 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 continue; @@ -108,6 +116,22 @@ export function joinUrlParts(...parts: string[]): string { let start = 0; let end = part.length; + if (i === lastIndex) { + 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 +146,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..3625ae9b85e8 100644 --- a/packages/angular/ssr/test/utils/url_spec.ts +++ b/packages/angular/ssr/test/utils/url_spec.ts @@ -112,6 +112,21 @@ 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=/'); + 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/'); + }); }); describe('stripIndexHtmlFromURL', () => {