From 5340db12377a0ce4efee770c67559acd1ca043e0 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 27 Jun 2022 19:28:27 -0400 Subject: [PATCH] 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. --- packages/ngtools/webpack/src/ivy/plugin.ts | 305 +++++++++++---------- 1 file changed, 164 insertions(+), 141 deletions(-) diff --git a/packages/ngtools/webpack/src/ivy/plugin.ts b/packages/ngtools/webpack/src/ivy/plugin.ts index 8f62358cc998..a782f7409d6b 100644 --- a/packages/ngtools/webpack/src/ivy/plugin.ts +++ b/packages/ngtools/webpack/src/ivy/plugin.ts @@ -53,6 +53,16 @@ export interface AngularWebpackPluginOptions { inlineStyleFileExtension?: string; } +/** + * The Angular compilation state that is maintained across each Webpack compilation. + */ +interface AngularCompilationState { + ngccProcessor?: NgccProcessor; + resourceLoader?: WebpackResourceLoader; + previousUnused?: Set; + pathsPlugin: TypeScriptPathsPlugin; +} + function initializeNgccProcessor( compiler: Compiler, tsconfig: string, @@ -138,9 +148,8 @@ export class AngularWebpackPlugin { return this.pluginOptions; } - // eslint-disable-next-line max-lines-per-function apply(compiler: Compiler): void { - const { NormalModuleReplacementPlugin, util } = compiler.webpack; + const { NormalModuleReplacementPlugin, WebpackError, util } = compiler.webpack; this.webpackCreateHash = util.createHash; // Setup file replacements with webpack @@ -175,171 +184,185 @@ export class AngularWebpackPlugin { // Load the compiler-cli if not already available compiler.hooks.beforeCompile.tapPromise(PLUGIN_NAME, () => this.initializeCompilerCli()); - let ngccProcessor: NgccProcessor | undefined; - let resourceLoader: WebpackResourceLoader | undefined; - let previousUnused: Set | undefined; + const compilationState: AngularCompilationState = { pathsPlugin }; compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { - // 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 (!resourceLoader) { - resourceLoader = new WebpackResourceLoader(this.watchMode); + try { + this.setupCompilation(compilation, compilationState); + } catch (error) { + compilation.errors.push( + new WebpackError( + `Failed to initialize Angular compilation - ${ + error instanceof Error ? error.message : error + }`, + ), + ); } + }); + } - // Initialize and process eager ngcc if not already setup - if (!ngccProcessor) { - const { processor, errors, warnings } = initializeNgccProcessor( - compiler, - this.pluginOptions.tsconfig, - this.compilerNgccModule, - ); + private setupCompilation(compilation: Compilation, state: AngularCompilationState): void { + const compiler = compilation.compiler; - processor.process(); - warnings.forEach((warning) => addWarning(compilation, warning)); - errors.forEach((error) => addError(compilation, error)); + // Register plugin to ensure deterministic emit order in multi-plugin usage + const emitRegistration = this.registerWithCompilation(compilation); + this.watchMode = compiler.watchMode; - ngccProcessor = processor; - } + // Initialize webpack cache + if (!this.webpackCache && compilation.options.cache) { + this.webpackCache = compilation.getCache(PLUGIN_NAME); + } - // Setup and read TypeScript and Angular compiler configuration - const { compilerOptions, rootNames, errors } = this.loadConfiguration(); + // Initialize the resource loader if not already setup + if (!state.resourceLoader) { + state.resourceLoader = new WebpackResourceLoader(this.watchMode); + } - // Create diagnostics reporter and report configuration file errors - const diagnosticsReporter = createDiagnosticsReporter(compilation, (diagnostic) => - this.compilerCli.formatDiagnostics([diagnostic]), + // Initialize and process eager ngcc if not already setup + if (!state.ngccProcessor) { + const { processor, errors, warnings } = initializeNgccProcessor( + compiler, + this.pluginOptions.tsconfig, + this.compilerNgccModule, ); - diagnosticsReporter(errors); - // Update TypeScript path mapping plugin with new configuration - pathsPlugin.update(compilerOptions); + processor.process(); + warnings.forEach((warning) => addWarning(compilation, warning)); + errors.forEach((error) => addError(compilation, error)); - // 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(); - 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; - } + 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(); + 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); } - augmentHostWithCaching(host, cache); + } else { + // Initialize a new cache + cache = new SourceFileCache(); + // Only store cache if in watch mode + if (this.watchMode) { + this.sourceFileCache = cache; + } + } + augmentHostWithCaching(host, cache); - const moduleResolutionCache = ts.createModuleResolutionCache( - host.getCurrentDirectory(), - host.getCanonicalFileName.bind(host), - compilerOptions, - ); + const moduleResolutionCache = ts.createModuleResolutionCache( + host.getCurrentDirectory(), + 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, - ); + // Setup source file dependency collection + augmentHostWithDependencyCollection(host, this.fileDependencies, moduleResolutionCache); - // Set of files used during the unused TypeScript file analysis - const currentUnused = new Set(); + // Setup on demand ngcc + augmentHostWithNgcc(host, state.ngccProcessor, moduleResolutionCache); - for (const sourceFile of builder.getSourceFiles()) { - if (internalFiles?.has(sourceFile)) { - continue; - } + // Setup resource loading + state.resourceLoader.update(compilation, changedFiles); + augmentHostWithResources(host, state.resourceLoader, { + directTemplateLoading: this.pluginOptions.directTemplateLoading, + inlineStyleFileExtension: this.pluginOptions.inlineStyleFileExtension, + }); - // Ensure all program files are considered part of the compilation and will be watched. - // Webpack does not normalize paths. Therefore, we need to normalize the path with FS seperators. - compilation.fileDependencies.add(externalizePath(sourceFile.fileName)); + // 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, + ); - // 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)); - } + // Set of files used during the unused TypeScript file analysis + const currentUnused = new Set(); + + for (const sourceFile of builder.getSourceFiles()) { + if (internalFiles?.has(sourceFile)) { + continue; } - compilation.hooks.finishModules.tapPromise(PLUGIN_NAME, async (modules) => { - // Rebuild any remaining AOT required modules - await this.rebuildRequiredFiles(modules, compilation, fileEmitter); + // Ensure all program files are considered part of the compilation and will be watched. + // Webpack does not normalize paths. Therefore, we need to normalize the path with FS seperators. + compilation.fileDependencies.add(externalizePath(sourceFile.fileName)); - // Clear out the Webpack compilation to avoid an extra retaining reference - resourceLoader?.clearParentCompilation(); + // 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)); + } + } - // Analyze program for unused files - if (compilation.errors.length > 0) { - return; - } + compilation.hooks.finishModules.tapPromise(PLUGIN_NAME, async (modules) => { + // Rebuild any remaining AOT required modules + await this.rebuildRequiredFiles(modules, compilation, fileEmitter); - for (const webpackModule of modules) { - const resource = (webpackModule as NormalModule).resource; - if (resource) { - this.markResourceUsed(normalizePath(resource), currentUnused); - } - } + // Clear out the Webpack compilation to avoid an extra retaining reference + state.resourceLoader?.clearParentCompilation(); - 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.`, - ); + // 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); } - previousUnused = currentUnused; - }); + } - // Store file emitter for loader usage - emitRegistration.update(fileEmitter); + for (const unused of currentUnused) { + if (state.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.`, + ); + } + state.previousUnused = currentUnused; }); + + // Store file emitter for loader usage + emitRegistration.update(fileEmitter); } private registerWithCompilation(compilation: Compilation) {