Skip to content

Commit

Permalink
fix(@angular-devkit/build-angular): fully track Web Worker file chang…
Browse files Browse the repository at this point in the history
…es 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)
  • Loading branch information
clydin authored and dgp1130 committed Oct 17, 2023
1 parent 34947fc commit a3e9efe
Show file tree
Hide file tree
Showing 5 changed files with 298 additions and 48 deletions.
@@ -0,0 +1,123 @@
/**
* @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.io/license
*/

import { logging } from '@angular-devkit/core';
import { concatMap, count, take, timeout } from 'rxjs';
import { buildApplication } from '../../index';
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';

/**
* Maximum time in milliseconds for single build/rebuild
* This accounts for CI variability.
*/
export const BUILD_TIMEOUT = 30_000;

describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
describe('Behavior: "Rebuilds when Web Worker files change"', () => {
it('Recovers from error when directly referenced worker file is changed', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
watch: true,
});

const workerCodeFile = `
console.log('WORKER FILE');
`;

const errorText = `Expected ";" but found "~"`;

// Create a worker file
await harness.writeFile('src/app/worker.ts', workerCodeFile);

// Create app component that uses the directive
await harness.writeFile(
'src/app/app.component.ts',
`
import { Component } from '@angular/core'
@Component({
selector: 'app-root',
template: '<h1>Worker Test</h1>',
})
export class AppComponent {
worker = new Worker(new URL('./worker', import.meta.url), { type: 'module' });
}
`,
);

const builderAbort = new AbortController();
const buildCount = await harness
.execute({ outputLogsOnFailure: false, signal: builderAbort.signal })
.pipe(
timeout(BUILD_TIMEOUT),
concatMap(async ({ result, logs }, index) => {
switch (index) {
case 0:
expect(result?.success).toBeTrue();

// Update the worker file to be invalid syntax
await harness.writeFile('src/app/worker.ts', `asd;fj$3~kls;kd^(*fjlk;sdj---flk`);

break;
case 1:
expect(logs).toContain(
jasmine.objectContaining<logging.LogEntry>({
message: jasmine.stringMatching(errorText),
}),
);

// Make an unrelated change to verify error cache was updated
// Should persist error in the next rebuild
await harness.modifyFile('src/main.ts', (content) => content + '\n');

break;
case 2:
expect(logs).toContain(
jasmine.objectContaining<logging.LogEntry>({
message: jasmine.stringMatching(errorText),
}),
);

// Revert the change that caused the error
// Should remove the error
await harness.writeFile('src/app/worker.ts', workerCodeFile);

break;
case 3:
expect(result?.success).toBeTrue();
expect(logs).not.toContain(
jasmine.objectContaining<logging.LogEntry>({
message: jasmine.stringMatching(errorText),
}),
);

// Make an unrelated change to verify error cache was updated
// Should continue showing no error
await harness.modifyFile('src/main.ts', (content) => content + '\n');

break;
case 4:
expect(result?.success).toBeTrue();
expect(logs).not.toContain(
jasmine.objectContaining<logging.LogEntry>({
message: jasmine.stringMatching(errorText),
}),
);

// Test complete - abort watch mode
builderAbort?.abort();
break;
}
}),
count(),
)
.toPromise();

expect(buildCount).toBe(5);
});
});
});
Expand Up @@ -7,6 +7,7 @@
*/

