Skip to content

Commit 1333a4e

Browse files
clydinangular-robot[bot]
authored andcommitted
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.
1 parent 22c1cb6 commit 1333a4e

File tree

4 files changed

+93
-96
lines changed

4 files changed

+93
-96
lines changed

packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/angular-compilation.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,11 @@ import { profileSync } from '../profiling';
1313
import type { AngularHostOptions } from './angular-host';
1414

1515
export interface EmitFileResult {
16-
content?: string;
17-
map?: string;
18-
dependencies: readonly string[];
16+
filename: string;
17+
contents: string;
18+
dependencies?: readonly string[];
1919
}
2020

21-
export type FileEmitter = (file: string) => Promise<EmitFileResult | undefined>;
22-
2321
export abstract class AngularCompilation {
2422
static #angularCompilerCliModule?: typeof ng;
2523

@@ -59,5 +57,5 @@ export abstract class AngularCompilation {
5957

6058
abstract collectDiagnostics(): Iterable<ts.Diagnostic>;
6159

62-
abstract createFileEmitter(onAfterEmit?: (sourceFile: ts.SourceFile) => void): FileEmitter;
60+
abstract emitAffectedFiles(): Iterable<EmitFileResult>;
6361
}

packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/aot-compilation.ts

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type ng from '@angular/compiler-cli';
1010
import assert from 'node:assert';
1111
import ts from 'typescript';
1212
import { profileAsync, profileSync } from '../profiling';
13-
import { AngularCompilation, FileEmitter } from './angular-compilation';
13+
import { AngularCompilation, EmitFileResult } from './angular-compilation';
1414
import {
1515
AngularHostOptions,
1616
createAngularCompilerHost,
@@ -149,38 +149,52 @@ export class AotCompilation extends AngularCompilation {
149149
}
150150
}
151151

152-
createFileEmitter(onAfterEmit?: (sourceFile: ts.SourceFile) => void): FileEmitter {
152+
emitAffectedFiles(): Iterable<EmitFileResult> {
153153
assert(this.#state, 'Angular compilation must be initialized prior to emitting files.');
154154
const { angularCompiler, typeScriptProgram } = this.#state;
155+
const buildInfoFilename =
156+
typeScriptProgram.getCompilerOptions().tsBuildInfoFile ?? '.tsbuildinfo';
157+
158+
const emittedFiles = new Map<ts.SourceFile, EmitFileResult>();
159+
const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => {
160+
if (sourceFiles?.length === 0 && filename.endsWith(buildInfoFilename)) {
161+
// TODO: Store incremental build info
162+
return;
163+
}
164+
165+
assert(sourceFiles?.length === 1, 'Invalid TypeScript program emit for ' + filename);
166+
const sourceFile = sourceFiles[0];
167+
if (angularCompiler.ignoreForEmit.has(sourceFile)) {
168+
return;
169+
}
155170

171+
emittedFiles.set(sourceFile, { filename: sourceFile.fileName, contents });
172+
};
156173
const transformers = mergeTransformers(angularCompiler.prepareEmit().transformers, {
157174
before: [replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker())],
158175
});
159176

160-
return async (file: string) => {
161-
const sourceFile = typeScriptProgram.getSourceFile(file);
162-
if (!sourceFile) {
163-
return undefined;
177+
// TypeScript will loop until there are no more affected files in the program
178+
while (
179+
typeScriptProgram.emitNextAffectedFile(writeFileCallback, undefined, undefined, transformers)
180+
) {
181+
/* empty */
182+
}
183+
184+
// Angular may have files that must be emitted but TypeScript does not consider affected
185+
for (const sourceFile of typeScriptProgram.getSourceFiles()) {
186+
if (emittedFiles.has(sourceFile) || angularCompiler.ignoreForEmit.has(sourceFile)) {
187+
continue;
164188
}
165189

166-
let content: string | undefined;
167-
typeScriptProgram.emit(
168-
sourceFile,
169-
(filename, data) => {
170-
if (/\.[cm]?js$/.test(filename)) {
171-
content = data;
172-
}
173-
},
174-
undefined /* cancellationToken */,
175-
undefined /* emitOnlyDtsFiles */,
176-
transformers,
177-
);
190+
if (angularCompiler.incrementalCompilation.safeToSkipEmit(sourceFile)) {
191+
continue;
192+
}
178193

179-
angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile);
180-
onAfterEmit?.(sourceFile);
194+
typeScriptProgram.emit(sourceFile, writeFileCallback, undefined, undefined, transformers);
195+
}
181196

182-
return { content, dependencies: [] };
183-
};
197+
return emittedFiles.values();
184198
}
185199
}
186200

packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/compiler-plugin.ts

Lines changed: 31 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import type {
1414
Plugin,
1515
PluginBuild,
1616
} from 'esbuild';
17-
import * as assert from 'node:assert';
1817
import { realpath } from 'node:fs/promises';
1918
import { platform } from 'node:os';
2019
import * as path from 'node:path';
@@ -30,7 +29,7 @@ import {
3029
resetCumulativeDurations,
3130
} from '../profiling';
3231
import { BundleStylesheetOptions, bundleComponentStylesheet } from '../stylesheets/bundle-options';
33-
import { AngularCompilation, FileEmitter } from './angular-compilation';
32+
import { AngularCompilation } from './angular-compilation';
3433
import { AngularHostOptions } from './angular-host';
3534
import { AotCompilation } from './aot-compilation';
3635
import { convertTypeScriptDiagnostic } from './diagnostics';
@@ -43,7 +42,7 @@ const WINDOWS_SEP_REGEXP = new RegExp(`\\${path.win32.sep}`, 'g');
4342
export class SourceFileCache extends Map<string, ts.SourceFile> {
4443
readonly modifiedFiles = new Set<string>();
4544
readonly babelFileCache = new Map<string, Uint8Array>();
46-
readonly typeScriptFileCache = new Map<string, Uint8Array>();
45+
readonly typeScriptFileCache = new Map<string, string | Uint8Array>();
4746
readonly loadResultCache = new MemoryLoadResultCache();
4847

4948
invalidate(files: Iterable<string>): void {
@@ -116,8 +115,11 @@ export function createCompilerPlugin(
116115
build.initialOptions.define[key] = value.toString();
117116
}
118117

119-
// The file emitter created during `onStart` that will be used during the build in `onLoad` callbacks for TS files
120-
let fileEmitter: FileEmitter | undefined;
118+
// The in-memory cache of TypeScript file outputs will be used during the build in `onLoad` callbacks for TS files.
119+
// A string value indicates direct TS/NG output and a Uint8Array indicates fully transformed code.
120+
const typeScriptFileCache =
121+
pluginOptions.sourceFileCache?.typeScriptFileCache ??
122+
new Map<string, string | Uint8Array>();
121123

122124
// The stylesheet resources from component stylesheets that will be added to the build results output files
123125
let stylesheetResourceFiles: OutputFile[] = [];
@@ -178,7 +180,6 @@ export function createCompilerPlugin(
178180
// Initialize the Angular compilation for the current build.
179181
// In watch mode, previous build state will be reused.
180182
const {
181-
affectedFiles,
182183
compilerOptions: { allowJs },
183184
} = await compilation.initialize(tsconfigPath, hostOptions, (compilerOptions) => {
184185
if (
@@ -219,15 +220,6 @@ export function createCompilerPlugin(
219220
});
220221
shouldTsIgnoreJs = !allowJs;
221222

222-
// Clear affected files from the cache (if present)
223-
if (pluginOptions.sourceFileCache) {
224-
for (const affected of affectedFiles) {
225-
pluginOptions.sourceFileCache.typeScriptFileCache.delete(
226-
pathToFileURL(affected.fileName).href,
227-
);
228-
}
229-
}
230-
231223
profileSync('NG_DIAGNOSTICS_TOTAL', () => {
232224
for (const diagnostic of compilation.collectDiagnostics()) {
233225
const message = convertTypeScriptDiagnostic(diagnostic);
@@ -239,7 +231,10 @@ export function createCompilerPlugin(
239231
}
240232
});
241233

242-
fileEmitter = compilation.createFileEmitter();
234+
// Update TypeScript file output cache for all affected files
235+
for (const { filename, contents } of compilation.emitAffectedFiles()) {
236+
typeScriptFileCache.set(pathToFileURL(filename).href, contents);
237+
}
243238

244239
// Reset the setup warnings so that they are only shown during the first build.
245240
setupWarnings = undefined;
@@ -251,8 +246,6 @@ export function createCompilerPlugin(
251246
profileAsync(
252247
'NG_EMIT_TS*',
253248
async () => {
254-
assert.ok(fileEmitter, 'Invalid plugin execution order');
255-
256249
const request = pluginOptions.fileReplacements?.[args.path] ?? args.path;
257250

258251
// Skip TS load attempt if JS TypeScript compilation not enabled and file is JS
@@ -264,41 +257,35 @@ export function createCompilerPlugin(
264257
// the options cannot change and do not need to be represented in the key. If the
265258
// cache is later stored to disk, then the options that affect transform output
266259
// would need to be added to the key as well as a check for any change of content.
267-
let contents = pluginOptions.sourceFileCache?.typeScriptFileCache.get(
268-
pathToFileURL(request).href,
269-
);
260+
let contents = typeScriptFileCache.get(pathToFileURL(request).href);
270261

271262
if (contents === undefined) {
272-
const typescriptResult = await fileEmitter(request);
273-
if (!typescriptResult?.content) {
274-
// No TS result indicates the file is not part of the TypeScript program.
275-
// If allowJs is enabled and the file is JS then defer to the next load hook.
276-
if (!shouldTsIgnoreJs && /\.[cm]?js$/.test(request)) {
277-
return undefined;
278-
}
279-
280-
// Otherwise return an error
281-
return {
282-
errors: [
283-
createMissingFileError(
284-
request,
285-
args.path,
286-
build.initialOptions.absWorkingDir ?? '',
287-
),
288-
],
289-
};
263+
// No TS result indicates the file is not part of the TypeScript program.
264+
// If allowJs is enabled and the file is JS then defer to the next load hook.
265+
if (!shouldTsIgnoreJs && /\.[cm]?js$/.test(request)) {
266+
return undefined;
290267
}
291268

269+
// Otherwise return an error
270+
return {
271+
errors: [
272+
createMissingFileError(
273+
request,
274+
args.path,
275+
build.initialOptions.absWorkingDir ?? '',
276+
),
277+
],
278+
};
279+
} else if (typeof contents === 'string') {
280+
// A string indicates untransformed output from the TS/NG compiler
292281
contents = await javascriptTransformer.transformData(
293282
request,
294-
typescriptResult.content,
283+
contents,
295284
true /* skipLinker */,
296285
);
297286

298-
pluginOptions.sourceFileCache?.typeScriptFileCache.set(
299-
pathToFileURL(request).href,
300-
contents,
301-
);
287+
// Store as the returned Uint8Array to allow caching the fully transformed code
288+
typeScriptFileCache.set(pathToFileURL(request).href, contents);
302289
}
303290

304291
return {

packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/jit-compilation.ts

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type ng from '@angular/compiler-cli';
1010
import assert from 'node:assert';
1111
import ts from 'typescript';
1212
import { profileSync } from '../profiling';
13-
import { AngularCompilation, FileEmitter } from './angular-compilation';
13+
import { AngularCompilation, EmitFileResult } from './angular-compilation';
1414
import { AngularHostOptions, createAngularCompilerHost } from './angular-host';
1515
import { createJitResourceTransformer } from './jit-resource-transformer';
1616

@@ -83,41 +83,39 @@ export class JitCompilation extends AngularCompilation {
8383
yield* profileSync('NG_DIAGNOSTICS_SEMANTIC', () => typeScriptProgram.getSemanticDiagnostics());
8484
}
8585

86-
createFileEmitter(onAfterEmit?: (sourceFile: ts.SourceFile) => void): FileEmitter {
86+
emitAffectedFiles(): Iterable<EmitFileResult> {
8787
assert(this.#state, 'Compilation must be initialized prior to emitting files.');
8888
const {
8989
typeScriptProgram,
9090
constructorParametersDownlevelTransform,
9191
replaceResourcesTransform,
9292
} = this.#state;
93+
const buildInfoFilename =
94+
typeScriptProgram.getCompilerOptions().tsBuildInfoFile ?? '.tsbuildinfo';
95+
96+
const emittedFiles: EmitFileResult[] = [];
97+
const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => {
98+
if (sourceFiles?.length === 0 && filename.endsWith(buildInfoFilename)) {
99+
// TODO: Store incremental build info
100+
return;
101+
}
102+
103+
assert(sourceFiles?.length === 1, 'Invalid TypeScript program emit for ' + filename);
93104

105+
emittedFiles.push({ filename: sourceFiles[0].fileName, contents });
106+
};
94107
const transformers = {
95108
before: [replaceResourcesTransform, constructorParametersDownlevelTransform],
96109
};
97110

98-
return async (file: string) => {
99-
const sourceFile = typeScriptProgram.getSourceFile(file);
100-
if (!sourceFile) {
101-
return undefined;
102-
}
111+
// TypeScript will loop until there are no more affected files in the program
112+
while (
113+
typeScriptProgram.emitNextAffectedFile(writeFileCallback, undefined, undefined, transformers)
114+
) {
115+
/* empty */
116+
}
103117

104-
let content: string | undefined;
105-
typeScriptProgram.emit(
106-
sourceFile,
107-
(filename, data) => {
108-
if (/\.[cm]?js$/.test(filename)) {
109-
content = data;
110-
}
111-
},
112-
undefined /* cancellationToken */,
113-
undefined /* emitOnlyDtsFiles */,
114-
transformers,
115-
);
116-
117-
onAfterEmit?.(sourceFile);
118-
119-
return { content, dependencies: [] };
120-
};
118+
return emittedFiles;
121119
}
122120
}
123121

0 commit comments

Comments
 (0)