From 903062af314a24a602d1af1c5484730d00db1c5b Mon Sep 17 00:00:00 2001 From: Arul1998 Date: Thu, 16 Jul 2026 11:48:11 +0100 Subject: [PATCH 1/2] 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 --- .../src/tools/esbuild/bundler-context.ts | 78 ++++++++++++++- .../src/tools/esbuild/bundler-context_spec.ts | 97 +++++++++++++++++++ 2 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 packages/angular/build/src/tools/esbuild/bundler-context_spec.ts diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts index d3f3ca567a0f..eece7e7353de 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,68 @@ 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); + } + 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..7d6fb2ce2d99 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts @@ -0,0 +1,97 @@ +/** + * @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'), + }, + }, + }; + + 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(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); + }); +}); From df609300ddbdc4a05d8421ffa174ee8fd7ffc180 Mon Sep 17 00:00:00 2001 From: Arul1998 Date: Thu, 16 Jul 2026 12:10:13 +0100 Subject: [PATCH 2/2] fix(@angular/build): remap metafile cssBundle paths as well Extends the metafile base path remapping to the cssBundle property of outputs, keeping all metafile path properties consistent with the workspace root. Addresses code review feedback. --- packages/angular/build/src/tools/esbuild/bundler-context.ts | 3 +++ .../angular/build/src/tools/esbuild/bundler-context_spec.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts index eece7e7353de..58f2df2a05c8 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-context.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -548,6 +548,9 @@ export function remapMetafileBasePath(metafile: Metafile, fromBase: string, toBa 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); diff --git a/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts b/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts index 7d6fb2ce2d99..8806f1d90406 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts @@ -32,6 +32,7 @@ describe('remapMetafileBasePath', () => { imports: [{ path: fromBaseRelative('chunk-ABC.js'), kind: 'import-statement' }], exports: [], entryPoint: fromBaseRelative('src/main.ts'), + cssBundle: fromBaseRelative('main.css'), }, }, }; @@ -43,6 +44,7 @@ describe('remapMetafileBasePath', () => { 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'); });