From bea7b63bc467e0fdd0e4f9a96b2e20c0393d56f2 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:24:04 -0400 Subject: [PATCH] refactor(@angular/build): remove unnecessary realpath resolution for workspace root Previously, `canonicalizePath` resolved `context.workspaceRoot` using `realpathSync` unless `preserveSymlinks` was enabled. Resolving the workspace root was originally intended to align workspace paths with Node/TypeScript default physical file resolution, but created an unnecessary path asymmetry between `preserveSymlinks` settings and mutated the logical workspace path. Furthermore, passing the resolved real path as `absWorkingDir` to esbuild caused esbuild to format metafile paths relative to the real path rather than the logical workspace root. This required workarounds to remap metafile paths back to the workspace root. This change is safe today because Angular compiler plugins and path resolution utilities normalize relative paths dynamically without requiring `workspaceRoot` to be realpath'd. Removing `realpathSync` from `canonicalizePath` ensures `workspaceRoot` consistently remains the logical workspace path across all builders, esbuild natively formats metafile paths relative to `workspaceRoot`, and `remapMetafileBasePath` is no longer needed. --- .../build/src/builders/application/options.ts | 2 +- .../build/src/builders/unit-test/options.ts | 2 +- .../src/tools/esbuild/bundler-context.ts | 81 +-------------- .../src/tools/esbuild/bundler-context_spec.ts | 99 ------------------- packages/angular/build/src/utils/path.ts | 14 +-- packages/angular/build/src/utils/path_spec.ts | 16 ++- 6 files changed, 23 insertions(+), 191 deletions(-) delete mode 100644 packages/angular/build/src/tools/esbuild/bundler-context_spec.ts diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index 4ec810ff3c07..b3c180843f70 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -160,7 +160,7 @@ export async function normalizeOptions( options.preserveSymlinks ?? process.execArgv.includes('--preserve-symlinks'); // Setup base paths based on workspace root and project information - const workspaceRoot = canonicalizePath(context.workspaceRoot, preserveSymlinks); + const workspaceRoot = canonicalizePath(context.workspaceRoot); const projectMetadata = await context.getProjectMetadata(projectName); const { projectRoot, projectSourceRoot } = getProjectRootPaths(workspaceRoot, projectMetadata); diff --git a/packages/angular/build/src/builders/unit-test/options.ts b/packages/angular/build/src/builders/unit-test/options.ts index d3402b090e0f..1206bb2c1f21 100644 --- a/packages/angular/build/src/builders/unit-test/options.ts +++ b/packages/angular/build/src/builders/unit-test/options.ts @@ -58,7 +58,7 @@ export async function normalizeOptions( : process.execArgv.includes('--preserve-symlinks'); // Setup base paths based on workspace root and project information - const workspaceRoot = canonicalizePath(context.workspaceRoot, preserveSymlinks); + const workspaceRoot = canonicalizePath(context.workspaceRoot); const projectMetadata = await context.getProjectMetadata(projectName); const { projectRoot, projectSourceRoot } = getProjectRootPaths(workspaceRoot, projectMetadata); diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts index 58f2df2a05c8..d3f3ca567a0f 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-context.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -17,9 +17,7 @@ import { context, } from 'esbuild'; import assert from 'node:assert'; -import { realpathSync } from 'node:fs'; -import { basename, extname, join, relative, resolve } from 'node:path'; -import { toPosixPath } from '../../utils/path'; +import { basename, extname, join, relative } from 'node:path'; import { SERVER_GENERATED_EXTERNALS } from '../../utils/server-rendering/manifest'; import { type BuildOutputFile, @@ -66,7 +64,6 @@ export class BundlerContext { #optionsFactory: BundlerOptionsFactory; #shouldCacheResult: boolean; #loadCache?: MemoryLoadResultCache; - #realWorkspaceRoot?: string; readonly watchFiles = new Set(); constructor( @@ -264,17 +261,6 @@ export class BundlerContext { } } - // esbuild always resolves its working directory through symbolic links (including - // Windows directory junctions) and generates metafile paths relative to the resolved - // path. When `preserveSymlinks` is enabled, the workspace root is intentionally not - // resolved, and the metafile paths are then relative to a different base directory. - // The paths are remapped so that all downstream consumers can rely on the documented - // invariant that metafile paths are relative to the workspace root. - this.#realWorkspaceRoot ??= realpathSync(this.workspaceRoot); - if (this.#realWorkspaceRoot !== this.workspaceRoot) { - remapMetafileBasePath(result.metafile, this.#realWorkspaceRoot, this.workspaceRoot); - } - // Update files that should be watched. // While this should technically not be linked to incremental mode, incremental is only // currently enabled with watch mode where watch files are needed. @@ -501,71 +487,6 @@ export class BundlerContext { } } -/** - * Remaps all relative paths within an esbuild metafile from one base directory to another. - * Virtual files (e.g., `angular:` namespaced or bundler generated), external imports, and - * non-relative paths are left unmodified. - * - * @param metafile The metafile to update in place. - * @param fromBase The absolute base directory the metafile paths are currently relative to. - * @param toBase The absolute base directory the metafile paths should be made relative to. - */ -export function remapMetafileBasePath(metafile: Metafile, fromBase: string, toBase: string): void { - const remapped = new Map(); - const remap = (value: string): string => { - // Skip virtual files and paths with a scheme-like or namespace prefix (e.g., `angular:`) - if ( - isInternalAngularFile(value) || - isInternalBundlerFile(value) || - /^[^\\/.]{2,}:/.test(value) - ) { - return value; - } - - let result = remapped.get(value); - if (result === undefined) { - // esbuild metafile paths always use POSIX path separators - result = toPosixPath(relative(toBase, resolve(fromBase, value))); - remapped.set(value, result); - } - - return result; - }; - - const inputs: Metafile['inputs'] = {}; - for (const [key, value] of Object.entries(metafile.inputs)) { - for (const importRecord of value.imports) { - if (!importRecord.external) { - importRecord.path = remap(importRecord.path); - } - } - inputs[remap(key)] = value; - } - metafile.inputs = inputs; - - const outputs: Metafile['outputs'] = {}; - for (const [key, value] of Object.entries(metafile.outputs)) { - if (value.entryPoint !== undefined) { - value.entryPoint = remap(value.entryPoint); - } - if (value.cssBundle !== undefined) { - value.cssBundle = remap(value.cssBundle); - } - for (const importRecord of value.imports) { - if (!importRecord.external) { - importRecord.path = remap(importRecord.path); - } - } - const outputInputs: (typeof value)['inputs'] = {}; - for (const [inputKey, inputValue] of Object.entries(value.inputs)) { - outputInputs[remap(inputKey)] = inputValue; - } - value.inputs = outputInputs; - outputs[remap(key)] = value; - } - metafile.outputs = outputs; -} - function isInternalAngularFile(file: string) { return file.startsWith('angular:'); } diff --git a/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts b/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts deleted file mode 100644 index 8806f1d90406..000000000000 --- a/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -import type { Metafile } from 'esbuild'; -import { join, relative } from 'node:path'; -import { remapMetafileBasePath } from './bundler-context'; - -describe('remapMetafileBasePath', () => { - // Simulates a workspace root accessed through a symbolic link or Windows - // directory junction (`toBase`) that resolves to a different real path - // (`fromBase`), as esbuild resolves its working directory through links. - const fromBase = join('/real', 'projects', 'demo'); - const toBase = join('/linked', 'demo'); - - /** Creates a metafile path as esbuild would: relative to the resolved (real) base. */ - const fromBaseRelative = (filePath: string): string => relative(fromBase, join(toBase, filePath)); - - it('remaps input and output paths onto the target base directory', () => { - const metafile: Metafile = { - inputs: { - [fromBaseRelative('src/main.ts')]: { bytes: 10, imports: [] }, - }, - outputs: { - [fromBaseRelative('main.js')]: { - bytes: 100, - inputs: { [fromBaseRelative('src/main.ts')]: { bytesInOutput: 10 } }, - imports: [{ path: fromBaseRelative('chunk-ABC.js'), kind: 'import-statement' }], - exports: [], - entryPoint: fromBaseRelative('src/main.ts'), - cssBundle: fromBaseRelative('main.css'), - }, - }, - }; - - remapMetafileBasePath(metafile, fromBase, toBase); - - expect(Object.keys(metafile.inputs)).toEqual(['src/main.ts']); - expect(Object.keys(metafile.outputs)).toEqual(['main.js']); - - const output = metafile.outputs['main.js']; - expect(output.entryPoint).toBe('src/main.ts'); - expect(output.cssBundle).toBe('main.css'); - expect(Object.keys(output.inputs)).toEqual(['src/main.ts']); - expect(output.imports[0].path).toBe('chunk-ABC.js'); - }); - - it('does not modify virtual and namespaced files', () => { - const metafile: Metafile = { - inputs: { - 'angular:polyfills': { - bytes: 10, - imports: [{ path: '', kind: 'import-statement' }], - }, - }, - outputs: { - [fromBaseRelative('polyfills.js')]: { - bytes: 100, - inputs: { 'angular:polyfills': { bytesInOutput: 10 } }, - imports: [], - exports: [], - entryPoint: 'angular:polyfills', - }, - }, - }; - - remapMetafileBasePath(metafile, fromBase, toBase); - - expect(Object.keys(metafile.inputs)).toEqual(['angular:polyfills']); - expect(metafile.inputs['angular:polyfills'].imports[0].path).toBe(''); - - const output = metafile.outputs['polyfills.js']; - expect(output.entryPoint).toBe('angular:polyfills'); - expect(Object.keys(output.inputs)).toEqual(['angular:polyfills']); - }); - - it('does not modify external imports', () => { - const externalPath = 'https://example.com/module.js'; - const metafile: Metafile = { - inputs: {}, - outputs: { - [fromBaseRelative('main.js')]: { - bytes: 100, - inputs: {}, - imports: [{ path: externalPath, kind: 'import-statement', external: true }], - exports: [], - }, - }, - }; - - remapMetafileBasePath(metafile, fromBase, toBase); - - expect(metafile.outputs['main.js'].imports[0].path).toBe(externalPath); - }); -}); diff --git a/packages/angular/build/src/utils/path.ts b/packages/angular/build/src/utils/path.ts index 4056972f790c..a236878d134e 100644 --- a/packages/angular/build/src/utils/path.ts +++ b/packages/angular/build/src/utils/path.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import { realpathSync } from 'node:fs'; import { isAbsolute, posix, relative, resolve } from 'node:path'; import { platform } from 'node:process'; @@ -53,18 +52,15 @@ export function isSubDirectory(parent: string, child: string): boolean { } /** - * Canonicalizes a file path by normalising Windows drive-letter casing to uppercase - * and optionally resolving symbolic links. + * Canonicalizes a file path by normalising Windows drive-letter casing to uppercase. * * @param pathString - The file path to canonicalize. - * @param preserveSymlinks - If true, symbolic links will not be resolved. * @returns The canonicalized file path. */ -export function canonicalizePath(pathString: string, preserveSymlinks = false): string { - const resolved = preserveSymlinks ? pathString : realpathSync(pathString); - if (platform === 'win32' && /^[a-z]:/.test(resolved)) { - return resolved[0].toUpperCase() + resolved.slice(1); +export function canonicalizePath(pathString: string): string { + if (platform === 'win32' && /^[a-z]:/.test(pathString)) { + return pathString[0].toUpperCase() + pathString.slice(1); } - return resolved; + return pathString; } diff --git a/packages/angular/build/src/utils/path_spec.ts b/packages/angular/build/src/utils/path_spec.ts index 84c7d8ac31c4..cd8ca3372433 100644 --- a/packages/angular/build/src/utils/path_spec.ts +++ b/packages/angular/build/src/utils/path_spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { isSubDirectory } from './path'; +import { canonicalizePath, isSubDirectory } from './path'; describe('isSubDirectory', () => { it('should return true for a direct child', () => { @@ -39,3 +39,17 @@ describe('isSubDirectory', () => { expect(isSubDirectory('/foo/bar', '/foo/bar/..baz/qux')).toBeTrue(); }); }); + +describe('canonicalizePath', () => { + it('should return the path unmodified on POSIX systems', () => { + expect(canonicalizePath('/foo/bar/baz')).toBe('/foo/bar/baz'); + }); + + if (process.platform === 'win32') { + it('should uppercase Windows drive-letter casing', () => { + expect(canonicalizePath('c:/foo/bar')).toBe('C:/foo/bar'); + expect(canonicalizePath('d:\\foo\\bar')).toBe('D:\\foo\\bar'); + expect(canonicalizePath('C:/foo/bar')).toBe('C:/foo/bar'); + }); + } +});