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/utils/ng.ts b/packages/angular/ssr/src/utils/ng.ts index 44f17781be56..f0e200323436 100644 --- a/packages/angular/ssr/src/utils/ng.ts +++ b/packages/angular/ssr/src/utils/ng.ts @@ -107,13 +107,15 @@ export async function renderAngular( if (!routerIsProvided) { hasNavigationError = false; - } else if (lastSuccessfulNavigation) { + } else if (lastSuccessfulNavigation?.finalUrl) { hasNavigationError = false; + const { pathname, search, hash } = envInjector.get(PlatformLocation); - const finalUrl = [stripTrailingSlash(pathname), search, hash].join(''); + const finalUrl = constructDecodedUrl({ pathname, search, hash }); + const urlToRenderString = constructDecodedUrl(urlToRender); - if (urlToRender.href !== new URL(finalUrl, urlToRender.origin).href) { - redirectTo = finalUrl; + if (urlToRenderString !== finalUrl) { + redirectTo = [pathname, search, hash].join(''); } } @@ -171,3 +173,23 @@ 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 '#'). + * @returns The constructed and decoded URL string. + */ +function constructDecodedUrl(url: { pathname: string; search: string; hash: string }): string { + const joinedUrl = [stripTrailingSlash(url.pathname), url.search, url.hash].join(''); + + return decodeURIComponent(joinedUrl); +} 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-engine_spec.ts b/packages/angular/ssr/test/app-engine_spec.ts index 76b68f6d4a82..0b970f7a309e 100644 --- a/packages/angular/ssr/test/app-engine_spec.ts +++ b/packages/angular/ssr/test/app-engine_spec.ts @@ -273,5 +273,19 @@ describe('AngularAppEngine', () => { const response = await appEngine.handle(request); expect(await response?.text()).toContain('Home works'); }); + + it('should work with encoded characters', async () => { + const request = new Request('https://example.com/home?email=xyz%40xyz.com'); + const response = await appEngine.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('https://example.com/home?email=xyz@xyz.com'); + const response = await appEngine.handle(request); + expect(response?.status).toBe(200); + expect(await response?.text()).toContain('Home works'); + }); }); });