Skip to content

Commit a3e9efe

Browse files
clydindgp1130
authored andcommitted
fix(@angular-devkit/build-angular): fully track Web Worker file changes in watch mode
When using the application builder with a Web Worker in watch mode, A change to the Web Worker code will now invalidate the referencing source file to ensure that all changes are captured and the new output file for the Web Worker is correctly injected into the referencing output file. Previously, the Web Worker output file may have changed but the reference may not have been updated causing an old instance of the Web worker code to be used in watch mode. (cherry picked from commit 1ed3a16)
1 parent 34947fc commit a3e9efe

File tree

5 files changed

+298
-48
lines changed

5 files changed

+298
-48
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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.io/license
7+
*/
8+
9+
import { logging } from '@angular-devkit/core';
10+
import { concatMap, count, take, timeout } from 'rxjs';
11+
import { buildApplication } from '../../index';
12+
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
13+
14+
/**
15+
* Maximum time in milliseconds for single build/rebuild
16+
* This accounts for CI variability.
17+
*/
18+
export const BUILD_TIMEOUT = 30_000;
19+
20+
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
21+
describe('Behavior: "Rebuilds when Web Worker files change"', () => {
22+
it('Recovers from error when directly referenced worker file is changed', async () => {
23+
harness.useTarget('build', {
24+
...BASE_OPTIONS,
25+
watch: true,
26+
});
27+
28+
const workerCodeFile = `
29+
console.log('WORKER FILE');
30+
`;
31+
32+
const errorText = `Expected ";" but found "~"`;
33+
34+
// Create a worker file
35+
await harness.writeFile('src/app/worker.ts', workerCodeFile);
36+
37+
// Create app component that uses the directive
38+
await harness.writeFile(
39+
'src/app/app.component.ts',
40+
`
41+
import { Component } from '@angular/core'
42+
@Component({
43+
selector: 'app-root',
44+
template: '<h1>Worker Test</h1>',
45+
})
46+
export class AppComponent {
47+
worker = new Worker(new URL('./worker', import.meta.url), { type: 'module' });
48+
}
49+
`,
50+
);
51+
52+
const builderAbort = new AbortController();
53+
const buildCount = await harness
54+
.execute({ outputLogsOnFailure: false, signal: builderAbort.signal })
55+
.pipe(
56+
timeout(BUILD_TIMEOUT),
57+
concatMap(async ({ result, logs }, index) => {
58+
switch (index) {
59+
case 0:
60+
expect(result?.success).toBeTrue();
61+
62+
// Update the worker file to be invalid syntax
63+
await harness.writeFile('src/app/worker.ts', `asd;fj$3~kls;kd^(*fjlk;sdj---flk`);
64+
65+
break;
66+
case 1:
67+
expect(logs).toContain(
68+
jasmine.objectContaining<logging.LogEntry>({
69+
message: jasmine.stringMatching(errorText),
70+
}),
71+
);
72+
73+
// Make an unrelated change to verify error cache was updated
74+
// Should persist error in the next rebuild
75+
await harness.modifyFile('src/main.ts', (content) => content + '\n');
76+
77+
break;
78+
case 2:
79+
expect(logs).toContain(
80+
jasmine.objectContaining<logging.LogEntry>({
81+
message: jasmine.stringMatching(errorText),
82+
}),
83+
);
84+
85+
// Revert the change that caused the error
86+
// Should remove the error
87+
await harness.writeFile('src/app/worker.ts', workerCodeFile);
88+
89+
break;
90+
case 3:
91+
expect(result?.success).toBeTrue();
92+
expect(logs).not.toContain(
93+
jasmine.objectContaining<logging.LogEntry>({
94+
message: jasmine.stringMatching(errorText),
95+
}),
96+
);
97+
98+
// Make an unrelated change to verify error cache was updated
99+
// Should continue showing no error
100+
await harness.modifyFile('src/main.ts', (content) => content + '\n');
101+
102+
break;
103+
case 4:
104+
expect(result?.success).toBeTrue();
105+
expect(logs).not.toContain(
106+
jasmine.objectContaining<logging.LogEntry>({
107+
message: jasmine.stringMatching(errorText),
108+
}),
109+
);
110+
111+
// Test complete - abort watch mode
112+
builderAbort?.abort();
113+
break;
114+
}
115+
}),
116+
count(),
117+
)
118+
.toPromise();
119+
120+
expect(buildCount).toBe(5);
121+
});
122+
});
123+
});

packages/angular_devkit/build_angular/src/tools/esbuild/angular/compiler-plugin.ts

