Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/angular/build/src/builders/application/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

/**
Expand Down
20 changes: 4 additions & 16 deletions packages/angular/build/src/utils/server-rendering/prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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,
Expand All @@ -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()),
});
}
}
Expand Down Expand Up @@ -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;
}
118 changes: 112 additions & 6 deletions packages/angular/build/src/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
30 changes: 26 additions & 4 deletions packages/angular/ssr/src/utils/ng.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
}
}

Expand Down Expand Up @@ -171,3 +173,23 @@ function asyncDestroyPlatform(platformRef: PlatformRef): Promise<void> {
}, 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);
}
15 changes: 15 additions & 0 deletions packages/angular/ssr/src/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(''));
}
14 changes: 14 additions & 0 deletions packages/angular/ssr/test/app-engine_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]');
const response = await appEngine.handle(request);
expect(response?.status).toBe(200);
expect(await response?.text()).toContain('Home works');
});
});
});