Skip to content

Commit 919fe21

Browse files
committed
perf(@angular-devkit/build-angular): avoid extra TypeScript emits with esbuild rebuilds
To further improve incremental rebuild performance of the experimental esbuild-based browser application builder, the output of the TypeScript file loader within the Angular compiler plugin are now cached in memory by the input file name and invalidated via the file watching events. This allows an additional TypeScript emit including the associated transformations per input file to be avoided if the file has not changed or has not been affected by other files within the TypeScript program. (cherry picked from commit 1c87de6)
1 parent c4b0355 commit 919fe21

File tree

1 file changed

+64
-36
lines changed

1 file changed

+64
-36
lines changed

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

Lines changed: 64 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
import type { CompilerHost, NgtscProgram } from '@angular/compiler-cli';
1010
import { transformAsync } from '@babel/core';
11-
import * as assert from 'assert';
1211
import type {
1312
OnStartResult,
1413
OutputFile,
@@ -17,9 +16,11 @@ import type {
1716
Plugin,
1817
PluginBuild,
1918
} from 'esbuild';
20-
import { promises as fs } from 'fs';
21-
import { platform } from 'os';
22-
import * as path from 'path';
19+
import * as assert from 'node:assert';
20+
import * as fs from 'node:fs/promises';
21+
import { platform } from 'node:os';
22+
import * as path from 'node:path';
23+
import { pathToFileURL } from 'node:url';
2324
import ts from 'typescript';
2425
import angularApplicationPreset from '../../babel/presets/application';
2526
import { requiresLinking } from '../../babel/webpack-loader';
@@ -133,11 +134,13 @@ const WINDOWS_SEP_REGEXP = new RegExp(`\\${path.win32.sep}`, 'g');
133134
export class SourceFileCache extends Map<string, ts.SourceFile> {
134135
readonly modifiedFiles = new Set<string>();
135136
readonly babelFileCache = new Map<string, Uint8Array>();
137+
readonly typeScriptFileCache = new Map<string, Uint8Array>();
136138

137139
invalidate(files: Iterable<string>): void {
138140
this.modifiedFiles.clear();
139141
for (let file of files) {
140142
this.babelFileCache.delete(file);
143+
this.typeScriptFileCache.delete(pathToFileURL(file).href);
141144

142145
// Normalize separators to allow matching TypeScript Host paths
143146
if (USING_WINDOWS) {
@@ -355,6 +358,17 @@ export function createCompilerPlugin(
355358
previousBuilder = builder;
356359

357360
await profileAsync('NG_ANALYZE_PROGRAM', () => angularCompiler.analyzeAsync());
361+
const affectedFiles = profileSync('NG_FIND_AFFECTED', () =>
362+
findAffectedFiles(builder, angularCompiler),
363+
);
364+
365+
if (pluginOptions.sourceFileCache) {
366+
for (const affected of affectedFiles) {
367+
pluginOptions.sourceFileCache.typeScriptFileCache.delete(
368+
pathToFileURL(affected.fileName).href,
369+
);
370+
}
371+
}
358372

359373
function* collectDiagnostics(): Iterable<ts.Diagnostic> {
360374
// Collect program level diagnostics
@@ -364,7 +378,6 @@ export function createCompilerPlugin(
364378
yield* builder.getGlobalDiagnostics();
365379

366380
// Collect source file specific diagnostics
367-
const affectedFiles = findAffectedFiles(builder, angularCompiler);
368381
const optimizeFor =
369382
affectedFiles.size > 1 ? OptimizeFor.WholeProgram : OptimizeFor.SingleFile;
370383
for (const sourceFile of builder.getSourceFiles()) {
@@ -434,41 +447,56 @@ export function createCompilerPlugin(
434447
async () => {
435448
assert.ok(fileEmitter, 'Invalid plugin execution order');
436449

437-
const typescriptResult = await fileEmitter(
438-
pluginOptions.fileReplacements?.[args.path] ?? args.path,
450+
// The filename is currently used as a cache key. Since the cache is memory only,
451+
// the options cannot change and do not need to be represented in the key. If the
452+
// cache is later stored to disk, then the options that affect transform output
453+
// would need to be added to the key as well as a check for any change of content.
454+
let contents = pluginOptions.sourceFileCache?.typeScriptFileCache.get(
455+
pathToFileURL(args.path).href,
439456
);
440-
if (!typescriptResult) {
441-
// No TS result indicates the file is not part of the TypeScript program.
442-
// If allowJs is enabled and the file is JS then defer to the next load hook.
443-
if (compilerOptions.allowJs && /\.[cm]?js$/.test(args.path)) {
444-
return undefined;
457+
458+
if (contents === undefined) {
459+
const typescriptResult = await fileEmitter(
460+
pluginOptions.fileReplacements?.[args.path] ?? args.path,
461+
);
462+
if (!typescriptResult) {
463+
// No TS result indicates the file is not part of the TypeScript program.
464+
// If allowJs is enabled and the file is JS then defer to the next load hook.
465+
if (compilerOptions.allowJs && /\.[cm]?js$/.test(args.path)) {
466+
return undefined;
467+
}
468+
469+
// Otherwise return an error
470+
return {
471+
errors: [
472+
{
473+
text: `File '${args.path}' is missing from the TypeScript compilation.`,
474+
notes: [
475+
{
476+
text: `Ensure the file is part of the TypeScript program via the 'files' or 'include' property.`,
477+
},
478+
],
479+
},
480+
],
481+
};
445482
}
446483

447-
// Otherwise return an error
448-
return {
449-
errors: [
450-
{
451-
text: `File '${args.path}' is missing from the TypeScript compilation.`,
452-
notes: [
453-
{
454-
text: `Ensure the file is part of the TypeScript program via the 'files' or 'include' property.`,
455-
},
456-
],
457-
},
458-
],
459-
};
460-
}
484+
const data = typescriptResult.content ?? '';
485+
// The pre-transformed data is used as a cache key. Since the cache is memory only,
486+
// the options cannot change and do not need to be represented in the key. If the
487+
// cache is later stored to disk, then the options that affect transform output
488+
// would need to be added to the key as well.
489+
contents = babelDataCache.get(data);
490+
if (contents === undefined) {
491+
const transformedData = await transformWithBabel(args.path, data, pluginOptions);
492+
contents = Buffer.from(transformedData, 'utf-8');
493+
babelDataCache.set(data, contents);
494+
}
461495

462-
const data = typescriptResult.content ?? '';
463-
// The pre-transformed data is used as a cache key. Since the cache is memory only,
464-
// the options cannot change and do not need to be represented in the key. If the
465-
// cache is later stored to disk, then the options that affect transform output
466-
// would need to be added to the key as well.
467-
let contents = babelDataCache.get(data);
468-
if (contents === undefined) {
469-
const transformedData = await transformWithBabel(args.path, data, pluginOptions);
470-
contents = Buffer.from(transformedData, 'utf-8');
471-
babelDataCache.set(data, contents);
496+
pluginOptions.sourceFileCache?.typeScriptFileCache.set(
497+
pathToFileURL(args.path).href,
498+
contents,
499+
);
472500
}
473501

474502
return {

0 commit comments

Comments
 (0)