From 085da999395f5fbc12f4689cdc13d40f235f43c0 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 15 Nov 2022 10:48:04 -0500 Subject: [PATCH] refactor(@angular-devkit/build-angular): encapsulate Angular compilation within esbuild compiler plugin The creation of the esbuild Angular plugin's Angular compilation has now been consolidated in a separate class. This refactor reduces the amount of code within the plugin's main start function as well as centralizing initialization, analysis, and source file emitting for the Angular build process. --- .../browser-esbuild/angular-compilation.ts | 245 ++++++++++++++++++ .../browser-esbuild/compiler-plugin.ts | 225 ++-------------- 2 files changed, 272 insertions(+), 198 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular-compilation.ts diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular-compilation.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular-compilation.ts new file mode 100644 index 000000000000..69b0e860c92f --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular-compilation.ts @@ -0,0 +1,245 @@ +/** + * @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 type ng from '@angular/compiler-cli'; +import assert from 'node:assert'; +import ts from 'typescript'; +import { loadEsmModule } from '../../utils/load-esm'; +import { + AngularHostOptions, + createAngularCompilerHost, + ensureSourceFileVersions, +} from './angular-host'; +import { profileAsync, profileSync } from './profiling'; + +// Temporary deep import for transformer support +// TODO: Move these to a private exports location or move the implementation into this package. +const { mergeTransformers, replaceBootstrap } = require('@ngtools/webpack/src/ivy/transformation'); + +class AngularCompilationState { + constructor( + public readonly angularProgram: ng.NgtscProgram, + public readonly typeScriptProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram, + public readonly affectedFiles: ReadonlySet, + public readonly templateDiagnosticsOptimization: ng.OptimizeFor, + public readonly diagnosticCache = new WeakMap(), + ) {} + + get angularCompiler() { + return this.angularProgram.compiler; + } +} + +export interface EmitFileResult { + content?: string; + map?: string; + dependencies: readonly string[]; +} +export type FileEmitter = (file: string) => Promise; + +export class AngularCompilation { + static #angularCompilerCliModule?: typeof ng; + + #state?: AngularCompilationState; + + static async loadCompilerCli(): Promise { + // This uses a wrapped dynamic import to load `@angular/compiler-cli` which is ESM. + // Once TypeScript provides support for retaining dynamic imports this workaround can be dropped. + this.#angularCompilerCliModule ??= await loadEsmModule('@angular/compiler-cli'); + + return this.#angularCompilerCliModule; + } + + constructor() {} + + async initialize( + rootNames: string[], + compilerOptions: ng.CompilerOptions, + hostOptions: AngularHostOptions, + configurationDiagnostics?: ts.Diagnostic[], + ): Promise<{ affectedFiles: ReadonlySet }> { + // Dynamically load the Angular compiler CLI package + const { NgtscProgram, OptimizeFor } = await AngularCompilation.loadCompilerCli(); + + // Create Angular compiler host + const host = createAngularCompilerHost(compilerOptions, hostOptions); + + // Create the Angular specific program that contains the Angular compiler + const angularProgram = profileSync( + 'NG_CREATE_PROGRAM', + () => new NgtscProgram(rootNames, compilerOptions, host, this.#state?.angularProgram), + ); + const angularCompiler = angularProgram.compiler; + const angularTypeScriptProgram = angularProgram.getTsProgram(); + ensureSourceFileVersions(angularTypeScriptProgram); + + const typeScriptProgram = ts.createEmitAndSemanticDiagnosticsBuilderProgram( + angularTypeScriptProgram, + host, + this.#state?.typeScriptProgram, + configurationDiagnostics, + ); + + await profileAsync('NG_ANALYZE_PROGRAM', () => angularCompiler.analyzeAsync()); + const affectedFiles = profileSync('NG_FIND_AFFECTED', () => + findAffectedFiles(typeScriptProgram, angularCompiler), + ); + + this.#state = new AngularCompilationState( + angularProgram, + typeScriptProgram, + affectedFiles, + affectedFiles.size === 1 ? OptimizeFor.SingleFile : OptimizeFor.WholeProgram, + this.#state?.diagnosticCache, + ); + + return { affectedFiles }; + } + + *collectDiagnostics(): Iterable { + assert(this.#state, 'Angular compilation must be initialized prior to collecting diagnostics.'); + const { + affectedFiles, + angularCompiler, + diagnosticCache, + templateDiagnosticsOptimization, + typeScriptProgram, + } = this.#state; + + // Collect program level diagnostics + yield* typeScriptProgram.getConfigFileParsingDiagnostics(); + yield* angularCompiler.getOptionDiagnostics(); + yield* typeScriptProgram.getOptionsDiagnostics(); + yield* typeScriptProgram.getGlobalDiagnostics(); + + // Collect source file specific diagnostics + for (const sourceFile of typeScriptProgram.getSourceFiles()) { + if (angularCompiler.ignoreForDiagnostics.has(sourceFile)) { + continue; + } + + // TypeScript will use cached diagnostics for files that have not been + // changed or affected for this build when using incremental building. + yield* profileSync( + 'NG_DIAGNOSTICS_SYNTACTIC', + () => typeScriptProgram.getSyntacticDiagnostics(sourceFile), + true, + ); + yield* profileSync( + 'NG_DIAGNOSTICS_SEMANTIC', + () => typeScriptProgram.getSemanticDiagnostics(sourceFile), + true, + ); + + // Declaration files cannot have template diagnostics + if (sourceFile.isDeclarationFile) { + continue; + } + + // Only request Angular template diagnostics for affected files to avoid + // overhead of template diagnostics for unchanged files. + if (affectedFiles.has(sourceFile)) { + const angularDiagnostics = profileSync( + 'NG_DIAGNOSTICS_TEMPLATE', + () => angularCompiler.getDiagnosticsForFile(sourceFile, templateDiagnosticsOptimization), + true, + ); + diagnosticCache.set(sourceFile, angularDiagnostics); + yield* angularDiagnostics; + } else { + const angularDiagnostics = diagnosticCache.get(sourceFile); + if (angularDiagnostics) { + yield* angularDiagnostics; + } + } + } + } + + createFileEmitter(onAfterEmit?: (sourceFile: ts.SourceFile) => void): FileEmitter { + assert(this.#state, 'Angular compilation must be initialized prior to emitting files.'); + const { angularCompiler, typeScriptProgram } = this.#state; + + const transformers = mergeTransformers(angularCompiler.prepareEmit().transformers, { + before: [replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker())], + }); + + return async (file: string) => { + const sourceFile = typeScriptProgram.getSourceFile(file); + if (!sourceFile) { + return undefined; + } + + let content: string | undefined; + typeScriptProgram.emit( + sourceFile, + (filename, data) => { + if (/\.[cm]?js$/.test(filename)) { + content = data; + } + }, + undefined /* cancellationToken */, + undefined /* emitOnlyDtsFiles */, + transformers, + ); + + angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile); + onAfterEmit?.(sourceFile); + + return { content, dependencies: [] }; + }; + } +} + +function findAffectedFiles( + builder: ts.EmitAndSemanticDiagnosticsBuilderProgram, + { ignoreForDiagnostics, ignoreForEmit, incrementalCompilation }: ng.NgtscProgram['compiler'], +): Set { + const affectedFiles = new Set(); + + // eslint-disable-next-line no-constant-condition + while (true) { + const result = builder.getSemanticDiagnosticsOfNextAffectedFile(undefined, (sourceFile) => { + // If the affected file is a TTC shim, add the shim's original source file. + // This ensures that changes that affect TTC are typechecked even when the changes + // are otherwise unrelated from a TS perspective and do not result in Ivy codegen changes. + // For example, changing @Input property types of a directive used in another component's + // template. + // A TTC shim is a file that has been ignored for diagnostics and has a filename ending in `.ngtypecheck.ts`. + if (ignoreForDiagnostics.has(sourceFile) && sourceFile.fileName.endsWith('.ngtypecheck.ts')) { + // This file name conversion relies on internal compiler logic and should be converted + // to an official method when available. 15 is length of `.ngtypecheck.ts` + const originalFilename = sourceFile.fileName.slice(0, -15) + '.ts'; + const originalSourceFile = builder.getSourceFile(originalFilename); + if (originalSourceFile) { + affectedFiles.add(originalSourceFile); + } + + return true; + } + + return false; + }); + + if (!result) { + break; + } + + affectedFiles.add(result.affected as ts.SourceFile); + } + + // A file is also affected if the Angular compiler requires it to be emitted + for (const sourceFile of builder.getSourceFiles()) { + if (ignoreForEmit.has(sourceFile) || incrementalCompilation.safeToSkipEmit(sourceFile)) { + continue; + } + + affectedFiles.add(sourceFile); + } + + return affectedFiles; +} diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts index ff2b07d95615..5c4875acc4fb 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.io/license */ -import type { NgtscProgram } from '@angular/compiler-cli'; import type { OnStartResult, OutputFile, @@ -21,8 +20,8 @@ import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; import ts from 'typescript'; import { maxWorkers } from '../../utils/environment-options'; -import { loadEsmModule } from '../../utils/load-esm'; -import { createAngularCompilerHost, ensureSourceFileVersions } from './angular-host'; +import { AngularCompilation, FileEmitter } from './angular-compilation'; +import { AngularHostOptions } from './angular-host'; import { JavaScriptTransformer } from './javascript-transformer'; import { logCumulativeDurations, @@ -32,28 +31,19 @@ import { } from './profiling'; import { BundleStylesheetOptions, bundleStylesheetFile, bundleStylesheetText } from './stylesheets'; -interface EmitFileResult { - content?: string; - map?: string; - dependencies: readonly string[]; - hash?: Uint8Array; -} -type FileEmitter = (file: string) => Promise; - /** * Converts TypeScript Diagnostic related information into an esbuild compatible note object. * Related information is a subset of a full TypeScript Diagnostic and also used for diagnostic * notes associated with the main Diagnostic. * @param info The TypeScript diagnostic relative information to convert. - * @param host A TypeScript FormatDiagnosticsHost instance to use during conversion. * @returns An esbuild diagnostic message as a PartialMessage object */ function convertTypeScriptDiagnosticInfo( info: ts.DiagnosticRelatedInformation, - host: ts.FormatDiagnosticsHost, textPrefix?: string, ): PartialNote { - let text = ts.flattenDiagnosticMessageText(info.messageText, host.getNewLine()); + const newLine = platform() === 'win32' ? '\r\n' : '\n'; + let text = ts.flattenDiagnosticMessageText(info.messageText, newLine); if (textPrefix) { text = textPrefix + text; } @@ -97,13 +87,9 @@ function convertTypeScriptDiagnosticInfo( /** * Converts a TypeScript Diagnostic message into an esbuild compatible message object. * @param diagnostic The TypeScript diagnostic to convert. - * @param host A TypeScript FormatDiagnosticsHost instance to use during conversion. * @returns An esbuild diagnostic message as a PartialMessage object */ -function convertTypeScriptDiagnostic( - diagnostic: ts.Diagnostic, - host: ts.FormatDiagnosticsHost, -): PartialMessage { +function convertTypeScriptDiagnostic(diagnostic: ts.Diagnostic): PartialMessage { let codePrefix = 'TS'; let code = `${diagnostic.code}`; if (diagnostic.source === 'ngtsc') { @@ -113,14 +99,14 @@ function convertTypeScriptDiagnostic( } const message: PartialMessage = { - ...convertTypeScriptDiagnosticInfo(diagnostic, host, `${codePrefix}${code}: `), + ...convertTypeScriptDiagnosticInfo(diagnostic, `${codePrefix}${code}: `), // Store original diagnostic for reference if needed downstream detail: diagnostic, }; if (diagnostic.relatedInformation?.length) { message.notes = diagnostic.relatedInformation.map((info) => - convertTypeScriptDiagnosticInfo(info, host), + convertTypeScriptDiagnosticInfo(info), ); } @@ -161,7 +147,6 @@ export interface CompilerPluginOptions { sourceFileCache?: SourceFileCache; } -// This is a non-watch version of the compiler code from `@ngtools/webpack` augmented for esbuild // eslint-disable-next-line max-lines-per-function export function createCompilerPlugin( pluginOptions: CompilerPluginOptions, @@ -176,16 +161,8 @@ export function createCompilerPlugin( // Initialize a worker pool for JavaScript transformations const javascriptTransformer = new JavaScriptTransformer(pluginOptions, maxWorkers); - // This uses a wrapped dynamic import to load `@angular/compiler-cli` which is ESM. - // Once TypeScript provides support for retaining dynamic imports this workaround can be dropped. - const { GLOBAL_DEFS_FOR_TERSER_WITH_AOT, NgtscProgram, OptimizeFor, readConfiguration } = - await loadEsmModule('@angular/compiler-cli'); - - // Temporary deep import for transformer support - const { - mergeTransformers, - replaceBootstrap, - } = require('@ngtools/webpack/src/ivy/transformation'); + const { GLOBAL_DEFS_FOR_TERSER_WITH_AOT, readConfiguration } = + await AngularCompilation.loadCompilerCli(); // Setup defines based on the values provided by the Angular compiler-cli build.initialOptions.define ??= {}; @@ -251,9 +228,7 @@ export function createCompilerPlugin( // The stylesheet resources from component stylesheets that will be added to the build results output files let stylesheetResourceFiles: OutputFile[]; - let previousBuilder: ts.EmitAndSemanticDiagnosticsBuilderProgram | undefined; - let previousAngularProgram: NgtscProgram | undefined; - const diagnosticCache = new WeakMap(); + let compilation: AngularCompilation | undefined; build.onStart(async () => { const result: OnStartResult = { @@ -269,8 +244,8 @@ export function createCompilerPlugin( // Reset stylesheet resource output files stylesheetResourceFiles = []; - // Create Angular compiler host - const host = createAngularCompilerHost(compilerOptions, { + // Create Angular compiler host options + const hostOptions: AngularHostOptions = { fileReplacements: pluginOptions.fileReplacements, modifiedFiles: pluginOptions.sourceFileCache?.modifiedFiles, sourceFileCache: pluginOptions.sourceFileCache, @@ -301,31 +276,21 @@ export function createCompilerPlugin( return contents; }, - }); + }; - // Create the Angular specific program that contains the Angular compiler - const angularProgram = profileSync( - 'NG_CREATE_PROGRAM', - () => new NgtscProgram(rootNames, compilerOptions, host, previousAngularProgram), - ); - previousAngularProgram = angularProgram; - const angularCompiler = angularProgram.compiler; - const typeScriptProgram = angularProgram.getTsProgram(); - ensureSourceFileVersions(typeScriptProgram); - - const builder = ts.createEmitAndSemanticDiagnosticsBuilderProgram( - typeScriptProgram, - host, - previousBuilder, - configurationDiagnostics, - ); - previousBuilder = builder; + // Create new compilation if first build; otherwise, use existing for rebuilds + compilation ??= new AngularCompilation(); - await profileAsync('NG_ANALYZE_PROGRAM', () => angularCompiler.analyzeAsync()); - const affectedFiles = profileSync('NG_FIND_AFFECTED', () => - findAffectedFiles(builder, angularCompiler), + // Initialize the Angular compilation for the current build. + // In watch mode, previous build state will be reused. + const { affectedFiles } = await compilation.initialize( + rootNames, + compilerOptions, + hostOptions, + configurationDiagnostics, ); + // Clear affected files from the cache (if present) if (pluginOptions.sourceFileCache) { for (const affected of affectedFiles) { pluginOptions.sourceFileCache.typeScriptFileCache.delete( @@ -334,61 +299,10 @@ export function createCompilerPlugin( } } - function* collectDiagnostics(): Iterable { - // Collect program level diagnostics - yield* builder.getConfigFileParsingDiagnostics(); - yield* angularCompiler.getOptionDiagnostics(); - yield* builder.getOptionsDiagnostics(); - yield* builder.getGlobalDiagnostics(); - - // Collect source file specific diagnostics - const optimizeFor = - affectedFiles.size > 1 ? OptimizeFor.WholeProgram : OptimizeFor.SingleFile; - for (const sourceFile of builder.getSourceFiles()) { - if (angularCompiler.ignoreForDiagnostics.has(sourceFile)) { - continue; - } - - // TypeScript will use cached diagnostics for files that have not been - // changed or affected for this build when using incremental building. - yield* profileSync( - 'NG_DIAGNOSTICS_SYNTACTIC', - () => builder.getSyntacticDiagnostics(sourceFile), - true, - ); - yield* profileSync( - 'NG_DIAGNOSTICS_SEMANTIC', - () => builder.getSemanticDiagnostics(sourceFile), - true, - ); - - // Declaration files cannot have template diagnostics - if (sourceFile.isDeclarationFile) { - continue; - } - - // Only request Angular template diagnostics for affected files to avoid - // overhead of template diagnostics for unchanged files. - if (affectedFiles.has(sourceFile)) { - const angularDiagnostics = profileSync( - 'NG_DIAGNOSTICS_TEMPLATE', - () => angularCompiler.getDiagnosticsForFile(sourceFile, optimizeFor), - true, - ); - diagnosticCache.set(sourceFile, angularDiagnostics); - yield* angularDiagnostics; - } else { - const angularDiagnostics = diagnosticCache.get(sourceFile); - if (angularDiagnostics) { - yield* angularDiagnostics; - } - } - } - } - profileSync('NG_DIAGNOSTICS_TOTAL', () => { - for (const diagnostic of collectDiagnostics()) { - const message = convertTypeScriptDiagnostic(diagnostic, host); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + for (const diagnostic of compilation!.collectDiagnostics()) { + const message = convertTypeScriptDiagnostic(diagnostic); if (diagnostic.category === ts.DiagnosticCategory.Error) { (result.errors ??= []).push(message); } else { @@ -397,13 +311,7 @@ export function createCompilerPlugin( } }); - fileEmitter = createFileEmitter( - builder, - mergeTransformers(angularCompiler.prepareEmit().transformers, { - before: [replaceBootstrap(() => builder.getProgram().getTypeChecker())], - }), - (sourceFile) => angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile), - ); + fileEmitter = compilation.createFileEmitter(); return result; }); @@ -502,85 +410,6 @@ export function createCompilerPlugin( }; } -function createFileEmitter( - program: ts.BuilderProgram, - transformers: ts.CustomTransformers = {}, - onAfterEmit?: (sourceFile: ts.SourceFile) => void, -): FileEmitter { - return async (file: string) => { - const sourceFile = program.getSourceFile(file); - if (!sourceFile) { - return undefined; - } - - let content: string | undefined; - program.emit( - sourceFile, - (filename, data) => { - if (/\.[cm]?js$/.test(filename)) { - content = data; - } - }, - undefined /* cancellationToken */, - undefined /* emitOnlyDtsFiles */, - transformers, - ); - - onAfterEmit?.(sourceFile); - - return { content, dependencies: [] }; - }; -} - -function findAffectedFiles( - builder: ts.EmitAndSemanticDiagnosticsBuilderProgram, - { ignoreForDiagnostics, ignoreForEmit, incrementalCompilation }: NgtscProgram['compiler'], -): Set { - const affectedFiles = new Set(); - - // eslint-disable-next-line no-constant-condition - while (true) { - const result = builder.getSemanticDiagnosticsOfNextAffectedFile(undefined, (sourceFile) => { - // If the affected file is a TTC shim, add the shim's original source file. - // This ensures that changes that affect TTC are typechecked even when the changes - // are otherwise unrelated from a TS perspective and do not result in Ivy codegen changes. - // For example, changing @Input property types of a directive used in another component's - // template. - // A TTC shim is a file that has been ignored for diagnostics and has a filename ending in `.ngtypecheck.ts`. - if (ignoreForDiagnostics.has(sourceFile) && sourceFile.fileName.endsWith('.ngtypecheck.ts')) { - // This file name conversion relies on internal compiler logic and should be converted - // to an official method when available. 15 is length of `.ngtypecheck.ts` - const originalFilename = sourceFile.fileName.slice(0, -15) + '.ts'; - const originalSourceFile = builder.getSourceFile(originalFilename); - if (originalSourceFile) { - affectedFiles.add(originalSourceFile); - } - - return true; - } - - return false; - }); - - if (!result) { - break; - } - - affectedFiles.add(result.affected as ts.SourceFile); - } - - // A file is also affected if the Angular compiler requires it to be emitted - for (const sourceFile of builder.getSourceFiles()) { - if (ignoreForEmit.has(sourceFile) || incrementalCompilation.safeToSkipEmit(sourceFile)) { - continue; - } - - affectedFiles.add(sourceFile); - } - - return affectedFiles; -} - function createMissingFileError(request: string, original: string, root: string): PartialMessage { const error = { text: `File '${path.relative(root, request)}' is missing from the TypeScript compilation.`,