+112-42
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import type {
10+
BuildFailure,
1011
Metafile,
1112
OnStartResult,
1213
OutputFile,
@@ -33,6 +34,7 @@ import { AngularHostOptions } from './angular-host';
3334
import { AngularCompilation, AotCompilation, JitCompilation, NoopCompilation } from './compilation';
3435
import { SharedTSCompilationState, getSharedCompilationState } from './compilation-state';
3536
import { ComponentStylesheetBundler } from './component-stylesheets';
37+
import { FileReferenceTracker } from './file-reference-tracker';
3638
import { setupJitPluginCallbacks } from './jit-plugin-callbacks';
3739
import { SourceFileCache } from './source-file-cache';
3840

@@ -84,9 +86,11 @@ export function createCompilerPlugin(
8486
pluginOptions.sourceFileCache?.typeScriptFileCache ??
8587
new Map<string, string | Uint8Array>();
8688

87-
// The stylesheet resources from component stylesheets that will be added to the build results output files
88-
let additionalOutputFiles: OutputFile[] = [];
89-
let additionalMetafiles: Metafile[];
89+
// The resources from component stylesheets and web workers that will be added to the build results output files
90+
const additionalResults = new Map<
91+
string,
92+
{ outputFiles?: OutputFile[]; metafile?: Metafile; errors?: PartialMessage[] }
93+
>();
9094

9195
// Create new reusable compilation for the appropriate mode based on the `jit` plugin option
9296
const compilation: AngularCompilation = pluginOptions.noopTypeScriptCompilation
@@ -106,6 +110,10 @@ export function createCompilerPlugin(
106110
);
107111
let sharedTSCompilationState: SharedTSCompilationState | undefined;
108112

113+
// To fully invalidate files, track resource referenced files and their referencing source
114+
const referencedFileTracker = new FileReferenceTracker();
115+
116+
// eslint-disable-next-line max-lines-per-function
109117
build.onStart(async () => {
110118
sharedTSCompilationState = getSharedCompilationState();
111119
if (!(compilation instanceof NoopCompilation)) {
@@ -119,14 +127,24 @@ export function createCompilerPlugin(
119127
// Reset debug performance tracking
120128
resetCumulativeDurations();
121129

122-
// Reset additional output files
123-
additionalOutputFiles = [];
124-
additionalMetafiles = [];
130+
// Update the reference tracker and generate a full set of modified files for the
131+
// Angular compiler which does not have direct knowledge of transitive resource
132+
// dependencies or web worker processing.
133+
let modifiedFiles;
134+
if (
135+
pluginOptions.sourceFileCache?.modifiedFiles.size &&
136+
referencedFileTracker &&
137+
!pluginOptions.noopTypeScriptCompilation
138+
) {
139+
// TODO: Differentiate between changed input files and stale output files
140+
modifiedFiles = referencedFileTracker.update(pluginOptions.sourceFileCache.modifiedFiles);
141+
pluginOptions.sourceFileCache.invalidate(modifiedFiles);
142+
}
125143

126144
// Create Angular compiler host options
127145
const hostOptions: AngularHostOptions = {
128146
fileReplacements: pluginOptions.fileReplacements,
129-
modifiedFiles: pluginOptions.sourceFileCache?.modifiedFiles,
147+
modifiedFiles,
130148
sourceFileCache: pluginOptions.sourceFileCache,
131149
async transformStylesheet(data, containingFile, stylesheetFile) {
132150
let stylesheetResult;
@@ -142,14 +160,22 @@ export function createCompilerPlugin(
142160
);
143161
}
144162

145-
const { contents, resourceFiles, errors, warnings } = stylesheetResult;
163+
const { contents, resourceFiles, referencedFiles, errors, warnings } = stylesheetResult;
146164
if (errors) {
147165
(result.errors ??= []).push(...errors);
148166
}
149167
(result.warnings ??= []).push(...warnings);
150-
additionalOutputFiles.push(...resourceFiles);
151-
if (stylesheetResult.metafile) {
152-
additionalMetafiles.push(stylesheetResult.metafile);
168+
additionalResults.set(stylesheetFile ?? containingFile, {
169+
outputFiles: resourceFiles,
170+
metafile: stylesheetResult.metafile,
171+
});
172+
173+
if (referencedFiles) {
174+
referencedFileTracker.add(containingFile, referencedFiles);
175+
if (stylesheetFile) {
176+
// Angular AOT compiler needs modified direct resource files to correctly invalidate its analysis
177+
referencedFileTracker.add(stylesheetFile, referencedFiles);
178+
}
153179
}
154180

155181
return contents;
@@ -159,37 +185,38 @@ export function createCompilerPlugin(
159185
// The synchronous API must be used due to the TypeScript compilation currently being
160186
// fully synchronous and this process callback being called from within a TypeScript
161187
// transformer.
162-
const workerResult = build.esbuild.buildSync({
163-
platform: 'browser',
164-
write: false,
165-
bundle: true,
166-
metafile: true,
167-
format: 'esm',
168-
mainFields: ['es2020', 'es2015', 'browser', 'module', 'main'],
169-
sourcemap: pluginOptions.sourcemap,
170-
entryNames: 'worker-[hash]',
171-
entryPoints: [fullWorkerPath],
172-
absWorkingDir: build.initialOptions.absWorkingDir,
173-
outdir: build.initialOptions.outdir,
174-
minifyIdentifiers: build.initialOptions.minifyIdentifiers,
175-
minifySyntax: build.initialOptions.minifySyntax,
176-
minifyWhitespace: build.initialOptions.minifyWhitespace,
177-
target: build.initialOptions.target,
178-
});
188+
const workerResult = bundleWebWorker(build, pluginOptions, fullWorkerPath);
179189

180190
(result.warnings ??= []).push(...workerResult.warnings);
181-
additionalOutputFiles.push(...workerResult.outputFiles);
182-
if (workerResult.metafile) {
183-
additionalMetafiles.push(workerResult.metafile);
184-
}
185-
186191
if (workerResult.errors.length > 0) {
187192
(result.errors ??= []).push(...workerResult.errors);
193+
// Track worker file errors to allow rebuilds on changes
194+
referencedFileTracker.add(
195+
containingFile,
196+
workerResult.errors
197+
.map((error) => error.location?.file)
198+
.filter((file): file is string => !!file)
199+
.map((file) => path.join(build.initialOptions.absWorkingDir ?? '', file)),
200+
);
201+
additionalResults.set(fullWorkerPath, { errors: result.errors });
188202

189203
// Return the original path if the build failed
190204
return workerFile;
191205
}
192206

207+
assert('outputFiles' in workerResult, 'Invalid web worker bundle result.');
208+
additionalResults.set(fullWorkerPath, {
209+
outputFiles: workerResult.outputFiles,
210+
metafile: workerResult.metafile,
211+
});
212+
213+
referencedFileTracker.add(
214+
containingFile,
215+
Object.keys(workerResult.metafile.inputs).map((input) =>
216+
path.join(build.initialOptions.absWorkingDir ?? '', input),
217+
),
218+
);
219+
193220
// Return bundled worker file entry name to be used in the built output
194221
const workerCodeFile = workerResult.outputFiles.find((file) =>
195222
file.path.endsWith('.js'),
@@ -277,9 +304,20 @@ export function createCompilerPlugin(
277304
}
278305
});
279306

307+
// Add errors from failed additional results.
308+
// This must be done after emit to capture latest web worker results.
309+
for (const { errors } of additionalResults.values()) {
310+
if (errors) {
311+
(result.errors ??= []).push(...errors);
312+
}
313+
}
314+
280315
// Store referenced files for updated file watching if enabled
281316
if (pluginOptions.sourceFileCache) {
282-
pluginOptions.sourceFileCache.referencedFiles = referencedFiles;
317+
pluginOptions.sourceFileCache.referencedFiles = [
318+
...referencedFiles,
319+
...referencedFileTracker.referencedFiles,
320+
];
283321
}
284322

285323
// Reset the setup warnings so that they are only shown during the first build.
@@ -363,20 +401,20 @@ export function createCompilerPlugin(
363401
setupJitPluginCallbacks(
364402
build,
365403
stylesheetBundler,
366-
additionalOutputFiles,
404+
additionalResults,
367405
styleOptions.inlineStyleLanguage,
368406
);
369407
}
370408

371409
build.onEnd((result) => {
372-
// Add any additional output files to the main output files
373-
if (additionalOutputFiles.length) {
374-
result.outputFiles?.push(...additionalOutputFiles);
375-
}
410+
for (const { outputFiles, metafile } of additionalResults.values()) {
411+
// Add any additional output files to the main output files
412+
if (outputFiles?.length) {
413+
result.outputFiles?.push(...outputFiles);
414+
}
376415

377-
// Combine additional metafiles with main metafile
378-
if (result.metafile && additionalMetafiles.length) {
379-
for (const metafile of additionalMetafiles) {
416+
// Combine additional metafiles with main metafile
417+
if (result.metafile && metafile) {
380418
result.metafile.inputs = { ...result.metafile.inputs, ...metafile.inputs };
381419
result.metafile.outputs = { ...result.metafile.outputs, ...metafile.outputs };
382420
}
@@ -393,6 +431,38 @@ export function createCompilerPlugin(
393431
};
394432
}
395433

434+
function bundleWebWorker(
435+
build: PluginBuild,
436+
pluginOptions: CompilerPluginOptions,
437+
workerFile: string,
438+
) {
439+
try {
440+
return build.esbuild.buildSync({
441+
platform: 'browser',
442+
write: false,
443+
bundle: true,
444+
metafile: true,
445+
format: 'esm',
446+
mainFields: ['es2020', 'es2015', 'browser', 'module', 'main'],
447+
logLevel: 'silent',
448+
sourcemap: pluginOptions.sourcemap,
449+
entryNames: 'worker-[hash]',
450+
entryPoints: [workerFile],
451+
absWorkingDir: build.initialOptions.absWorkingDir,
452+
outdir: build.initialOptions.outdir,
453+
minifyIdentifiers: build.initialOptions.minifyIdentifiers,
454+
minifySyntax: build.initialOptions.minifySyntax,
455+
minifyWhitespace: build.initialOptions.minifyWhitespace,
456+
target: build.initialOptions.target,
457+
});
458+
} catch (error) {
459+
if (error && typeof error === 'object' && 'errors' in error && 'warnings' in error) {
460+
return error as BuildFailure;
461+
}
462+
throw error;
463+
}
464+
}
465+
396466
function createMissingFileError(request: string, original: string, root: string): PartialMessage {
397467
const error = {
398468
text: `File '${path.relative(root, request)}' is missing from the TypeScript compilation.`,

0 commit comments

Comments
 (0)