import type {
BuildFailure,
Metafile,
OnStartResult,
OutputFile,
Expand All @@ -33,6 +34,7 @@ import { AngularHostOptions } from './angular-host';
import { AngularCompilation, AotCompilation, JitCompilation, NoopCompilation } from './compilation';
import { SharedTSCompilationState, getSharedCompilationState } from './compilation-state';
import { ComponentStylesheetBundler } from './component-stylesheets';
import { FileReferenceTracker } from './file-reference-tracker';
import { setupJitPluginCallbacks } from './jit-plugin-callbacks';
import { SourceFileCache } from './source-file-cache';

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

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

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

// To fully invalidate files, track resource referenced files and their referencing source
const referencedFileTracker = new FileReferenceTracker();

// eslint-disable-next-line max-lines-per-function
build.onStart(async () => {
sharedTSCompilationState = getSharedCompilationState();
if (!(compilation instanceof NoopCompilation)) {
Expand All @@ -119,14 +127,24 @@ export function createCompilerPlugin(
// Reset debug performance tracking
resetCumulativeDurations();

// Reset additional output files
additionalOutputFiles = [];
additionalMetafiles = [];
// Update the reference tracker and generate a full set of modified files for the
// Angular compiler which does not have direct knowledge of transitive resource
// dependencies or web worker processing.
let modifiedFiles;
if (
pluginOptions.sourceFileCache?.modifiedFiles.size &&
referencedFileTracker &&
!pluginOptions.noopTypeScriptCompilation
) {
// TODO: Differentiate between changed input files and stale output files
modifiedFiles = referencedFileTracker.update(pluginOptions.sourceFileCache.modifiedFiles);
pluginOptions.sourceFileCache.invalidate(modifiedFiles);
}

// Create Angular compiler host options
const hostOptions: AngularHostOptions = {
fileReplacements: pluginOptions.fileReplacements,
modifiedFiles: pluginOptions.sourceFileCache?.modifiedFiles,
modifiedFiles,
sourceFileCache: pluginOptions.sourceFileCache,
async transformStylesheet(data, containingFile, stylesheetFile) {
let stylesheetResult;
Expand All @@ -142,14 +160,22 @@ export function createCompilerPlugin(
);
}

const { contents, resourceFiles, errors, warnings } = stylesheetResult;
const { contents, resourceFiles, referencedFiles, errors, warnings } = stylesheetResult;
if (errors) {
(result.errors ??= []).push(...errors);
}
(result.warnings ??= []).push(...warnings);
additionalOutputFiles.push(...resourceFiles);
if (stylesheetResult.metafile) {
additionalMetafiles.push(stylesheetResult.metafile);
additionalResults.set(stylesheetFile ?? containingFile, {
outputFiles: resourceFiles,
metafile: stylesheetResult.metafile,
});

if (referencedFiles) {
referencedFileTracker.add(containingFile, referencedFiles);
if (stylesheetFile) {
// Angular AOT compiler needs modified direct resource files to correctly invalidate its analysis
referencedFileTracker.add(stylesheetFile, referencedFiles);
}
}

return contents;
Expand All @@ -159,37 +185,38 @@ export function createCompilerPlugin(
// The synchronous API must be used due to the TypeScript compilation currently being
// fully synchronous and this process callback being called from within a TypeScript
// transformer.
const workerResult = build.esbuild.buildSync({
platform: 'browser',
write: false,
bundle: true,
metafile: true,
format: 'esm',
mainFields: ['es2020', 'es2015', 'browser', 'module', 'main'],
sourcemap: pluginOptions.sourcemap,
entryNames: 'worker-[hash]',
entryPoints: [fullWorkerPath],
absWorkingDir: build.initialOptions.absWorkingDir,
outdir: build.initialOptions.outdir,
minifyIdentifiers: build.initialOptions.minifyIdentifiers,
minifySyntax: build.initialOptions.minifySyntax,
minifyWhitespace: build.initialOptions.minifyWhitespace,
target: build.initialOptions.target,
});
const workerResult = bundleWebWorker(build, pluginOptions, fullWorkerPath);

(result.warnings ??= []).push(...workerResult.warnings);
additionalOutputFiles.push(...workerResult.outputFiles);
if (workerResult.metafile) {
additionalMetafiles.push(workerResult.metafile);
}

if (workerResult.errors.length > 0) {
(result.errors ??= []).push(...workerResult.errors);
// Track worker file errors to allow rebuilds on changes
referencedFileTracker.add(
containingFile,
workerResult.errors
.map((error) => error.location?.file)
.filter((file): file is string => !!file)
.map((file) => path.join(build.initialOptions.absWorkingDir ?? '', file)),
);
additionalResults.set(fullWorkerPath, { errors: result.errors });

// Return the original path if the build failed
return workerFile;
}

assert('outputFiles' in workerResult, 'Invalid web worker bundle result.');
additionalResults.set(fullWorkerPath, {
outputFiles: workerResult.outputFiles,
metafile: workerResult.metafile,
});

referencedFileTracker.add(
containingFile,
Object.keys(workerResult.metafile.inputs).map((input) =>
path.join(build.initialOptions.absWorkingDir ?? '', input),
),
);

// Return bundled worker file entry name to be used in the built output
const workerCodeFile = workerResult.outputFiles.find((file) =>
file.path.endsWith('.js'),
Expand Down Expand Up @@ -277,9 +304,20 @@ export function createCompilerPlugin(
}
});

// Add errors from failed additional results.
// This must be done after emit to capture latest web worker results.
for (const { errors } of additionalResults.values()) {
if (errors) {
(result.errors ??= []).push(...errors);
}
}

// Store referenced files for updated file watching if enabled
if (pluginOptions.sourceFileCache) {
pluginOptions.sourceFileCache.referencedFiles = referencedFiles;
pluginOptions.sourceFileCache.referencedFiles = [
...referencedFiles,
...referencedFileTracker.referencedFiles,
];
}

// Reset the setup warnings so that they are only shown during the first build.
Expand Down Expand Up @@ -363,20 +401,20 @@ export function createCompilerPlugin(
setupJitPluginCallbacks(
build,
stylesheetBundler,
additionalOutputFiles,
additionalResults,
styleOptions.inlineStyleLanguage,
);
}

build.onEnd((result) => {
// Add any additional output files to the main output files
if (additionalOutputFiles.length) {
result.outputFiles?.push(...additionalOutputFiles);
}
for (const { outputFiles, metafile } of additionalResults.values()) {
// Add any additional output files to the main output files
if (outputFiles?.length) {
result.outputFiles?.push(...outputFiles);
}

// Combine additional metafiles with main metafile
if (result.metafile && additionalMetafiles.length) {
for (const metafile of additionalMetafiles) {
// Combine additional metafiles with main metafile
if (result.metafile && metafile) {
result.metafile.inputs = { ...result.metafile.inputs, ...metafile.inputs };
result.metafile.outputs = { ...result.metafile.outputs, ...metafile.outputs };
}
Expand All @@ -393,6 +431,38 @@ export function createCompilerPlugin(
};
}

function bundleWebWorker(
build: PluginBuild,
pluginOptions: CompilerPluginOptions,
workerFile: string,
) {
try {
return build.esbuild.buildSync({
platform: 'browser',
write: false,
bundle: true,
metafile: true,
format: 'esm',
mainFields: ['es2020', 'es2015', 'browser', 'module', 'main'],
logLevel: 'silent',
sourcemap: pluginOptions.sourcemap,
entryNames: 'worker-[hash]',
entryPoints: [workerFile],
absWorkingDir: build.initialOptions.absWorkingDir,
outdir: build.initialOptions.outdir,
minifyIdentifiers: build.initialOptions.minifyIdentifiers,
minifySyntax: build.initialOptions.minifySyntax,
minifyWhitespace: build.initialOptions.minifyWhitespace,
target: build.initialOptions.target,
});
} catch (error) {
if (error && typeof error === 'object' && 'errors' in error && 'warnings' in error) {
return error as BuildFailure;
}
throw error;
}
}

function createMissingFileError(request: string, original: string, root: string): PartialMessage {
const error = {
text: `File '${path.relative(root, request)}' is missing from the TypeScript compilation.`,
Expand Down

0 comments on commit a3e9efe

Please sign in to comment.