Skip to content

Commit 9534f62

Browse files
authored
fix(@angular/build): scope BundlerContext invalidation and cache one-shot bundle results
Ensure `BundlerContext.invalidate()` only clears `#esbuildResult` when a changed file is in `this.watchFiles` while still invalidating `#loadCache`, preventing edits to one component stylesheet from invalidating all other components sharing the same `MemoryLoadResultCache`.
1 parent a68d0df commit 9534f62

2 files changed

Lines changed: 174 additions & 35 deletions

File tree

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

Lines changed: 65 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export class BundlerContext {
8181
#optionsFactory: BundlerOptionsFactory<BuildOptions & { metafile: true; write: false }>;
8282
#shouldCacheResult: boolean;
8383
#loadCache?: LoadResultCache;
84+
#loadCacheKeys?: Set<string>;
8485
#invalidationEpoch = 0;
8586
readonly watchFiles = new Set<string>();
8687

@@ -222,16 +223,34 @@ export class BundlerContext {
222223

223224
// eslint-disable-next-line max-lines-per-function
224225
async #performBundle(): Promise<BundleContextResult> {
226+
this.#loadCacheKeys?.clear();
227+
225228
// Create esbuild options if not present
226229
if (this.#esbuildOptions === undefined) {
227230
if (this.incremental && !this.#loadCache) {
228231
this.#loadCache = new MemoryLoadResultCache();
229232
}
230-
this.#esbuildOptions = this.#optionsFactory(this.#loadCache);
231-
}
232-
233-
if (this.incremental) {
234-
this.watchFiles.clear();
233+
const loadCache = this.#loadCache;
234+
const scopedLoadCache: LoadResultCache | undefined =
235+
this.incremental && loadCache
236+
? {
237+
get: (path) => {
238+
(this.#loadCacheKeys ??= new Set()).add(path);
239+
240+
return loadCache.get(path);
241+
},
242+
put: (path, result) => {
243+
(this.#loadCacheKeys ??= new Set()).add(path);
244+
245+
return loadCache.put(path, result);
246+
},
247+
invalidate: (path) => loadCache.invalidate(path),
248+
get watchFiles() {
249+
return loadCache.watchFiles;
250+
},
251+
}
252+
: loadCache;
253+
this.#esbuildOptions = this.#optionsFactory(scopedLoadCache);
235254
}
236255

237256
let result: BuildResult<{ metafile: true; write: false }>;
@@ -263,7 +282,7 @@ export class BundlerContext {
263282
// Build failures will throw an exception which contains errors/warnings
264283
if (isEsBuildFailure(failure)) {
265284
this.#addErrorsToWatch(failure);
266-
this.#addLoadCacheFilesToWatch();
285+
await this.#addLoadCacheFilesToWatch();
267286

268287
return failure;
269288
} else {
@@ -275,6 +294,8 @@ export class BundlerContext {
275294
// While this should technically not be linked to incremental mode, incremental is only
276295
// currently enabled with watch mode where watch files are needed.
277296
if (this.incremental) {
297+
this.watchFiles.clear();
298+
278299
// Add input files except virtual angular files which do not exist on disk
279300
for (const input of Object.keys(result.metafile.inputs)) {
280301
const isInternal = isInternalAngularFile(input) || isInternalBundlerFile(input);
@@ -309,7 +330,7 @@ export class BundlerContext {
309330
// Return if the build encountered any errors
310331
if (result.errors.length) {
311332
this.#addErrorsToWatch(result);
312-
this.#addLoadCacheFilesToWatch();
333+
await this.#addLoadCacheFilesToWatch();
313334

314335
return {
315336
errors: result.errors,
@@ -425,12 +446,26 @@ export class BundlerContext {
425446
}
426447

427448
#addErrorsToWatch(result: BuildFailure | BuildResult): void {
449+
const entryPoints = this.#esbuildOptions?.entryPoints;
450+
if (entryPoints) {
451+
const entries = Array.isArray(entryPoints)
452+
? entryPoints.map((entry) => (typeof entry === 'string' ? entry : entry.in))
453+
: Object.values(entryPoints);
454+
for (const entry of entries) {
455+
if (isInternalAngularFile(entry) || isInternalBundlerFile(entry)) {
456+
continue;
457+
}
458+
459+
this.watchFiles.add(isAbsolute(entry) ? normalize(entry) : join(this.workspaceRoot, entry));
460+
}
461+
}
462+
428463
for (const error of result.errors) {
429464
let file = error.location?.file;
430465
if (file && !isInternalAngularFile(file)) {
431466
this.watchFiles.add(isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file));
432467
}
433-
for (const note of error.notes) {
468+
for (const note of error.notes ?? []) {
434469
file = note.location?.file;
435470
if (file && !isInternalAngularFile(file)) {
436471
this.watchFiles.add(isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file));
@@ -439,12 +474,24 @@ export class BundlerContext {
439474
}
440475
}
441476

442-
#addLoadCacheFilesToWatch(): void {
443-
if (this.incremental && this.#loadCache) {
444-
for (const file of this.#loadCache.watchFiles) {
445-
if (!isInternalAngularFile(file)) {
446-
this.watchFiles.add(isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file));
477+
async #addLoadCacheFilesToWatch(): Promise<void> {
478+
const loadCache = this.#loadCache;
479+
if (!this.incremental || !loadCache || !this.#loadCacheKeys) {
480+
return;
481+
}
482+
483+
const results = await Promise.all(Array.from(this.#loadCacheKeys, (key) => loadCache.get(key)));
484+
for (const cachedResult of results) {
485+
if (!cachedResult?.watchFiles) {
486+
continue;
487+
}
488+
489+
for (const file of cachedResult.watchFiles) {
490+
if (isInternalAngularFile(file)) {
491+
continue;
447492
}
493+
494+
this.watchFiles.add(isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file));
448495
}
449496
}
450497
}
@@ -499,30 +546,13 @@ export class BundlerContext {
499546
candidateFiles = normalizedFiles;
500547
}
501548

502-
let invalid = false;
503-
for (const file of candidateFiles) {
504-
if (this.#loadCache?.invalidate(file)) {
505-
invalid = true;
549+
if (this.#loadCache) {
550+
for (const file of candidateFiles) {
551+
this.#loadCache.invalidate(file);
506552
}
507553
}
508554

509-
if (!invalid) {
510-
if (this.watchFiles.size < candidateFiles.size) {
511-
for (const file of this.watchFiles) {
512-
if (candidateFiles.has(file)) {
513-
invalid = true;
514-
break;
515-
}
516-
}
517-
} else {
518-
for (const file of candidateFiles) {
519-
if (this.watchFiles.has(file)) {
520-
invalid = true;
521-
break;
522-
}
523-
}
524-
}
525-
}
555+
const invalid = !this.watchFiles.isDisjointFrom(candidateFiles);
526556

527557
if (invalid) {
528558
this.#invalidationEpoch++;
@@ -544,6 +574,7 @@ export class BundlerContext {
544574
this.#esbuildResult = undefined;
545575
this.#activeBundlePromise = undefined;
546576
this.#loadCache = undefined;
577+
this.#loadCacheKeys = undefined;
547578
await this.#esbuildContext?.dispose();
548579
} finally {
549580
this.#esbuildContext = undefined;

‎packages/angular/build/src/tools/esbuild/bundler-context_spec.ts‎

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,21 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import assert from 'node:assert';
10+
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
11+
import { join } from 'node:path';
912
import { BundlerContext } from './bundler-context';
10-
import { MemoryLoadResultCache } from './load-result-cache';
13+
import { MemoryLoadResultCache, createCachedLoad } from './load-result-cache';
1114

1215
describe('BundlerContext', () => {
16+
let tempRoot: string;
17+
18+
beforeAll(async () => {
19+
const baseTmpDir = process.env['TEST_TMPDIR'];
20+
assert(baseTmpDir, 'TEST_TMPDIR is not set');
21+
tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-bundler-context-test-'));
22+
});
23+
1324
describe('invalidate', () => {
1425
it('should return false when incremental is disabled', () => {
1526
const context = new BundlerContext('/workspace', /* incremental */ false, () => ({
@@ -83,12 +94,73 @@ describe('BundlerContext', () => {
8394
/* initialFilter */ undefined,
8495
loadCache,
8596
);
97+
context.watchFiles.add('/workspace/src/app.css');
8698

8799
expect(loadCache.get('file:/workspace/src/app.css')).toBeDefined();
88100
expect(context.invalidate(['/workspace/src/app.css'])).toBeTrue();
89101
expect(loadCache.get('file:/workspace/src/app.css')).toBeUndefined();
90102
});
91103

104+
it('should not invalidate unrelated contexts sharing a load cache', async () => {
105+
const workspaceDir = await mkdtemp(join(tempRoot, 'shared-cache-'));
106+
const compAPath = join(workspaceDir, 'comp-a.css');
107+
const compBPath = join(workspaceDir, 'comp-b.css');
108+
await writeFile(compAPath, '.a { color: red; }');
109+
await writeFile(compBPath, '.b { color: blue; }');
110+
111+
const sharedCache = new MemoryLoadResultCache();
112+
const createCssContext = (entry: string) =>
113+
new BundlerContext(
114+
workspaceDir,
115+
/* incremental */ true,
116+
(loadCache) => ({
117+
absWorkingDir: workspaceDir,
118+
bundle: true,
119+
entryPoints: [entry],
120+
outdir: workspaceDir,
121+
plugins: [
122+
{
123+
name: 'test-cached-css',
124+
setup(build) {
125+
build.onLoad(
126+
{ filter: /\.css$/ },
127+
createCachedLoad(loadCache, async (args) => ({
128+
contents: await readFile(args.path, 'utf8'),
129+
loader: 'css',
130+
watchFiles: [args.path],
131+
})),
132+
);
133+
},
134+
},
135+
],
136+
}),
137+
/* useContext */ false,
138+
/* initialFilter */ undefined,
139+
sharedCache,
140+
);
141+
142+
const contextA = createCssContext(compAPath);
143+
const contextB = createCssContext(compBPath);
144+
145+
const resultA1 = await contextA.bundle();
146+
const resultB1 = await contextB.bundle();
147+
expect(resultA1.errors).toBeUndefined();
148+
expect(resultB1.errors).toBeUndefined();
149+
150+
await writeFile(compAPath, '.a { color: green; }');
151+
152+
// Even if contextB.invalidate runs first against the shared cache, contextB must not be invalidated
153+
expect(contextB.invalidate([compAPath])).toBeFalse();
154+
expect(contextA.invalidate([compAPath])).toBeTrue();
155+
156+
const resultB2 = await contextB.bundle();
157+
const resultA2 = await contextA.bundle();
158+
expect(resultB2).toBe(resultB1);
159+
expect(resultA2).not.toBe(resultA1);
160+
assert(!resultA2.errors);
161+
expect(resultA2.outputFiles[0].text).toContain('green');
162+
});
163+
92164
it('should work when watchFiles is smaller than changed files', () => {
93165
const context = new BundlerContext('/workspace', /* incremental */ true, () => ({
94166
entryPoints: ['main.js'],
@@ -110,4 +182,40 @@ describe('BundlerContext', () => {
110182
expect(context.invalidate(new Set(['/workspace/src/file50.css']))).toBeTrue();
111183
});
112184
});
185+
186+
describe('bundle', () => {
187+
it('should recover when fixing a broken @import in plain CSS in watch mode', async () => {
188+
const workspaceDir = await mkdtemp(join(tempRoot, 'css-import-recovery-'));
189+
const compCssPath = join(workspaceDir, 'comp.css');
190+
const subCssPath = join(workspaceDir, 'sub.css');
191+
await writeFile(compCssPath, "@import './sub.css';");
192+
await writeFile(subCssPath, "@import './non-existent.css';");
193+
194+
const context = new BundlerContext(
195+
workspaceDir,
196+
/* incremental */ true,
197+
() => ({
198+
absWorkingDir: workspaceDir,
199+
bundle: true,
200+
entryPoints: [compCssPath],
201+
outdir: workspaceDir,
202+
logLevel: 'silent',
203+
}),
204+
/* useContext */ false,
205+
);
206+
207+
const failedResult = await context.bundle();
208+
expect(failedResult.errors).toBeDefined();
209+
expect(context.watchFiles.has(subCssPath)).toBeTrue();
210+
expect(context.watchFiles.has(compCssPath)).toBeTrue();
211+
212+
// Fix sub.css and verify invalidation + recovery
213+
await writeFile(subCssPath, '.sub { color: purple; }');
214+
expect(context.invalidate([subCssPath])).toBeTrue();
215+
216+
const recoveredResult = await context.bundle();
217+
assert(!recoveredResult.errors);
218+
expect(recoveredResult.outputFiles[0].text).toContain('purple');
219+
});
220+
});
113221
});

0 commit comments

Comments
 (0)