diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/angular-compilation.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/angular-compilation.ts index 88772fabcc..de1e775dbd 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/angular-compilation.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/angular-compilation.ts @@ -13,13 +13,11 @@ import { profileSync } from '../profiling'; import type { AngularHostOptions } from './angular-host'; export interface EmitFileResult { - content?: string; - map?: string; - dependencies: readonly string[]; + filename: string; + contents: string; + dependencies?: readonly string[]; } -export type FileEmitter = (file: string) => Promise; - export abstract class AngularCompilation { static #angularCompilerCliModule?: typeof ng; @@ -59,5 +57,5 @@ export abstract class AngularCompilation { abstract collectDiagnostics(): Iterable; - abstract createFileEmitter(onAfterEmit?: (sourceFile: ts.SourceFile) => void): FileEmitter; + abstract emitAffectedFiles(): Iterable; } diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/aot-compilation.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/aot-compilation.ts index a91a2566ee..e0cc06cb7e 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/aot-compilation.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/aot-compilation.ts @@ -10,7 +10,7 @@ import type ng from '@angular/compiler-cli'; import assert from 'node:assert'; import ts from 'typescript'; import { profileAsync, profileSync } from '../profiling'; -import { AngularCompilation, FileEmitter } from './angular-compilation'; +import { AngularCompilation, EmitFileResult } from './angular-compilation'; import { AngularHostOptions, createAngularCompilerHost, @@ -149,38 +149,52 @@ export class AotCompilation extends AngularCompilation { } } - createFileEmitter(onAfterEmit?: (sourceFile: ts.SourceFile) => void): FileEmitter { + emitAffectedFiles(): Iterable { assert(this.#state, 'Angular compilation must be initialized prior to emitting files.'); const { angularCompiler, typeScriptProgram } = this.#state; + const buildInfoFilename = + typeScriptProgram.getCompilerOptions().tsBuildInfoFile ?? '.tsbuildinfo'; + const emittedFiles = new Map(); + const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => { + if (sourceFiles?.length === 0 && filename.endsWith(buildInfoFilename)) { + // TODO: Store incremental build info + return; + } + + assert(sourceFiles?.length === 1, 'Invalid TypeScript program emit for ' + filename); + const sourceFile = sourceFiles[0]; + if (angularCompiler.ignoreForEmit.has(sourceFile)) { + return; + } + + emittedFiles.set(sourceFile, { filename: sourceFile.fileName, contents }); + }; const transformers = mergeTransformers(angularCompiler.prepareEmit().transformers, { before: [replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker())], }); - return async (file: string) => { - const sourceFile = typeScriptProgram.getSourceFile(file); - if (!sourceFile) { - return undefined; + // TypeScript will loop until there are no more affected files in the program + while ( + typeScriptProgram.emitNextAffectedFile(writeFileCallback, undefined, undefined, transformers) + ) { + /* empty */ + } + + // Angular may have files that must be emitted but TypeScript does not consider affected + for (const sourceFile of typeScriptProgram.getSourceFiles()) { + if (emittedFiles.has(sourceFile) || angularCompiler.ignoreForEmit.has(sourceFile)) { + continue; } - let content: string | undefined; - typeScriptProgram.emit( - sourceFile, - (filename, data) => { - if (/\.[cm]?js$/.test(filename)) { - content = data; - } - }, - undefined /* cancellationToken */, - undefined /* emitOnlyDtsFiles */, - transformers, - ); + if (angularCompiler.incrementalCompilation.safeToSkipEmit(sourceFile)) { + continue; + } - angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile); - onAfterEmit?.(sourceFile); + typeScriptProgram.emit(sourceFile, writeFileCallback, undefined, undefined, transformers); + } - return { content, dependencies: [] }; - }; + return emittedFiles.values(); } } diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/compiler-plugin.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/compiler-plugin.ts index e1999f5fd3..8f13108898 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/compiler-plugin.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/compiler-plugin.ts @@ -14,7 +14,6 @@ import type { Plugin, PluginBuild, } from 'esbuild'; -import * as assert from 'node:assert'; import { realpath } from 'node:fs/promises'; import { platform } from 'node:os'; import * as path from 'node:path'; @@ -30,7 +29,7 @@ import { resetCumulativeDurations, } from '../profiling'; import { BundleStylesheetOptions, bundleComponentStylesheet } from '../stylesheets/bundle-options'; -import { AngularCompilation, FileEmitter } from './angular-compilation'; +import { AngularCompilation } from './angular-compilation'; import { AngularHostOptions } from './angular-host'; import { AotCompilation } from './aot-compilation'; import { convertTypeScriptDiagnostic } from './diagnostics'; @@ -43,7 +42,7 @@ const WINDOWS_SEP_REGEXP = new RegExp(`\\${path.win32.sep}`, 'g'); export class SourceFileCache extends Map { readonly modifiedFiles = new Set(); readonly babelFileCache = new Map(); - readonly typeScriptFileCache = new Map(); + readonly typeScriptFileCache = new Map(); readonly loadResultCache = new MemoryLoadResultCache(); invalidate(files: Iterable): void { @@ -116,8 +115,11 @@ export function createCompilerPlugin( build.initialOptions.define[key] = value.toString(); } - // The file emitter created during `onStart` that will be used during the build in `onLoad` callbacks for TS files - let fileEmitter: FileEmitter | undefined; + // The in-memory cache of TypeScript file outputs will be used during the build in `onLoad` callbacks for TS files. + // A string value indicates direct TS/NG output and a Uint8Array indicates fully transformed code. + const typeScriptFileCache = + pluginOptions.sourceFileCache?.typeScriptFileCache ?? + new Map(); // The stylesheet resources from component stylesheets that will be added to the build results output files let stylesheetResourceFiles: OutputFile[] = []; @@ -178,7 +180,6 @@ export function createCompilerPlugin( // Initialize the Angular compilation for the current build. // In watch mode, previous build state will be reused. const { - affectedFiles, compilerOptions: { allowJs }, } = await compilation.initialize(tsconfigPath, hostOptions, (compilerOptions) => { if ( @@ -219,15 +220,6 @@ export function createCompilerPlugin( }); shouldTsIgnoreJs = !allowJs; - // Clear affected files from the cache (if present) - if (pluginOptions.sourceFileCache) { - for (const affected of affectedFiles) { - pluginOptions.sourceFileCache.typeScriptFileCache.delete( - pathToFileURL(affected.fileName).href, - ); - } - } - profileSync('NG_DIAGNOSTICS_TOTAL', () => { for (const diagnostic of compilation.collectDiagnostics()) { const message = convertTypeScriptDiagnostic(diagnostic); @@ -239,7 +231,10 @@ export function createCompilerPlugin( } }); - fileEmitter = compilation.createFileEmitter(); + // Update TypeScript file output cache for all affected files + for (const { filename, contents } of compilation.emitAffectedFiles()) { + typeScriptFileCache.set(pathToFileURL(filename).href, contents); + } // Reset the setup warnings so that they are only shown during the first build. setupWarnings = undefined; @@ -251,8 +246,6 @@ export function createCompilerPlugin( profileAsync( 'NG_EMIT_TS*', async () => { - assert.ok(fileEmitter, 'Invalid plugin execution order'); - const request = pluginOptions.fileReplacements?.[args.path] ?? args.path; // Skip TS load attempt if JS TypeScript compilation not enabled and file is JS @@ -264,41 +257,35 @@ export function createCompilerPlugin( // the options cannot change and do not need to be represented in the key. If the // cache is later stored to disk, then the options that affect transform output // would need to be added to the key as well as a check for any change of content. - let contents = pluginOptions.sourceFileCache?.typeScriptFileCache.get( - pathToFileURL(request).href, - ); + let contents = typeScriptFileCache.get(pathToFileURL(request).href); if (contents === undefined) { - const typescriptResult = await fileEmitter(request); - if (!typescriptResult?.content) { - // No TS result indicates the file is not part of the TypeScript program. - // If allowJs is enabled and the file is JS then defer to the next load hook. - if (!shouldTsIgnoreJs && /\.[cm]?js$/.test(request)) { - return undefined; - } - - // Otherwise return an error - return { - errors: [ - createMissingFileError( - request, - args.path, - build.initialOptions.absWorkingDir ?? '', - ), - ], - }; + // No TS result indicates the file is not part of the TypeScript program. + // If allowJs is enabled and the file is JS then defer to the next load hook. + if (!shouldTsIgnoreJs && /\.[cm]?js$/.test(request)) { + return undefined; } + // Otherwise return an error + return { + errors: [ + createMissingFileError( + request, + args.path, + build.initialOptions.absWorkingDir ?? '', + ), + ], + }; + } else if (typeof contents === 'string') { + // A string indicates untransformed output from the TS/NG compiler contents = await javascriptTransformer.transformData( request, - typescriptResult.content, + contents, true /* skipLinker */, ); - pluginOptions.sourceFileCache?.typeScriptFileCache.set( - pathToFileURL(request).href, - contents, - ); + // Store as the returned Uint8Array to allow caching the fully transformed code + typeScriptFileCache.set(pathToFileURL(request).href, contents); } return { diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/jit-compilation.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/jit-compilation.ts index c6cc4626d0..24607dd2e7 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/jit-compilation.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/jit-compilation.ts @@ -10,7 +10,7 @@ import type ng from '@angular/compiler-cli'; import assert from 'node:assert'; import ts from 'typescript'; import { profileSync } from '../profiling'; -import { AngularCompilation, FileEmitter } from './angular-compilation'; +import { AngularCompilation, EmitFileResult } from './angular-compilation'; import { AngularHostOptions, createAngularCompilerHost } from './angular-host'; import { createJitResourceTransformer } from './jit-resource-transformer'; @@ -83,41 +83,39 @@ export class JitCompilation extends AngularCompilation { yield* profileSync('NG_DIAGNOSTICS_SEMANTIC', () => typeScriptProgram.getSemanticDiagnostics()); } - createFileEmitter(onAfterEmit?: (sourceFile: ts.SourceFile) => void): FileEmitter { + emitAffectedFiles(): Iterable { assert(this.#state, 'Compilation must be initialized prior to emitting files.'); const { typeScriptProgram, constructorParametersDownlevelTransform, replaceResourcesTransform, } = this.#state; + const buildInfoFilename = + typeScriptProgram.getCompilerOptions().tsBuildInfoFile ?? '.tsbuildinfo'; + const emittedFiles: EmitFileResult[] = []; + const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => { + if (sourceFiles?.length === 0 && filename.endsWith(buildInfoFilename)) { + // TODO: Store incremental build info + return; + } + + assert(sourceFiles?.length === 1, 'Invalid TypeScript program emit for ' + filename); + + emittedFiles.push({ filename: sourceFiles[0].fileName, contents }); + }; const transformers = { before: [replaceResourcesTransform, constructorParametersDownlevelTransform], }; - return async (file: string) => { - const sourceFile = typeScriptProgram.getSourceFile(file); - if (!sourceFile) { - return undefined; - } + // TypeScript will loop until there are no more affected files in the program + while ( + typeScriptProgram.emitNextAffectedFile(writeFileCallback, undefined, undefined, transformers) + ) { + /* empty */ + } - let content: string | undefined; - typeScriptProgram.emit( - sourceFile, - (filename, data) => { - if (/\.[cm]?js$/.test(filename)) { - content = data; - } - }, - undefined /* cancellationToken */, - undefined /* emitOnlyDtsFiles */, - transformers, - ); - - onAfterEmit?.(sourceFile); - - return { content, dependencies: [] }; - }; + return emittedFiles; } }