From 128ff6834481e6c80d8dbadd1909ca12c4600433 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:18:38 +0000 Subject: [PATCH 1/2] fix(@angular/ssr): use router to normalize URLs for comparison Updates `constructDecodedUrl` in the SSR engine to use the Angular `Router` for parsing and serializing URLs instead of manual string manipulation and decoding. This ensures that the URL comparison used to determine if a redirect is necessary is consistent with how the router interprets and serializes the URL. This prevents issues where differences in encoding or edge cases (like duplicate query parameters or empty values) could lead to incorrect comparison results and unexpected redirects. Also updates tests to include edge cases for query parameters and paths to verify this behavior. Fixes #33053 --- packages/angular/ssr/src/utils/ng.ts | 27 +++++++++++++++++---------- packages/angular/ssr/test/app_spec.ts | 22 +++++++++++++++++----- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/packages/angular/ssr/src/utils/ng.ts b/packages/angular/ssr/src/utils/ng.ts index acf2df180f32..2c4ada0b8c6d 100644 --- a/packages/angular/ssr/src/utils/ng.ts +++ b/packages/angular/ssr/src/utils/ng.ts @@ -126,8 +126,8 @@ export async function renderAngular( envInjector.get(REQUEST, null, { optional: true })?.headers.get('X-Forwarded-Prefix'); const { pathname, search, hash } = envInjector.get(PlatformLocation); - const finalUrl = constructDecodedUrl({ pathname, search, hash }, requestPrefix); - const urlToRenderString = constructDecodedUrl(urlToRender, requestPrefix); + const finalUrl = constructSerializedUrl(router, { pathname, search, hash }, requestPrefix); + const urlToRenderString = constructSerializedUrl(router, urlToRender, requestPrefix); if (urlToRenderString !== finalUrl) { redirectTo = [pathname, search, hash].join(''); @@ -190,22 +190,27 @@ function asyncDestroyPlatform(platformRef: PlatformRef): Promise { } /** - * Constructs a decoded URL string from its components, ensuring consistency for comparison. + * Constructs a normalized and serialized URL string from its components. * - * This function takes a URL-like object (containing `pathname`, `search`, and `hash`), - * strips the trailing slash from the pathname, joins the components, and then decodes - * the entire string. This normalization is crucial for accurately comparing URLs - * that might differ only in encoding or trailing slashes. + * This function uses the provided `Router` instance to parse and serialize the URL, + * ensuring that the resulting string is consistent with the router's configuration. + * It also handles the optional `prefix` parameter to ensure proper URL construction. * + * @param router - The `Router` instance to use for parsing and serializing the URL. * @param url - An object containing the URL components: * - `pathname`: The path of the URL. * - `search`: The query string of the URL (including '?'). * - `hash`: The hash fragment of the URL (including '#'). * @param prefix - An optional prefix (e.g., `APP_BASE_HREF`) to prepend to the pathname * if it is not already present. - * @returns The constructed and decoded URL string. + * @returns The normalized and serialized URL string. + * + * @note + * We use the Angular `Router` to construct the URL, so that the URL is consistent with the router's configuration. + * This is important for the URL to be correctly parsed and serialized by the router as it might have different encodings. */ -function constructDecodedUrl( +function constructSerializedUrl( + router: Router, url: { pathname: string; search: string; hash: string }, prefix?: string | null, ): string { @@ -219,5 +224,7 @@ function constructDecodedUrl( urlParts.push(search, hash); - return decodeURIComponent(urlParts.join('')); + const urlTree = router.parseUrl(urlParts.join('')); + + return router.serializeUrl(urlTree); } diff --git a/packages/angular/ssr/test/app_spec.ts b/packages/angular/ssr/test/app_spec.ts index 8fb82a10cfb9..b35424796467 100644 --- a/packages/angular/ssr/test/app_spec.ts +++ b/packages/angular/ssr/test/app_spec.ts @@ -334,11 +334,23 @@ describe('AngularServerApp', () => { expect(response?.status).toBe(302); }); - it('should work with encoded characters', async () => { - const request = new Request('http://localhost/home?email=xyz%40xyz.com'); - const response = await app.handle(request); - expect(response?.status).toBe(200); - expect(await response?.text()).toContain('Home works'); + it('should work with complex and encoded URLs', async () => { + const urls = [ + 'http://localhost/home?email=xyz%40xyz.com', + 'http://localhost/home?empty', + 'http://localhost/home?scope=email+profile', + 'http://localhost/home?bbb=1&aaa=2&bbb=3', + 'http://localhost//home', + ]; + + for (const url of urls) { + const request = new Request(url); + const response = await app.handle(request); + expect(response?.status).withContext(`url: ${url}`).toBe(200); + expect(await response?.text()) + .withContext(`url: ${url}`) + .toContain('Home works'); + } }); it('should work with decoded characters', async () => { From 8c926e8dd93bf8d53802565f310c57135adb8703 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:31:12 +0000 Subject: [PATCH 2/2] fix(@angular/ssr): decode route segments when building and matching route tree Updates the `getPathSegments` method in `RouteTree` to decode each path segment using `decodeURIComponent` after splitting the route by `/`. This ensures that encoded characters in URL segments (such as spaces or encoded slashes) are correctly interpreted when matching incoming requests against the route tree. This prevents issues where encoded segments would fail to match their corresponding route definitions or wildcard patterns in the tree. Also reverts experimental changes in `url.ts` and `router.ts` regarding matrix parameter handling, as the simple change in `route-tree.ts` is sufficient to resolve the issue and passes all tests. Adds a test in `router_spec.ts` to verify that a URL with an encoded parameter containing spaces and slashes (`Bob%20%2F%20Roberts`) correctly matches a wildcard route (`/user/*`). Fixes #33044 --- packages/angular/ssr/src/routes/route-tree.ts | 2 +- packages/angular/ssr/src/routes/router.ts | 1 - packages/angular/ssr/test/routes/router_spec.ts | 10 ++++++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/angular/ssr/src/routes/route-tree.ts b/packages/angular/ssr/src/routes/route-tree.ts index 68287de7f521..ff689a3c90f5 100644 --- a/packages/angular/ssr/src/routes/route-tree.ts +++ b/packages/angular/ssr/src/routes/route-tree.ts @@ -207,7 +207,7 @@ export class RouteTree = {}> * @returns An array of path segments. */ private getPathSegments(route: string): string[] { - return route.split('/').filter(Boolean); + return route.split('/').filter(Boolean).map(decodeURIComponent); } /** diff --git a/packages/angular/ssr/src/routes/router.ts b/packages/angular/ssr/src/routes/router.ts index f01e9989028e..16b6a83c90f1 100644 --- a/packages/angular/ssr/src/routes/router.ts +++ b/packages/angular/ssr/src/routes/router.ts @@ -87,7 +87,6 @@ export class ServerRouter { // A request to `http://www.example.com/page/index.html` will render the Angular route corresponding to `http://www.example.com/page`. let { pathname } = stripIndexHtmlFromURL(url); pathname = stripMatrixParams(pathname); - pathname = decodeURIComponent(pathname); return this.routeTree.match(pathname); } diff --git a/packages/angular/ssr/test/routes/router_spec.ts b/packages/angular/ssr/test/routes/router_spec.ts index 1ce317f414c7..380bc659549a 100644 --- a/packages/angular/ssr/test/routes/router_spec.ts +++ b/packages/angular/ssr/test/routes/router_spec.ts @@ -127,5 +127,15 @@ describe('ServerRouter', () => { renderMode: RenderMode.Server, }); }); + + it('should handle encoded params', () => { + const encodedUserMetadata = router.match( + new URL('http://localhost/user/Bob%20%2F%20Roberts'), + ); + expect(encodedUserMetadata).toEqual({ + route: '/user/*', + renderMode: RenderMode.Server, + }); + }); }); });