Skip to content

Commit 19cda74

Browse files
Arul1998dgp1130
authored andcommitted
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 (cherry picked from commit 751adb9)
1 parent b8bdbaa commit 19cda74

3 files changed

Lines changed: 180 additions & 1 deletion

File tree

packages/angular/build/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ ts_project(
146146
":node_modules/@babel/core",
147147
"//:node_modules/@angular/compiler-cli",
148148
"//:node_modules/@types/jasmine",
149+
"//:node_modules/esbuild",
149150
"//:node_modules/prettier",
150151
"//:node_modules/typescript",
151152
"//packages/angular/build/private",

packages/angular/build/src/tools/esbuild/bundler-context.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import {
1717
context,
1818
} from 'esbuild';
1919
import assert from 'node:assert';
20-
import { basename, extname, join, relative } from 'node:path';
20+
import { realpathSync } from 'node:fs';
21+
import { basename, extname, join, relative, resolve } from 'node:path';
22+
import { toPosixPath } from '../../utils/path';
2123
import { SERVER_GENERATED_EXTERNALS } from '../../utils/server-rendering/manifest';
2224
import {
2325
type BuildOutputFile,
@@ -64,6 +66,7 @@ export class BundlerContext {
6466
#optionsFactory: BundlerOptionsFactory<BuildOptions & { metafile: true; write: false }>;
6567
#shouldCacheResult: boolean;
6668
#loadCache?: MemoryLoadResultCache;
69+
#realWorkspaceRoot?: string;
6770
readonly watchFiles = new Set<string>();
6871

6972
constructor(
@@ -251,6 +254,17 @@ export class BundlerContext {
251254
}
252255
}
253256

257+
// esbuild always resolves its working directory through symbolic links (including
258+
// Windows directory junctions) and generates metafile paths relative to the resolved
259+
// path. When `preserveSymlinks` is enabled, the workspace root is intentionally not
260+
// resolved, and the metafile paths are then relative to a different base directory.
261+
// The paths are remapped so that all downstream consumers can rely on the documented
262+
// invariant that metafile paths are relative to the workspace root.
263+
this.#realWorkspaceRoot ??= realpathSync(this.workspaceRoot);
264+
if (this.#realWorkspaceRoot !== this.workspaceRoot) {
265+
remapMetafileBasePath(result.metafile, this.#realWorkspaceRoot, this.workspaceRoot);
266+
}
267+
254268
// Update files that should be watched.
255269
// While this should technically not be linked to incremental mode, incremental is only
256270
// currently enabled with watch mode where watch files are needed.
@@ -477,6 +491,71 @@ export class BundlerContext {
477491
}
478492
}
479493

494+
/**
495+
* Remaps all relative paths within an esbuild metafile from one base directory to another.
496+
* Virtual files (e.g., `angular:` namespaced or bundler generated), external imports, and
497+
* non-relative paths are left unmodified.
498+
*
499+
* @param metafile The metafile to update in place.
500+
* @param fromBase The absolute base directory the metafile paths are currently relative to.
501+
* @param toBase The absolute base directory the metafile paths should be made relative to.
502+
*/
503+
export function remapMetafileBasePath(metafile: Metafile, fromBase: string, toBase: string): void {
504+
const remapped = new Map<string, string>();
505+
const remap = (value: string): string => {
506+
// Skip virtual files and paths with a scheme-like or namespace prefix (e.g., `angular:`)
507+
if (
508+
isInternalAngularFile(value) ||
509+
isInternalBundlerFile(value) ||
510+
/^[^\\/.]{2,}:/.test(value)
511+
) {
512+
return value;
513+
}
514+
515+
let result = remapped.get(value);
516+
if (result === undefined) {
517+
// esbuild metafile paths always use POSIX path separators
518+
result = toPosixPath(relative(toBase, resolve(fromBase, value)));
519+
remapped.set(value, result);
520+
}
521+
522+
return result;
523+
};
524+
525+
const inputs: Metafile['inputs'] = {};
526+
for (const [key, value] of Object.entries(metafile.inputs)) {
527+
for (const importRecord of value.imports) {
528+
if (!importRecord.external) {
529+
importRecord.path = remap(importRecord.path);
530+
}
531+
}
532+
inputs[remap(key)] = value;
533+
}
534+
metafile.inputs = inputs;
535+
536+
const outputs: Metafile['outputs'] = {};
537+
for (const [key, value] of Object.entries(metafile.outputs)) {
538+
if (value.entryPoint !== undefined) {
539+
value.entryPoint = remap(value.entryPoint);
540+
}
541+
if (value.cssBundle !== undefined) {
542+
value.cssBundle = remap(value.cssBundle);
543+
}
544+
for (const importRecord of value.imports) {
545+
if (!importRecord.external) {
546+
importRecord.path = remap(importRecord.path);
547+
}
548+
}
549+
const outputInputs: (typeof value)['inputs'] = {};
550+
for (const [inputKey, inputValue] of Object.entries(value.inputs)) {
551+
outputInputs[remap(inputKey)] = inputValue;
552+
}
553+
value.inputs = outputInputs;
554+
outputs[remap(key)] = value;
555+
}
556+
metafile.outputs = outputs;
557+
}
558+
480559
function isInternalAngularFile(file: string) {
481560
return file.startsWith('angular:');
482561
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import type { Metafile } from 'esbuild';
10+
import { join, relative } from 'node:path';
11+
import { remapMetafileBasePath } from './bundler-context';
12+
13+
describe('remapMetafileBasePath', () => {
14+
// Simulates a workspace root accessed through a symbolic link or Windows
15+
// directory junction (`toBase`) that resolves to a different real path
16+
// (`fromBase`), as esbuild resolves its working directory through links.
17+
const fromBase = join('/real', 'projects', 'demo');
18+
const toBase = join('/linked', 'demo');
19+
20+
/** Creates a metafile path as esbuild would: relative to the resolved (real) base. */
21+
const fromBaseRelative = (filePath: string): string => relative(fromBase, join(toBase, filePath));
22+
23+
it('remaps input and output paths onto the target base directory', () => {
24+
const metafile: Metafile = {
25+
inputs: {
26+
[fromBaseRelative('src/main.ts')]: { bytes: 10, imports: [] },
27+
},
28+
outputs: {
29+
[fromBaseRelative('main.js')]: {
30+
bytes: 100,
31+
inputs: { [fromBaseRelative('src/main.ts')]: { bytesInOutput: 10 } },
32+
imports: [{ path: fromBaseRelative('chunk-ABC.js'), kind: 'import-statement' }],
33+
exports: [],
34+
entryPoint: fromBaseRelative('src/main.ts'),
35+
cssBundle: fromBaseRelative('main.css'),
36+
},
37+
},
38+
};
39+
40+
remapMetafileBasePath(metafile, fromBase, toBase);
41+
42+
expect(Object.keys(metafile.inputs)).toEqual(['src/main.ts']);
43+
expect(Object.keys(metafile.outputs)).toEqual(['main.js']);
44+
45+
const output = metafile.outputs['main.js'];
46+
expect(output.entryPoint).toBe('src/main.ts');
47+
expect(output.cssBundle).toBe('main.css');
48+
expect(Object.keys(output.inputs)).toEqual(['src/main.ts']);
49+
expect(output.imports[0].path).toBe('chunk-ABC.js');
50+
});
51+
52+
it('does not modify virtual and namespaced files', () => {
53+
const metafile: Metafile = {
54+
inputs: {
55+
'angular:polyfills': {
56+
bytes: 10,
57+
imports: [{ path: '<runtime>', kind: 'import-statement' }],
58+
},
59+
},
60+
outputs: {
61+
[fromBaseRelative('polyfills.js')]: {
62+
bytes: 100,
63+
inputs: { 'angular:polyfills': { bytesInOutput: 10 } },
64+
imports: [],
65+
exports: [],
66+
entryPoint: 'angular:polyfills',
67+
},
68+
},
69+
};
70+
71+
remapMetafileBasePath(metafile, fromBase, toBase);
72+
73+
expect(Object.keys(metafile.inputs)).toEqual(['angular:polyfills']);
74+
expect(metafile.inputs['angular:polyfills'].imports[0].path).toBe('<runtime>');
75+
76+
const output = metafile.outputs['polyfills.js'];
77+
expect(output.entryPoint).toBe('angular:polyfills');
78+
expect(Object.keys(output.inputs)).toEqual(['angular:polyfills']);
79+
});
80+
81+
it('does not modify external imports', () => {
82+
const externalPath = 'https://example.com/module.js';
83+
const metafile: Metafile = {
84+
inputs: {},
85+
outputs: {
86+
[fromBaseRelative('main.js')]: {
87+
bytes: 100,
88+
inputs: {},
89+
imports: [{ path: externalPath, kind: 'import-statement', external: true }],
90+
exports: [],
91+
},
92+
},
93+
};
94+
95+
remapMetafileBasePath(metafile, fromBase, toBase);
96+
97+
expect(metafile.outputs['main.js'].imports[0].path).toBe(externalPath);
98+
});
99+
});

0 commit comments

Comments
 (0)