Skip to content

Commit d8930fa

Browse files
committed
feat(@angular-devkit/build-angular): support incremental TypeScript semantic diagnostics in esbuild builder
When using the esbuild-based browser application builder with CLI caching enabled, TypeScript's `incremental` option will also be enabled by default. A TypeScript build information file will be written after each build and an attempt to load and use the file will be made during compilation setup. Caching is enabled by default within the CLI and can be controlled via the `ng cache` command. This is the first use of persistent caching for the esbuild-based builder. If the TypeScript `incremental` option is manually set to `false`, the build system will not alter the value. This can be used to disable the behavior, if preferred, by setting the option to `false` in the application's configured `tsconfig` file. NOTE: The build information only contains information regarding the TypeScript compilation itself and does not contain information about the Angular AOT compilation. TypeScript does not have knowledge of the AOT compiler and it therefore cannot include that information in its build information file. Angular AOT analysis is still performed for each build.
1 parent 3ede1a2 commit d8930fa

File tree

5 files changed

+113
-72
lines changed

5 files changed

+113
-72
lines changed

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

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const { mergeTransformers, replaceBootstrap } = require('@ngtools/webpack/src/iv
2424
class AngularCompilationState {
2525
constructor(
2626
public readonly angularProgram: ng.NgtscProgram,
27+
public readonly compilerHost: ng.CompilerHost,
2728
public readonly typeScriptProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram,
2829
public readonly affectedFiles: ReadonlySet<ts.SourceFile>,
2930
public readonly templateDiagnosticsOptimization: ng.OptimizeFor,
@@ -67,20 +68,28 @@ export class AotCompilation extends AngularCompilation {
6768
const angularTypeScriptProgram = angularProgram.getTsProgram();
6869
ensureSourceFileVersions(angularTypeScriptProgram);
6970

71+
let oldProgram = this.#state?.typeScriptProgram;
72+
let usingBuildInfo = false;
73+
if (!oldProgram) {
74+
oldProgram = ts.readBuilderProgram(compilerOptions, host);
75+
usingBuildInfo = true;
76+
}
77+
7078
const typeScriptProgram = ts.createEmitAndSemanticDiagnosticsBuilderProgram(
7179
angularTypeScriptProgram,
7280
host,
73-
this.#state?.typeScriptProgram,
81+
oldProgram,
7482
configurationDiagnostics,
7583
);
7684

7785
await profileAsync('NG_ANALYZE_PROGRAM', () => angularCompiler.analyzeAsync());
7886
const affectedFiles = profileSync('NG_FIND_AFFECTED', () =>
79-
findAffectedFiles(typeScriptProgram, angularCompiler),
87+
findAffectedFiles(typeScriptProgram, angularCompiler, usingBuildInfo),
8088
);
8189

8290
this.#state = new AngularCompilationState(
8391
angularProgram,
92+
host,
8493
typeScriptProgram,
8594
affectedFiles,
8695
affectedFiles.size === 1 ? OptimizeFor.SingleFile : OptimizeFor.WholeProgram,
@@ -151,14 +160,16 @@ export class AotCompilation extends AngularCompilation {
151160

152161
emitAffectedFiles(): Iterable<EmitFileResult> {
153162
assert(this.#state, 'Angular compilation must be initialized prior to emitting files.');
154-
const { angularCompiler, typeScriptProgram } = this.#state;
163+
const { angularCompiler, compilerHost, typeScriptProgram } = this.#state;
155164
const buildInfoFilename =
156165
typeScriptProgram.getCompilerOptions().tsBuildInfoFile ?? '.tsbuildinfo';
157166

158167
const emittedFiles = new Map<ts.SourceFile, EmitFileResult>();
159168
const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => {
160-
if (sourceFiles?.length === 0 && filename.endsWith(buildInfoFilename)) {
161-
// TODO: Store incremental build info
169+
if (!sourceFiles?.length && filename.endsWith(buildInfoFilename)) {
170+
// Save builder info contents to specified location
171+
compilerHost.writeFile(filename, contents, false);
172+
162173
return;
163174
}
164175

@@ -168,6 +179,7 @@ export class AotCompilation extends AngularCompilation {
168179
return;
169180
}
170181

182+
angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile);
171183
emittedFiles.set(sourceFile, { filename: sourceFile.fileName, contents });
172184
};
173185
const transformers = mergeTransformers(angularCompiler.prepareEmit().transformers, {
@@ -187,6 +199,10 @@ export class AotCompilation extends AngularCompilation {
187199
continue;
188200
}
189201

202+
if (sourceFile.isDeclarationFile) {
203+
continue;
204+
}
205+
190206
if (angularCompiler.incrementalCompilation.safeToSkipEmit(sourceFile)) {
191207
continue;
192208
}
@@ -200,7 +216,8 @@ export class AotCompilation extends AngularCompilation {
200216

201217
function findAffectedFiles(
202218
builder: ts.EmitAndSemanticDiagnosticsBuilderProgram,
203-
{ ignoreForDiagnostics, ignoreForEmit, incrementalCompilation }: ng.NgtscProgram['compiler'],
219+
{ ignoreForDiagnostics }: ng.NgtscProgram['compiler'],
220+
includeTTC: boolean,
204221
): Set<ts.SourceFile> {
205222
const affectedFiles = new Set<ts.SourceFile>();
206223

@@ -235,13 +252,22 @@ function findAffectedFiles(
235252
affectedFiles.add(result.affected as ts.SourceFile);
236253
}
237254

238-
// A file is also affected if the Angular compiler requires it to be emitted
239-
for (const sourceFile of builder.getSourceFiles()) {
240-
if (ignoreForEmit.has(sourceFile) || incrementalCompilation.safeToSkipEmit(sourceFile)) {
241-
continue;
255+
// Add all files with associated template type checking files.
256+
// Stored TS build info does not have knowledge of the AOT compiler or the typechecking state of the templates.
257+
// To ensure that errors are reported correctly, all AOT component diagnostics need to be analyzed even if build
258+
// info is present.
259+
if (includeTTC) {
260+
for (const sourceFile of builder.getSourceFiles()) {
261+
if (ignoreForDiagnostics.has(sourceFile) && sourceFile.fileName.endsWith('.ngtypecheck.ts')) {
262+
// This file name conversion relies on internal compiler logic and should be converted
263+
// to an official method when available. 15 is length of `.ngtypecheck.ts`
264+
const originalFilename = sourceFile.fileName.slice(0, -15) + '.ts';
265+
const originalSourceFile = builder.getSourceFile(originalFilename);
266+
if (originalSourceFile) {
267+
affectedFiles.add(originalSourceFile);
268+
}
269+
}
242270
}
243-
244-
affectedFiles.add(sourceFile);
245271
}
246272

247273
return affectedFiles;

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

Lines changed: 61 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export class SourceFileCache extends Map<string, ts.SourceFile> {
4545
readonly typeScriptFileCache = new Map<string, string | Uint8Array>();
4646
readonly loadResultCache = new MemoryLoadResultCache();
4747

48+
constructor(readonly persistentCachePath?: string) {
49+
super();
50+
}
51+
4852
invalidate(files: Iterable<string>): void {
4953
this.modifiedFiles.clear();
5054
for (let file of files) {
@@ -208,6 +212,18 @@ export function createCompilerPlugin(
208212
});
209213
}
210214

215+
// Enable incremental compilation by default if caching is enabled
216+
if (pluginOptions.sourceFileCache?.persistentCachePath) {
217+
compilerOptions.incremental ??= true;
218+
// Set the build info file location to the configured cache directory
219+
compilerOptions.tsBuildInfoFile = path.join(
220+
pluginOptions.sourceFileCache?.persistentCachePath,
221+
'.tsbuildinfo',
222+
);
223+
} else {
224+
compilerOptions.incremental = false;
225+
}
226+
211227
return {
212228
...compilerOptions,
213229
noEmitOnError: false,
@@ -232,70 +248,62 @@ export function createCompilerPlugin(
232248
});
233249

234250
// 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-
}
251+
profileSync('NG_EMIT_TS', () => {
252+
for (const { filename, contents } of compilation.emitAffectedFiles()) {
253+
typeScriptFileCache.set(pathToFileURL(filename).href, contents);
254+
}
255+
});
238256

239257
// Reset the setup warnings so that they are only shown during the first build.
240258
setupWarnings = undefined;
241259

242260
return result;
243261
});
244262

245-
build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) =>
246-
profileAsync(
247-
'NG_EMIT_TS*',
248-
async () => {
249-
const request = pluginOptions.fileReplacements?.[args.path] ?? args.path;
263+
build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async (args) => {
264+
const request = pluginOptions.fileReplacements?.[args.path] ?? args.path;
250265

251-
// Skip TS load attempt if JS TypeScript compilation not enabled and file is JS
252-
if (shouldTsIgnoreJs && /\.[cm]?js$/.test(request)) {
253-
return undefined;
254-
}
266+
// Skip TS load attempt if JS TypeScript compilation not enabled and file is JS
267+
if (shouldTsIgnoreJs && /\.[cm]?js$/.test(request)) {
268+
return undefined;
269+
}
255270

256-
// The filename is currently used as a cache key. Since the cache is memory only,
257-
// the options cannot change and do not need to be represented in the key. If the
258-
// cache is later stored to disk, then the options that affect transform output
259-
// would need to be added to the key as well as a check for any change of content.
260-
let contents = typeScriptFileCache.get(pathToFileURL(request).href);
271+
// The filename is currently used as a cache key. Since the cache is memory only,
272+
// the options cannot change and do not need to be represented in the key. If the
273+
// cache is later stored to disk, then the options that affect transform output
274+
// would need to be added to the key as well as a check for any change of content.
275+
let contents = typeScriptFileCache.get(pathToFileURL(request).href);
276+
277+
if (contents === undefined) {
278+
// No TS result indicates the file is not part of the TypeScript program.
279+
// If allowJs is enabled and the file is JS then defer to the next load hook.
280+
if (!shouldTsIgnoreJs && /\.[cm]?js$/.test(request)) {
281+
return undefined;
282+
}
261283

262-
if (contents === undefined) {
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;
267-
}
268-
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
281-
contents = await javascriptTransformer.transformData(
282-
request,
283-
contents,
284-
true /* skipLinker */,
285-
);
286-
287-
// Store as the returned Uint8Array to allow caching the fully transformed code
288-
typeScriptFileCache.set(pathToFileURL(request).href, contents);
289-
}
284+
// Otherwise return an error
285+
return {
286+
errors: [
287+
createMissingFileError(request, args.path, build.initialOptions.absWorkingDir ?? ''),
288+
],
289+
};
290+
} else if (typeof contents === 'string') {
291+
// A string indicates untransformed output from the TS/NG compiler
292+
contents = await javascriptTransformer.transformData(
293+
request,
294+
contents,
295+
true /* skipLinker */,
296+
);
297+
298+
// Store as the returned Uint8Array to allow caching the fully transformed code
299+
typeScriptFileCache.set(pathToFileURL(request).href, contents);
300+
}
290301

291-
return {
292-
contents,
293-
loader: 'js',
294-
};
295-
},
296-
true,
297-
),
298-
);
302+
return {
303+
contents,
304+
loader: 'js',
305+
};
306+
});
299307

300308
build.onLoad({ filter: /\.[cm]?js$/ }, (args) =>
301309
profileAsync(

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { createJitResourceTransformer } from './jit-resource-transformer';
1616

1717
class JitCompilationState {
1818
constructor(
19+
public readonly compilerHost: ng.CompilerHost,
1920
public readonly typeScriptProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram,
2021
public readonly constructorParametersDownlevelTransform: ts.TransformerFactory<ts.SourceFile>,
2122
public readonly replaceResourcesTransform: ts.TransformerFactory<ts.SourceFile>,
@@ -51,7 +52,7 @@ export class JitCompilation extends AngularCompilation {
5152
rootNames,
5253
compilerOptions,
5354
host,
54-
this.#state?.typeScriptProgram,
55+
this.#state?.typeScriptProgram ?? ts.readBuilderProgram(compilerOptions, host),
5556
configurationDiagnostics,
5657
),
5758
);
@@ -61,6 +62,7 @@ export class JitCompilation extends AngularCompilation {
6162
);
6263

6364
this.#state = new JitCompilationState(
65+
host,
6466
typeScriptProgram,
6567
constructorParametersDownlevelTransform(typeScriptProgram.getProgram()),
6668
createJitResourceTransformer(() => typeScriptProgram.getProgram().getTypeChecker()),
@@ -86,6 +88,7 @@ export class JitCompilation extends AngularCompilation {
8688
emitAffectedFiles(): Iterable<EmitFileResult> {
8789
assert(this.#state, 'Compilation must be initialized prior to emitting files.');
8890
const {
91+
compilerHost,
8992
typeScriptProgram,
9093
constructorParametersDownlevelTransform,
9194
replaceResourcesTransform,
@@ -95,8 +98,10 @@ export class JitCompilation extends AngularCompilation {
9598

9699
const emittedFiles: EmitFileResult[] = [];
97100
const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => {
98-
if (sourceFiles?.length === 0 && filename.endsWith(buildInfoFilename)) {
99-
// TODO: Store incremental build info
101+
if (!sourceFiles?.length && filename.endsWith(buildInfoFilename)) {
102+
// Save builder info contents to specified location
103+
compilerHost.writeFile(filename, contents, false);
104+
100105
return;
101106
}
102107

packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,16 +98,17 @@ async function execute(
9898
assets,
9999
serviceWorkerOptions,
100100
indexHtmlOptions,
101+
cacheOptions,
101102
} = options;
102103

103104
const browsers = getSupportedBrowsers(projectRoot, context.logger);
104105
const target = transformSupportedBrowsersToTargets(browsers);
105106

106107
// Reuse rebuild state or create new bundle contexts for code and global stylesheets
107108
let bundlerContexts = rebuildState?.rebuildContexts;
108-
const codeBundleCache = options.watch
109-
? rebuildState?.codeBundleCache ?? new SourceFileCache()
110-
: undefined;
109+
const codeBundleCache =
110+
rebuildState?.codeBundleCache ??
111+
new SourceFileCache(cacheOptions.enabled ? cacheOptions.path : undefined);
111112
if (bundlerContexts === undefined) {
112113
bundlerContexts = [];
113114

packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
*/
88

99
import { BuilderContext } from '@angular-devkit/architect';
10-
import fs from 'node:fs';
1110
import { createRequire } from 'node:module';
1211
import path from 'node:path';
1312
import { normalizeAssetPatterns, normalizeOptimization, normalizeSourceMaps } from '../../utils';
@@ -64,7 +63,9 @@ export async function normalizeOptions(
6463
path.join(workspaceRoot, (projectMetadata.sourceRoot as string | undefined) ?? 'src'),
6564
);
6665

66+
// Gather persistent caching option and provide a project specific cache location
6767
const cacheOptions = normalizeCacheOptions(projectMetadata, workspaceRoot);
68+
cacheOptions.path = path.join(cacheOptions.path, projectName);
6869

6970
const entryPoints = normalizeEntryPoints(workspaceRoot, options.main, options.entryPoints);
7071
const tsconfig = path.join(workspaceRoot, options.tsConfig);

0 commit comments

Comments
 (0)