diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4be84a714b12..75cf55112e3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,22 @@
+
+
+# 20.3.12 (2025-11-25)
+
+### @angular/build
+
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- |
+| [25bb7e65c](https://github.com/angular/angular-cli/commit/25bb7e65c4fc7e401c658126c53b0b7a13d62965) | fix | ensure correct URL joining for prerender routes |
+
+### @angular/ssr
+
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ |
+| [cceb86296](https://github.com/angular/angular-cli/commit/cceb862969e541a5f54b689a6439e32773eafe65) | fix | handle `X-Forwarded-Prefix` and `APP_BASE_HREF` in redirects |
+| [1abe68ad8](https://github.com/angular/angular-cli/commit/1abe68ad87f9b892734117a087b5775068bd232b) | fix | prevent redirect loop with encoded query parameters |
+
+
+
# 20.3.11 (2025-11-19)
diff --git a/package.json b/package.json
index 8c277a5e0c02..a4d839e71740 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@angular/devkit-repo",
- "version": "20.3.11",
+ "version": "20.3.12",
"private": true,
"description": "Software Development Kit for Angular",
"keywords": [
diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts
index 1b3a15b8cd56..25bd87253357 100644
--- a/packages/angular/build/src/builders/application/options.ts
+++ b/packages/angular/build/src/builders/application/options.ts
@@ -25,7 +25,7 @@ import {
loadPostcssConfiguration,
} from '../../utils/postcss-configuration';
import { getProjectRootPaths, normalizeDirectoryPath } from '../../utils/project-metadata';
-import { urlJoin } from '../../utils/url';
+import { addTrailingSlash, joinUrlParts } from '../../utils/url';
import {
Schema as ApplicationBuilderOptions,
ExperimentalPlatform,
@@ -681,7 +681,9 @@ export function getLocaleBaseHref(
const baseHrefSuffix = localeData.baseHref ?? localeData.subPath + '/';
- return baseHrefSuffix !== '' ? urlJoin(baseHref, baseHrefSuffix) : undefined;
+ return baseHrefSuffix !== ''
+ ? addTrailingSlash(joinUrlParts(baseHref, baseHrefSuffix))
+ : undefined;
}
/**
diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts
index 5ece379ec9c0..f33f851f10c4 100644
--- a/packages/angular/build/src/utils/server-rendering/prerender.ts
+++ b/packages/angular/build/src/utils/server-rendering/prerender.ts
@@ -14,7 +14,7 @@ import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundle
import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result';
import { assertIsError } from '../error';
import { toPosixPath } from '../path';
-import { urlJoin } from '../url';
+import { addLeadingSlash, addTrailingSlash, joinUrlParts, stripLeadingSlash } from '../url';
import { WorkerPool } from '../worker-pool';
import { IMPORT_EXEC_ARGV } from './esm-in-memory-loader/utils';
import { SERVER_APP_MANIFEST_FILENAME } from './manifest';
@@ -240,7 +240,7 @@ async function renderPages(
? addLeadingSlash(route.slice(baseHrefPathnameWithLeadingSlash.length))
: route;
- const outPath = posix.join(removeLeadingSlash(routeWithoutBaseHref), 'index.html');
+ const outPath = stripLeadingSlash(posix.join(routeWithoutBaseHref, 'index.html'));
if (typeof redirectTo === 'string') {
output[outPath] = { content: generateRedirectStaticPage(redirectTo), appShellRoute: false };
@@ -298,7 +298,7 @@ async function getAllRoutes(
let appShellRoute: string | undefined;
if (appShellOptions) {
- appShellRoute = urlJoin(baseHref, appShellOptions.route);
+ appShellRoute = joinUrlParts(baseHref, appShellOptions.route);
routes.push({
renderMode: RouteRenderMode.Prerender,
@@ -311,7 +311,7 @@ async function getAllRoutes(
for (const route of routesFromFile) {
routes.push({
renderMode: RouteRenderMode.Prerender,
- route: urlJoin(baseHref, route.trim()),
+ route: joinUrlParts(baseHref, route.trim()),
});
}
}
@@ -369,15 +369,3 @@ async function getAllRoutes(
void renderWorker.destroy();
}
}
-
-function addLeadingSlash(value: string): string {
- return value[0] === '/' ? value : '/' + value;
-}
-
-function addTrailingSlash(url: string): string {
- return url[url.length - 1] === '/' ? url : `${url}/`;
-}
-
-function removeLeadingSlash(value: string): string {
- return value[0] === '/' ? value.slice(1) : value;
-}
diff --git a/packages/angular/build/src/utils/url.ts b/packages/angular/build/src/utils/url.ts
index d3f1e5791276..9edbfb3a3de5 100644
--- a/packages/angular/build/src/utils/url.ts
+++ b/packages/angular/build/src/utils/url.ts
@@ -6,11 +6,117 @@
* found in the LICENSE file at https://angular.dev/license
*/
-export function urlJoin(...parts: string[]): string {
- const [p, ...rest] = parts;
+/**
+ * Removes the trailing slash from a URL if it exists.
+ *
+ * @param url - The URL string from which to remove the trailing slash.
+ * @returns The URL string without a trailing slash.
+ *
+ * @example
+ * ```js
+ * stripTrailingSlash('path/'); // 'path'
+ * stripTrailingSlash('/path'); // '/path'
+ * stripTrailingSlash('/'); // '/'
+ * stripTrailingSlash(''); // ''
+ * ```
+ */
+export function stripTrailingSlash(url: string): string {
+ // Check if the last character of the URL is a slash
+ return url.length > 1 && url[url.length - 1] === '/' ? url.slice(0, -1) : url;
+}
+
+/**
+ * Removes the leading slash from a URL if it exists.
+ *
+ * @param url - The URL string from which to remove the leading slash.
+ * @returns The URL string without a leading slash.
+ *
+ * @example
+ * ```js
+ * stripLeadingSlash('/path'); // 'path'
+ * stripLeadingSlash('/path/'); // 'path/'
+ * stripLeadingSlash('/'); // '/'
+ * stripLeadingSlash(''); // ''
+ * ```
+ */
+export function stripLeadingSlash(url: string): string {
+ // Check if the first character of the URL is a slash
+ return url.length > 1 && url[0] === '/' ? url.slice(1) : url;
+}
+
+/**
+ * Adds a leading slash to a URL if it does not already have one.
+ *
+ * @param url - The URL string to which the leading slash will be added.
+ * @returns The URL string with a leading slash.
+ *
+ * @example
+ * ```js
+ * addLeadingSlash('path'); // '/path'
+ * addLeadingSlash('/path'); // '/path'
+ * ```
+ */
+export function addLeadingSlash(url: string): string {
+ // Check if the URL already starts with a slash
+ return url[0] === '/' ? url : `/${url}`;
+}
+
+/**
+ * Adds a trailing slash to a URL if it does not already have one.
+ *
+ * @param url - The URL string to which the trailing slash will be added.
+ * @returns The URL string with a trailing slash.
+ *
+ * @example
+ * ```js
+ * addTrailingSlash('path'); // 'path/'
+ * addTrailingSlash('path/'); // 'path/'
+ * ```
+ */
+export function addTrailingSlash(url: string): string {
+ // Check if the URL already end with a slash
+ return url[url.length - 1] === '/' ? url : `${url}/`;
+}
+
+/**
+ * Joins URL parts into a single URL string.
+ *
+ * This function takes multiple URL segments, normalizes them by removing leading
+ * and trailing slashes where appropriate, and then joins them into a single URL.
+ *
+ * @param parts - The parts of the URL to join. Each part can be a string with or without slashes.
+ * @returns The joined URL string, with normalized slashes.
+ *
+ * @example
+ * ```js
+ * joinUrlParts('path/', '/to/resource'); // '/path/to/resource'
+ * joinUrlParts('/path/', 'to/resource'); // '/path/to/resource'
+ * joinUrlParts('http://localhost/path/', 'to/resource'); // 'http://localhost/path/to/resource'
+ * joinUrlParts('', ''); // '/'
+ * ```
+ */
+export function joinUrlParts(...parts: string[]): string {
+ const normalizeParts: string[] = [];
+ for (const part of parts) {
+ if (part === '') {
+ // Skip any empty parts
+ continue;
+ }
+
+ let normalizedPart = part;
+ if (part[0] === '/') {
+ normalizedPart = normalizedPart.slice(1);
+ }
+ if (part[part.length - 1] === '/') {
+ normalizedPart = normalizedPart.slice(0, -1);
+ }
+ if (normalizedPart !== '') {
+ normalizeParts.push(normalizedPart);
+ }
+ }
+
+ const protocolMatch = normalizeParts.length && /^https?:\/\//.test(normalizeParts[0]);
+ const joinedParts = normalizeParts.join('/');
- // Remove trailing slash from first part
- // Join all parts with `/`
- // Dedupe double slashes from path names
- return p.replace(/\/$/, '') + ('/' + rest.join('/')).replace(/\/\/+/g, '/');
+ return protocolMatch ? joinedParts : addLeadingSlash(joinedParts);
}
diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts
index 4895866d715b..ee14f8a26105 100644
--- a/packages/angular/ssr/src/app.ts
+++ b/packages/angular/ssr/src/app.ts
@@ -175,8 +175,15 @@ export class AngularServerApp {
}
const { redirectTo, status, renderMode } = matchedRoute;
+
if (redirectTo !== undefined) {
- return createRedirectResponse(buildPathWithParams(redirectTo, url.pathname), status);
+ return createRedirectResponse(
+ joinUrlParts(
+ request.headers.get('X-Forwarded-Prefix') ?? '',
+ buildPathWithParams(redirectTo, url.pathname),
+ ),
+ status,
+ );
}
if (renderMode === RenderMode.Prerender) {
diff --git a/packages/angular/ssr/src/utils/ng.ts b/packages/angular/ssr/src/utils/ng.ts
index 44f17781be56..a35beeff835b 100644
--- a/packages/angular/ssr/src/utils/ng.ts
+++ b/packages/angular/ssr/src/utils/ng.ts
@@ -6,10 +6,11 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import { PlatformLocation } from '@angular/common';
+import { APP_BASE_HREF, PlatformLocation } from '@angular/common';
import {
ApplicationRef,
type PlatformRef,
+ REQUEST,
type StaticProvider,
type Type,
ɵConsole,
@@ -23,7 +24,7 @@ import {
} from '@angular/platform-server';
import { ActivatedRoute, Router } from '@angular/router';
import { Console } from '../console';
-import { stripIndexHtmlFromURL, stripTrailingSlash } from './url';
+import { addTrailingSlash, joinUrlParts, stripIndexHtmlFromURL, stripTrailingSlash } from './url';
/**
* Represents the bootstrap mechanism for an Angular application.
@@ -107,13 +108,19 @@ export async function renderAngular(
if (!routerIsProvided) {
hasNavigationError = false;
- } else if (lastSuccessfulNavigation) {
+ } else if (lastSuccessfulNavigation?.finalUrl) {
hasNavigationError = false;
+
+ const requestPrefix =
+ envInjector.get(APP_BASE_HREF, null, { optional: true }) ??
+ envInjector.get(REQUEST, null, { optional: true })?.headers.get('X-Forwarded-Prefix');
+
const { pathname, search, hash } = envInjector.get(PlatformLocation);
- const finalUrl = [stripTrailingSlash(pathname), search, hash].join('');
+ const finalUrl = constructDecodedUrl({ pathname, search, hash }, requestPrefix);
+ const urlToRenderString = constructDecodedUrl(urlToRender, requestPrefix);
- if (urlToRender.href !== new URL(finalUrl, urlToRender.origin).href) {
- redirectTo = finalUrl;
+ if (urlToRenderString !== finalUrl) {
+ redirectTo = [pathname, search, hash].join('');
}
}
@@ -171,3 +178,36 @@ function asyncDestroyPlatform(platformRef: PlatformRef): Promise {
}, 0);
});
}
+
+/**
+ * Constructs a decoded URL string from its components, ensuring consistency for comparison.
+ *
+ * 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.
+ *
+ * @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.
+ */
+function constructDecodedUrl(
+ url: { pathname: string; search: string; hash: string },
+ prefix?: string | null,
+): string {
+ const { pathname, hash, search } = url;
+ const urlParts: string[] = [];
+ if (prefix && !addTrailingSlash(pathname).startsWith(addTrailingSlash(prefix))) {
+ urlParts.push(joinUrlParts(prefix, pathname));
+ } else {
+ urlParts.push(stripTrailingSlash(pathname));
+ }
+
+ urlParts.push(search, hash);
+
+ return decodeURIComponent(urlParts.join(''));
+}
diff --git a/packages/angular/ssr/src/utils/url.ts b/packages/angular/ssr/src/utils/url.ts
index 9b5edede7f8e..faabf15b1bd4 100644
--- a/packages/angular/ssr/src/utils/url.ts
+++ b/packages/angular/ssr/src/utils/url.ts
@@ -220,3 +220,18 @@ export function stripMatrixParams(pathname: string): string {
// This regex finds all occurrences of a semicolon followed by any characters
return pathname.includes(';') ? pathname.replace(MATRIX_PARAMS_REGEX, '') : pathname;
}
+
+/**
+ * Constructs a decoded URL string from its components.
+ *
+ * This function joins the pathname (with trailing slash removed), search, and hash,
+ * and then decodes the result.
+ *
+ * @param pathname - The path of the URL.
+ * @param search - The query string of the URL (including '?').
+ * @param hash - The hash fragment of the URL (including '#').
+ * @returns The constructed and decoded URL string.
+ */
+export function constructUrl(pathname: string, search: string, hash: string): string {
+ return decodeURIComponent([stripTrailingSlash(pathname), search, hash].join(''));
+}
diff --git a/packages/angular/ssr/test/app_spec.ts b/packages/angular/ssr/test/app_spec.ts
index b3ea250cae41..edf51e5e65fa 100644
--- a/packages/angular/ssr/test/app_spec.ts
+++ b/packages/angular/ssr/test/app_spec.ts
@@ -11,7 +11,8 @@
import '@angular/compiler';
/* eslint-enable import/no-unassigned-import */
-import { Component, inject } from '@angular/core';
+import { APP_BASE_HREF } from '@angular/common';
+import { Component, REQUEST, inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AngularServerApp } from '../src/app';
import { RenderMode } from '../src/routes/route-config';
@@ -125,6 +126,14 @@ describe('AngularServerApp', () => {
hash: 'f799132d0a09e0fef93c68a12e443527700eb59e6f67fcb7854c3a60ff082fde',
},
},
+ undefined,
+ undefined,
+ [
+ {
+ provide: APP_BASE_HREF,
+ useFactory: () => inject(REQUEST)?.headers.get('X-Forwarded-Prefix'),
+ },
+ ],
);
app = new AngularServerApp();
@@ -310,6 +319,50 @@ describe('AngularServerApp', () => {
expect(response?.headers.get('location')).toBe('/redirect-via-guard?filter=test');
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 decoded characters', async () => {
+ const request = new Request('http://localhost/home?email=xyz@xyz.com');
+ const response = await app.handle(request);
+ expect(response?.status).toBe(200);
+ expect(await response?.text()).toContain('Home works');
+ });
+
+ describe('APP_BASE_HREF / X-Forwarded-Prefix', () => {
+ const headers = new Headers({ 'X-Forwarded-Prefix': '/base/' });
+
+ it('should return a rendered page for known paths', async () => {
+ const request = new Request('https://example.com/home', { headers });
+ const response = await app.handle(request);
+ expect(await response?.text()).toContain('Home works');
+ });
+
+ it('returns a 302 status and redirects to the correct location when `redirectTo` is a function', async () => {
+ const response = await app.handle(
+ new Request('http://localhost/redirect-to-function', {
+ headers,
+ }),
+ );
+ expect(response?.headers.get('location')).toBe('/base/home');
+ expect(response?.status).toBe(302);
+ });
+
+ it('returns a 302 status and redirects to the correct location when `redirectTo` is a string', async () => {
+ const response = await app.handle(
+ new Request('http://localhost/redirect', {
+ headers,
+ }),
+ );
+ expect(response?.headers.get('location')).toBe('/base/home');
+ expect(response?.status).toBe(302);
+ });
+ });
});
});
});