From c9aa52e70a335f583865cd4490cd928450da8b14 Mon Sep 17 00:00:00 2001 From: Arul1998 Date: Wed, 22 Jul 2026 00:05:15 +0100 Subject: [PATCH] fix(@angular/build): remap metafile paths when workspace root is a symlink or junction esbuild always resolves its working directory through symbolic links and Windows directory junctions, so when preserveSymlinks is enabled the metafile paths are relative to a different base than the workspace root. This caused initial file detection to silently fail and index.html to be generated without script tags. The metafile paths are now remapped to be relative to the workspace root. Fixes #32306 --- packages/angular/build/BUILD.bazel | 1 + .../src/tools/esbuild/bundler-context.ts | 81 ++++++++++++++- .../src/tools/esbuild/bundler-context_spec.ts | 99 +++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 packages/angular/build/src/tools/esbuild/bundler-context_spec.ts diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index 52eb43f9472c..efb581f0f1df 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -146,6 +146,7 @@ ts_project( ":node_modules/@babel/core", "//:node_modules/@angular/compiler-cli", "//:node_modules/@types/jasmine", + "//:node_modules/esbuild", "//:node_modules/prettier", "//:node_modules/typescript", "//packages/angular/build/private", diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts index d3f3ca567a0f..58f2df2a05c8 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-context.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -17,7 +17,9 @@ import { context, } from 'esbuild'; import assert from 'node:assert'; -import { basename, extname, join, relative } from 'node:path'; +import { realpathSync } from 'node:fs'; +import { basename, extname, join, relative, resolve } from 'node:path'; +import { toPosixPath } from '../../utils/path'; import { SERVER_GENERATED_EXTERNALS } from '../../utils/server-rendering/manifest'; import { type BuildOutputFile, @@ -64,6 +66,7 @@ export class BundlerContext { #optionsFactory: BundlerOptionsFactory; #shouldCacheResult: boolean; #loadCache?: MemoryLoadResultCache; + #realWorkspaceRoot?: string; readonly watchFiles = new Set(); constructor( @@ -261,6 +264,17 @@ 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. @@ -487,6 +501,71 @@ 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 new file mode 100644 index 000000000000..8806f1d90406 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts @@ -0,0 +1,99 @@ +/** + * @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); + }); +});