Skip to content

Commit 72cc982

Browse files
committed
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`. Also scope `#addLoadCacheFilesToWatch()` to the load cache keys accessed by the current `BundlerContext`, retain `watchFiles` across failed rebuilds while tracking entry points and unresolved relative imports on build failures, and cache `#esbuildResult` in non-incremental builds so multiple components sharing the same `styleUrl` do not re-run esbuild.
1 parent 193abc0 commit 72cc982

2 files changed

Lines changed: 227 additions & 34 deletions

File tree

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

Lines changed: 75 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
context,
1818
} from 'esbuild';
1919
import assert from 'node:assert';
20-
import { basename, extname, isAbsolute, join, normalize, relative } from 'node:path';
20+
import { basename, dirname, extname, isAbsolute, join, normalize, relative } from 'node:path';
2121
import { SERVER_GENERATED_EXTERNALS } from '../../utils/server-rendering/manifest';
2222
import {
2323
type BuildOutputFile,
@@ -81,6 +81,7 @@ export class BundlerContext {
8181
#optionsFactory: BundlerOptionsFactory<BuildOptions & { metafile: true; write: false }>;
8282
#shouldCacheResult: boolean;
8383
#loadCache?: LoadResultCache;
84+
readonly #loadCacheKeys = new Set<string>();
8485
#invalidationEpoch = 0;
8586
readonly watchFiles = new Set<string>();
8687

@@ -93,8 +94,9 @@ export class BundlerContext {
9394
sharedLoadCache?: LoadResultCache,
9495
) {
9596
this.#loadCache = sharedLoadCache;
96-
// To cache the results an option factory is needed to capture the full set of dependencies
97-
this.#shouldCacheResult = incremental && typeof options === 'function';
97+
// In incremental mode, an option factory is needed to capture the full set of dependencies.
98+
// In non-incremental mode, results can always be cached for the duration of the build.
99+
this.#shouldCacheResult = !incremental || typeof options === 'function';
98100
this.#optionsFactory = (...args) => {
99101
const baseOptions = typeof options === 'function' ? options(...args) : options;
100102

@@ -227,11 +229,24 @@ export class BundlerContext {
227229
if (this.incremental && !this.#loadCache) {
228230
this.#loadCache = new MemoryLoadResultCache();
229231
}
230-
this.#esbuildOptions = this.#optionsFactory(this.#loadCache);
231-
}
232-
233-
if (this.incremental) {
234-
this.watchFiles.clear();
232+
const loadCache = this.#loadCache;
233+
const scopedLoadCache: LoadResultCache | undefined = loadCache && {
234+
get: (path) => {
235+
this.#loadCacheKeys.add(path);
236+
237+
return loadCache.get(path);
238+
},
239+
put: (path, result) => {
240+
this.#loadCacheKeys.add(path);
241+
242+
return loadCache.put(path, result);
243+
},
244+
invalidate: (path) => loadCache.invalidate(path),
245+
get watchFiles() {
246+
return loadCache.watchFiles;
247+
},
248+
};
249+
this.#esbuildOptions = this.#optionsFactory(scopedLoadCache);
235250
}
236251

237252
let result: BuildResult<{ metafile: true; write: false }>;
@@ -263,7 +278,7 @@ export class BundlerContext {
263278
// Build failures will throw an exception which contains errors/warnings
264279
if (isEsBuildFailure(failure)) {
265280
this.#addErrorsToWatch(failure);
266-
this.#addLoadCacheFilesToWatch();
281+
await this.#addLoadCacheFilesToWatch();
267282

268283
return failure;
269284
} else {
@@ -275,6 +290,8 @@ export class BundlerContext {
275290
// While this should technically not be linked to incremental mode, incremental is only
276291
// currently enabled with watch mode where watch files are needed.
277292
if (this.incremental) {
293+
this.watchFiles.clear();
294+
278295
// Add input files except virtual angular files which do not exist on disk
279296
for (const input of Object.keys(result.metafile.inputs)) {
280297
const isInternal = isInternalAngularFile(input) || isInternalBundlerFile(input);
@@ -309,7 +326,7 @@ export class BundlerContext {
309326
// Return if the build encountered any errors
310327
if (result.errors.length) {
311328
this.#addErrorsToWatch(result);
312-
this.#addLoadCacheFilesToWatch();
329+
await this.#addLoadCacheFilesToWatch();
313330

314331
return {
315332
errors: result.errors,
@@ -425,12 +442,31 @@ export class BundlerContext {
425442
}
426443

427444
#addErrorsToWatch(result: BuildFailure | BuildResult): void {
445+
const entryPoints = this.#esbuildOptions?.entryPoints;
446+
if (entryPoints) {
447+
const entries = Array.isArray(entryPoints)
448+
? entryPoints.map((entry) => (typeof entry === 'string' ? entry : entry.in))
449+
: Object.values(entryPoints);
450+
for (const entry of entries) {
451+
if (!isInternalAngularFile(entry) && !isInternalBundlerFile(entry)) {
452+
this.watchFiles.add(
453+
isAbsolute(entry) ? normalize(entry) : join(this.workspaceRoot, entry),
454+
);
455+
}
456+
}
457+
}
458+
428459
for (const error of result.errors) {
429460
let file = error.location?.file;
430461
if (file && !isInternalAngularFile(file)) {
431-
this.watchFiles.add(isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file));
462+
const errorFile = isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file);
463+
this.watchFiles.add(errorFile);
464+
const match = /^Could not resolve "(\.\.?\/[^"]+)"/.exec(error.text);
465+
if (match) {
466+
this.watchFiles.add(join(dirname(errorFile), match[1]));
467+
}
432468
}
433-
for (const note of error.notes) {
469+
for (const note of error.notes ?? []) {
434470
file = note.location?.file;
435471
if (file && !isInternalAngularFile(file)) {
436472
this.watchFiles.add(isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file));
@@ -439,11 +475,18 @@ export class BundlerContext {
439475
}
440476
}
441477

442-
#addLoadCacheFilesToWatch(): void {
478+
async #addLoadCacheFilesToWatch(): Promise<void> {
443479
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));
480+
for (const key of this.#loadCacheKeys) {
481+
const cachedResult = await this.#loadCache.get(key);
482+
if (cachedResult?.watchFiles) {
483+
for (const file of cachedResult.watchFiles) {
484+
if (!isInternalAngularFile(file)) {
485+
this.watchFiles.add(
486+
isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file),
487+
);
488+
}
489+
}
447490
}
448491
}
449492
}
@@ -499,27 +542,25 @@ export class BundlerContext {
499542
candidateFiles = normalizedFiles;
500543
}
501544

502-
let invalid = false;
503-
for (const file of candidateFiles) {
504-
if (this.#loadCache?.invalidate(file)) {
505-
invalid = true;
545+
if (this.#loadCache) {
546+
for (const file of candidateFiles) {
547+
this.#loadCache.invalidate(file);
506548
}
507549
}
508550

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-
}
551+
let invalid = false;
552+
if (this.watchFiles.size < candidateFiles.size) {
553+
for (const file of this.watchFiles) {
554+
if (candidateFiles.has(file)) {
555+
invalid = true;
556+
break;
516557
}
517-
} else {
518-
for (const file of candidateFiles) {
519-
if (this.watchFiles.has(file)) {
520-
invalid = true;
521-
break;
522-
}
558+
}
559+
} else {
560+
for (const file of candidateFiles) {
561+
if (this.watchFiles.has(file)) {
562+
invalid = true;
563+
break;
523564
}
524565
}
525566
}
@@ -544,6 +585,7 @@ export class BundlerContext {
544585
this.#esbuildResult = undefined;
545586
this.#activeBundlePromise = undefined;
546587
this.#loadCache = undefined;
588+
this.#loadCacheKeys.clear();
547589
await this.#esbuildContext?.dispose();
548590
} finally {
549591
this.#esbuildContext = undefined;

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

Lines changed: 152 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, 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,76 @@ 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 import('node:fs/promises').then((f) =>
129+
f.readFile(args.path, 'utf8'),
130+
),
131+
loader: 'css',
132+
watchFiles: [args.path],
133+
})),
134+
);
135+
},
136+
},
137+
],
138+
}),
139+
/* useContext */ false,
140+
/* initialFilter */ undefined,
141+
sharedCache,
142+
);
143+
144+
const contextA = createCssContext(compAPath);
145+
const contextB = createCssContext(compBPath);
146+
147+
const resultA1 = await contextA.bundle();
148+
const resultB1 = await contextB.bundle();
149+
expect(resultA1.errors).toBeUndefined();
150+
expect(resultB1.errors).toBeUndefined();
151+
152+
await writeFile(compAPath, '.a { color: green; }');
153+
154+
// Even if contextB.invalidate runs first against the shared cache, contextB must not be invalidated
155+
expect(contextB.invalidate([compAPath])).toBeFalse();
156+
expect(contextA.invalidate([compAPath])).toBeTrue();
157+
158+
const resultB2 = await contextB.bundle();
159+
const resultA2 = await contextA.bundle();
160+
expect(resultB2).toBe(resultB1);
161+
expect(resultA2).not.toBe(resultA1);
162+
if (!resultA2.errors) {
163+
expect(resultA2.outputFiles[0].text).toContain('green');
164+
}
165+
});
166+
92167
it('should work when watchFiles is smaller than changed files', () => {
93168
const context = new BundlerContext('/workspace', /* incremental */ true, () => ({
94169
entryPoints: ['main.js'],
@@ -110,4 +185,80 @@ describe('BundlerContext', () => {
110185
expect(context.invalidate(new Set(['/workspace/src/file50.css']))).toBeTrue();
111186
});
112187
});
188+
189+
describe('bundle', () => {
190+
it('should recover when fixing a broken @import in plain CSS in watch mode', async () => {
191+
const workspaceDir = await mkdtemp(join(tempRoot, 'css-import-recovery-'));
192+
const compCssPath = join(workspaceDir, 'comp.css');
193+
const subCssPath = join(workspaceDir, 'sub.css');
194+
await writeFile(compCssPath, "@import './sub.css';");
195+
await writeFile(subCssPath, "@import './non-existent.css';");
196+
197+
const context = new BundlerContext(
198+
workspaceDir,
199+
/* incremental */ true,
200+
() => ({
201+
absWorkingDir: workspaceDir,
202+
bundle: true,
203+
entryPoints: [compCssPath],
204+
outdir: workspaceDir,
205+
logLevel: 'silent',
206+
}),
207+
/* useContext */ false,
208+
);
209+
210+
const failedResult = await context.bundle();
211+
expect(failedResult.errors).toBeDefined();
212+
expect(context.watchFiles.has(subCssPath)).toBeTrue();
213+
expect(context.watchFiles.has(compCssPath)).toBeTrue();
214+
215+
// Fix sub.css and verify invalidation + recovery
216+
await writeFile(subCssPath, '.sub { color: purple; }');
217+
expect(context.invalidate([subCssPath])).toBeTrue();
218+
219+
const recoveredResult = await context.bundle();
220+
expect(recoveredResult.errors).toBeUndefined();
221+
if (!recoveredResult.errors) {
222+
expect(recoveredResult.outputFiles[0].text).toContain('purple');
223+
}
224+
});
225+
226+
it('should reuse cached result when calling bundle() twice on a non-incremental context', async () => {
227+
const workspaceDir = await mkdtemp(join(tempRoot, 'non-incremental-cache-'));
228+
const stylePath = join(workspaceDir, 'shared.css');
229+
await writeFile(stylePath, '.shared { color: orange; }');
230+
231+
let loadCount = 0;
232+
const context = new BundlerContext(
233+
workspaceDir,
234+
/* incremental */ false,
235+
() => ({
236+
absWorkingDir: workspaceDir,
237+
bundle: true,
238+
entryPoints: [stylePath],
239+
outdir: workspaceDir,
240+
plugins: [
241+
{
242+
name: 'count-loads',
243+
setup(build) {
244+
build.onLoad({ filter: /\.css$/ }, async () => {
245+
loadCount++;
246+
247+
return null;
248+
});
249+
},
250+
},
251+
],
252+
}),
253+
/* useContext */ false,
254+
);
255+
256+
const firstResult = await context.bundle();
257+
const secondResult = await context.bundle();
258+
259+
expect(firstResult.errors).toBeUndefined();
260+
expect(secondResult).toBe(firstResult);
261+
expect(loadCount).toBe(1);
262+
});
263+
});
113264
});

0 commit comments

Comments
 (0)