fix(@ngtools/webpack): show a compilation error on invalid TypeScript version

A TypeScript version mismatch with the Angular compiler will no longer cause an
exception to propagate up through the Webpack system. In Node.js v14, this resulted
in an unhandled promise rejection warning and the build command never completing.
This can also be reproduced in newer versions of Node.js by using the Node.js
option `--unhandled-rejections=warn`. To correct this issue, the version mismatch
is now treated as a compilation error and added to the list of errors that are
displayed at the end of the build. This also has the benefit of avoiding the stack
trace of the exception from being shown which previously drew attention away from
the actual error message.
This commit is contained in:
Charles Lyding 2022-06-27 19:28:27 -04:00 committed by Charles
parent a69000407c
commit 34ecf669dd

View File

@ -53,6 +53,16 @@ export interface AngularWebpackPluginOptions {
inlineStyleFileExtension?: string; inlineStyleFileExtension?: string;
} }
/**
* The Angular compilation state that is maintained across each Webpack compilation.
*/
interface AngularCompilationState {
ngccProcessor?: NgccProcessor;
resourceLoader?: WebpackResourceLoader;
previousUnused?: Set<string>;
pathsPlugin: TypeScriptPathsPlugin;
}
function initializeNgccProcessor( function initializeNgccProcessor(
compiler: Compiler, compiler: Compiler,
tsconfig: string, tsconfig: string,
@ -138,9 +148,8 @@ export class AngularWebpackPlugin {
return this.pluginOptions; return this.pluginOptions;
} }
// eslint-disable-next-line max-lines-per-function
apply(compiler: Compiler): void { apply(compiler: Compiler): void {
const { NormalModuleReplacementPlugin, util } = compiler.webpack; const { NormalModuleReplacementPlugin, WebpackError, util } = compiler.webpack;
this.webpackCreateHash = util.createHash; this.webpackCreateHash = util.createHash;
// Setup file replacements with webpack // Setup file replacements with webpack
@ -175,171 +184,185 @@ export class AngularWebpackPlugin {
// Load the compiler-cli if not already available // Load the compiler-cli if not already available
compiler.hooks.beforeCompile.tapPromise(PLUGIN_NAME, () => this.initializeCompilerCli()); compiler.hooks.beforeCompile.tapPromise(PLUGIN_NAME, () => this.initializeCompilerCli());
let ngccProcessor: NgccProcessor | undefined; const compilationState: AngularCompilationState = { pathsPlugin };
let resourceLoader: WebpackResourceLoader | undefined;
let previousUnused: Set<string> | undefined;
compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
// Register plugin to ensure deterministic emit order in multi-plugin usage try {
const emitRegistration = this.registerWithCompilation(compilation); this.setupCompilation(compilation, compilationState);
this.watchMode = compiler.watchMode; } catch (error) {
compilation.errors.push(
// Initialize webpack cache new WebpackError(
if (!this.webpackCache && compilation.options.cache) { `Failed to initialize Angular compilation - ${
this.webpackCache = compilation.getCache(PLUGIN_NAME); error instanceof Error ? error.message : error
}`,
),
);
} }
});
}
// Initialize the resource loader if not already setup private setupCompilation(compilation: Compilation, state: AngularCompilationState): void {
if (!resourceLoader) { const compiler = compilation.compiler;
resourceLoader = new WebpackResourceLoader(this.watchMode);
// Register plugin to ensure deterministic emit order in multi-plugin usage
const emitRegistration = this.registerWithCompilation(compilation);
this.watchMode = compiler.watchMode;
// Initialize webpack cache
if (!this.webpackCache && compilation.options.cache) {
this.webpackCache = compilation.getCache(PLUGIN_NAME);
}
// Initialize the resource loader if not already setup
if (!state.resourceLoader) {
state.resourceLoader = new WebpackResourceLoader(this.watchMode);
}
// Initialize and process eager ngcc if not already setup
if (!state.ngccProcessor) {
const { processor, errors, warnings } = initializeNgccProcessor(
compiler,
this.pluginOptions.tsconfig,
this.compilerNgccModule,
);
processor.process();
warnings.forEach((warning) => addWarning(compilation, warning));
errors.forEach((error) => addError(compilation, error));
state.ngccProcessor = processor;
}
// Setup and read TypeScript and Angular compiler configuration
const { compilerOptions, rootNames, errors } = this.loadConfiguration();
// Create diagnostics reporter and report configuration file errors
const diagnosticsReporter = createDiagnosticsReporter(compilation, (diagnostic) =>
this.compilerCli.formatDiagnostics([diagnostic]),
);
diagnosticsReporter(errors);
// Update TypeScript path mapping plugin with new configuration
state.pathsPlugin.update(compilerOptions);
// Create a Webpack-based TypeScript compiler host
const system = createWebpackSystem(
// Webpack lacks an InputFileSytem type definition with sync functions
compiler.inputFileSystem as InputFileSystemSync,
normalizePath(compiler.context),
);
const host = ts.createIncrementalCompilerHost(compilerOptions, system);
// Setup source file caching and reuse cache from previous compilation if present
let cache = this.sourceFileCache;
let changedFiles;
if (cache) {
changedFiles = new Set<string>();
for (const changedFile of [...compiler.modifiedFiles, ...compiler.removedFiles]) {
const normalizedChangedFile = normalizePath(changedFile);
// Invalidate file dependencies
this.fileDependencies.delete(normalizedChangedFile);
// Invalidate existing cache
cache.invalidate(normalizedChangedFile);
changedFiles.add(normalizedChangedFile);
} }
} else {
// Initialize a new cache
cache = new SourceFileCache();
// Only store cache if in watch mode
if (this.watchMode) {
this.sourceFileCache = cache;
}
}
augmentHostWithCaching(host, cache);
// Initialize and process eager ngcc if not already setup const moduleResolutionCache = ts.createModuleResolutionCache(
if (!ngccProcessor) { host.getCurrentDirectory(),
const { processor, errors, warnings } = initializeNgccProcessor( host.getCanonicalFileName.bind(host),
compiler, compilerOptions,
this.pluginOptions.tsconfig, );
this.compilerNgccModule,
// Setup source file dependency collection
augmentHostWithDependencyCollection(host, this.fileDependencies, moduleResolutionCache);
// Setup on demand ngcc
augmentHostWithNgcc(host, state.ngccProcessor, moduleResolutionCache);
// Setup resource loading
state.resourceLoader.update(compilation, changedFiles);
augmentHostWithResources(host, state.resourceLoader, {
directTemplateLoading: this.pluginOptions.directTemplateLoading,
inlineStyleFileExtension: this.pluginOptions.inlineStyleFileExtension,
});
// Setup source file adjustment options
augmentHostWithReplacements(host, this.pluginOptions.fileReplacements, moduleResolutionCache);
augmentHostWithSubstitutions(host, this.pluginOptions.substitutions);
// Create the file emitter used by the webpack loader
const { fileEmitter, builder, internalFiles } = this.pluginOptions.jitMode
? this.updateJitProgram(compilerOptions, rootNames, host, diagnosticsReporter)
: this.updateAotProgram(
compilerOptions,
rootNames,
host,
diagnosticsReporter,
state.resourceLoader,
); );
processor.process(); // Set of files used during the unused TypeScript file analysis
warnings.forEach((warning) => addWarning(compilation, warning)); const currentUnused = new Set<string>();
errors.forEach((error) => addError(compilation, error));
ngccProcessor = processor; for (const sourceFile of builder.getSourceFiles()) {
if (internalFiles?.has(sourceFile)) {
continue;
} }
// Setup and read TypeScript and Angular compiler configuration // Ensure all program files are considered part of the compilation and will be watched.
const { compilerOptions, rootNames, errors } = this.loadConfiguration(); // Webpack does not normalize paths. Therefore, we need to normalize the path with FS seperators.
compilation.fileDependencies.add(externalizePath(sourceFile.fileName));
// Create diagnostics reporter and report configuration file errors // Add all non-declaration files to the initial set of unused files. The set will be
const diagnosticsReporter = createDiagnosticsReporter(compilation, (diagnostic) => // analyzed and pruned after all Webpack modules are finished building.
this.compilerCli.formatDiagnostics([diagnostic]), if (!sourceFile.isDeclarationFile) {
); currentUnused.add(normalizePath(sourceFile.fileName));
diagnosticsReporter(errors); }
}
// Update TypeScript path mapping plugin with new configuration compilation.hooks.finishModules.tapPromise(PLUGIN_NAME, async (modules) => {
pathsPlugin.update(compilerOptions); // Rebuild any remaining AOT required modules
await this.rebuildRequiredFiles(modules, compilation, fileEmitter);
// Create a Webpack-based TypeScript compiler host // Clear out the Webpack compilation to avoid an extra retaining reference
const system = createWebpackSystem( state.resourceLoader?.clearParentCompilation();
// Webpack lacks an InputFileSytem type definition with sync functions
compiler.inputFileSystem as InputFileSystemSync,
normalizePath(compiler.context),
);
const host = ts.createIncrementalCompilerHost(compilerOptions, system);
// Setup source file caching and reuse cache from previous compilation if present // Analyze program for unused files
let cache = this.sourceFileCache; if (compilation.errors.length > 0) {
let changedFiles; return;
if (cache) { }
changedFiles = new Set<string>();
for (const changedFile of [...compiler.modifiedFiles, ...compiler.removedFiles]) {
const normalizedChangedFile = normalizePath(changedFile);
// Invalidate file dependencies
this.fileDependencies.delete(normalizedChangedFile);
// Invalidate existing cache
cache.invalidate(normalizedChangedFile);
changedFiles.add(normalizedChangedFile); for (const webpackModule of modules) {
} const resource = (webpackModule as NormalModule).resource;
} else { if (resource) {
// Initialize a new cache this.markResourceUsed(normalizePath(resource), currentUnused);
cache = new SourceFileCache();
// Only store cache if in watch mode
if (this.watchMode) {
this.sourceFileCache = cache;
} }
} }
augmentHostWithCaching(host, cache);
const moduleResolutionCache = ts.createModuleResolutionCache( for (const unused of currentUnused) {
host.getCurrentDirectory(), if (state.previousUnused?.has(unused)) {
host.getCanonicalFileName.bind(host),
compilerOptions,
);
// Setup source file dependency collection
augmentHostWithDependencyCollection(host, this.fileDependencies, moduleResolutionCache);
// Setup on demand ngcc
augmentHostWithNgcc(host, ngccProcessor, moduleResolutionCache);
// Setup resource loading
resourceLoader.update(compilation, changedFiles);
augmentHostWithResources(host, resourceLoader, {
directTemplateLoading: this.pluginOptions.directTemplateLoading,
inlineStyleFileExtension: this.pluginOptions.inlineStyleFileExtension,
});
// Setup source file adjustment options
augmentHostWithReplacements(host, this.pluginOptions.fileReplacements, moduleResolutionCache);
augmentHostWithSubstitutions(host, this.pluginOptions.substitutions);
// Create the file emitter used by the webpack loader
const { fileEmitter, builder, internalFiles } = this.pluginOptions.jitMode
? this.updateJitProgram(compilerOptions, rootNames, host, diagnosticsReporter)
: this.updateAotProgram(
compilerOptions,
rootNames,
host,
diagnosticsReporter,
resourceLoader,
);
// Set of files used during the unused TypeScript file analysis
const currentUnused = new Set<string>();
for (const sourceFile of builder.getSourceFiles()) {
if (internalFiles?.has(sourceFile)) {
continue; continue;
} }
addWarning(
// Ensure all program files are considered part of the compilation and will be watched. compilation,
// Webpack does not normalize paths. Therefore, we need to normalize the path with FS seperators. `${unused} is part of the TypeScript compilation but it's unused.\n` +
compilation.fileDependencies.add(externalizePath(sourceFile.fileName)); `Add only entry points to the 'files' or 'include' properties in your tsconfig.`,
);
// Add all non-declaration files to the initial set of unused files. The set will be
// analyzed and pruned after all Webpack modules are finished building.
if (!sourceFile.isDeclarationFile) {
currentUnused.add(normalizePath(sourceFile.fileName));
}
} }
state.previousUnused = currentUnused;
compilation.hooks.finishModules.tapPromise(PLUGIN_NAME, async (modules) => {
// Rebuild any remaining AOT required modules
await this.rebuildRequiredFiles(modules, compilation, fileEmitter);
// Clear out the Webpack compilation to avoid an extra retaining reference
resourceLoader?.clearParentCompilation();
// Analyze program for unused files
if (compilation.errors.length > 0) {
return;
}
for (const webpackModule of modules) {
const resource = (webpackModule as NormalModule).resource;
if (resource) {
this.markResourceUsed(normalizePath(resource), currentUnused);
}
}
for (const unused of currentUnused) {
if (previousUnused && previousUnused.has(unused)) {
continue;
}
addWarning(
compilation,
`${unused} is part of the TypeScript compilation but it's unused.\n` +
`Add only entry points to the 'files' or 'include' properties in your tsconfig.`,
);
}
previousUnused = currentUnused;
});
// Store file emitter for loader usage
emitRegistration.update(fileEmitter);
}); });
// Store file emitter for loader usage
emitRegistration.update(fileEmitter);
} }
private registerWithCompilation(compilation: Compilation) { private registerWithCompilation(compilation: Compilation) {