refactor(@angular-devkit/build-angular): emit affected files as a group in esbuild builder

The internal emit strategy for the TypeScript/Angular compiler has been adjusted to prefill
the memory cache during the initial phase of the build. Previously each file was emitted
during the bundling process as requested by the bundler. This change has no immediate effect
on the build process but enables future build performance improvements.
This commit is contained in:
Charles Lyding 2023-05-09 14:48:34 -04:00 committed by angular-robot[bot]
parent 22c1cb66c5
commit 1333a4e8c0
4 changed files with 93 additions and 96 deletions

View File

@ -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<EmitFileResult | undefined>;
export abstract class AngularCompilation {
static #angularCompilerCliModule?: typeof ng;
@ -59,5 +57,5 @@ export abstract class AngularCompilation {
abstract collectDiagnostics(): Iterable<ts.Diagnostic>;
abstract createFileEmitter(onAfterEmit?: (sourceFile: ts.SourceFile) => void): FileEmitter;
abstract emitAffectedFiles(): Iterable<EmitFileResult>;
}

View File

@ -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<EmitFileResult> {
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<ts.SourceFile, 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);
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();
}
}

View File

@ -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<string, ts.SourceFile> {
readonly modifiedFiles = new Set<string>();
readonly babelFileCache = new Map<string, Uint8Array>();
readonly typeScriptFileCache = new Map<string, Uint8Array>();
readonly typeScriptFileCache = new Map<string, string | Uint8Array>();
readonly loadResultCache = new MemoryLoadResultCache();
invalidate(files: Iterable<string>): 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<string, string | Uint8Array>();
// 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 {

View File

@ -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<EmitFileResult> {
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;
}
}