From 440fe64c8a93f1d0b92a478e0b0b756648d27c5a Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:32:16 +0000 Subject: [PATCH 1/2] perf(@angular/build): cache transformed configuration in TypeScript compilation When `rootFiles` are explicitly provided (such as in library builds), avoid re-reading and re-parsing `tsconfig.json` and traversing the project directory structure on every watch rebuild. Cache the `TransformedConfiguration` and invalidate it only when a `.json` configuration file changes. --- .../angular/compilation/aot-compilation.ts | 36 ++++--- .../angular/compilation/jit-compilation.ts | 8 +- .../compilation/typescript-compilation.ts | 102 ++++++++++++++---- 3 files changed, 108 insertions(+), 38 deletions(-) diff --git a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts index e4047a3e3e1b..46ee44048b8a 100644 --- a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts @@ -77,6 +77,7 @@ export class AotCompilation extends TypeScriptCompilation { rootNames, errors: configurationDiagnostics, warnings, + extendedConfigFiles, } = await this.loadConfiguration(tsconfig, compilerOptionOverrides, buildType); const useTypeScriptTranspilation = @@ -209,25 +210,28 @@ export class AotCompilation extends TypeScriptCompilation { const componentResourcesDependencies = new Map(); // Get all files referenced in the TypeScript/Angular program including component resources - const referencedFiles = typeScriptProgram - .getSourceFiles() - .filter((sourceFile) => !angularCompiler.ignoreForEmit.has(sourceFile)) - .flatMap((sourceFile) => { - const resourceDependencies = angularCompiler.getResourceDependencies(sourceFile); - componentResourcesDependencies.set(sourceFile.fileName, resourceDependencies); - // Also invalidate Angular diagnostics for a source file if component resources are modified - if (this.#state && hostOptions.modifiedFiles?.size) { - for (const resourceDependency of resourceDependencies) { - if (hostOptions.modifiedFiles.has(resourceDependency)) { - this.#state.diagnosticCache.delete(sourceFile); - // Also mark as affected in case changed template affects diagnostics - affectedFiles.add(sourceFile); + const referencedFiles = [ + ...(extendedConfigFiles ?? []), + ...typeScriptProgram + .getSourceFiles() + .filter((sourceFile) => !angularCompiler.ignoreForEmit.has(sourceFile)) + .flatMap((sourceFile) => { + const resourceDependencies = angularCompiler.getResourceDependencies(sourceFile); + componentResourcesDependencies.set(sourceFile.fileName, resourceDependencies); + // Also invalidate Angular diagnostics for a source file if component resources are modified + if (this.#state && hostOptions.modifiedFiles?.size) { + for (const resourceDependency of resourceDependencies) { + if (hostOptions.modifiedFiles.has(resourceDependency)) { + this.#state.diagnosticCache.delete(sourceFile); + // Also mark as affected in case changed template affects diagnostics + affectedFiles.add(sourceFile); + } } } - } - return [sourceFile.fileName, ...resourceDependencies]; - }); + return [sourceFile.fileName, ...resourceDependencies]; + }), + ]; this.#state = new AngularCompilationState( angularProgram, diff --git a/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts b/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts index 773279e236a9..fc63ee1d0faa 100644 --- a/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts @@ -55,6 +55,7 @@ export class JitCompilation extends TypeScriptCompilation { rootNames, errors: configurationDiagnostics, warnings, + extendedConfigFiles, } = await this.loadConfiguration(tsconfig, compilerOptionOverrides, buildType); if (hostOptions.modifiedFiles) { @@ -89,9 +90,10 @@ export class JitCompilation extends TypeScriptCompilation { createWorkerTransformer(hostOptions.processWebWorker.bind(hostOptions)), ); - const referencedFiles = typeScriptProgram - .getSourceFiles() - .map((sourceFile) => sourceFile.fileName); + const referencedFiles = [ + ...(extendedConfigFiles ?? []), + ...typeScriptProgram.getSourceFiles().map((sourceFile) => sourceFile.fileName), + ]; return { compilerOptions, referencedFiles, warnings }; } diff --git a/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts b/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts index 2595bc60f55a..914c846c7fac 100644 --- a/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts @@ -20,10 +20,14 @@ export interface TransformedConfiguration { rootNames: string[]; errors: ts.Diagnostic[]; warnings: PartialMessage[]; + extendedConfigFiles?: readonly string[]; } export abstract class TypeScriptCompilation extends AngularCompilation { static #angularCompilerCliModule?: typeof ng; + #cachedConfiguration?: TransformedConfiguration; + #cachedRootFiles?: readonly string[]; + readonly #extendedConfigCache = new Map(); static async loadCompilerCli(): Promise { TypeScriptCompilation.#angularCompilerCliModule ??= await import('@angular/compiler-cli'); @@ -36,29 +40,55 @@ export abstract class TypeScriptCompilation extends AngularCompilation { compilerOptionOverrides?: CompilerOptionOverrides, buildType: 'application' | 'library' = 'application', ): Promise { - const { readConfiguration } = await TypeScriptCompilation.loadCompilerCli(); + // When `rootFiles` are explicitly provided (e.g., library builder), avoid re-parsing `tsconfig.json` + // and walking the project directory tree via `readConfiguration` on every watch rebuild (~200-350ms on large libraries). + const currentRootFiles = compilerOptionOverrides?.rootFiles; + if ( + currentRootFiles && + this.#cachedConfiguration && + this.#cachedRootFiles && + this.#cachedRootFiles.length === currentRootFiles.length && + this.#cachedRootFiles.every((file, index) => file === currentRootFiles[index]) + ) { + return this.#cachedConfiguration; + } + + // TODO(alanagius): remove casting when @angular/compiler-cli exports the correct typings. + const { readConfiguration } = (await TypeScriptCompilation.loadCompilerCli()) as typeof ng & { + readConfiguration( + project: string, + existingOptions?: ng.CompilerOptions, + host?: unknown, + extendedConfigCache?: Map, + ): ng.ParsedConfiguration; + }; const { options: originalCompilerOptions, rootNames: originalRootNames, errors, } = profileSync('NG_READ_CONFIG', () => - readConfiguration(tsconfig, { - // Angular specific configuration defaults and overrides to ensure a functioning compilation. - suppressOutputPathCheck: true, - outDir: undefined, - sourceMap: false, - declaration: false, - declarationMap: false, - allowEmptyCodegenFiles: false, - annotationsAs: 'decorators', - enableResourceInlining: false, - supportTestBed: false, - supportJitMode: false, - // Disable removing of comments as TS is quite aggressive with these and can - // remove important annotations, such as /* @__PURE__ */ and comments like /* vite-ignore */. - removeComments: false, - }), + readConfiguration( + tsconfig, + { + // Angular specific configuration defaults and overrides to ensure a functioning compilation. + suppressOutputPathCheck: true, + outDir: undefined, + sourceMap: false, + declaration: false, + declarationMap: false, + allowEmptyCodegenFiles: false, + annotationsAs: 'decorators', + enableResourceInlining: false, + supportTestBed: false, + supportJitMode: false, + // Disable removing of comments as TS is quite aggressive with these and can + // remove important annotations, such as /* @__PURE__ */ and comments like /* vite-ignore */. + removeComments: false, + }, + undefined, + this.#extendedConfigCache, + ), ); let rootNames = originalRootNames; @@ -82,19 +112,53 @@ export abstract class TypeScriptCompilation extends AngularCompilation { buildType, ); - return { + const config: TransformedConfiguration = { compilerOptions, rootNames, errors, warnings, + extendedConfigFiles: [...this.#extendedConfigCache.keys()], }; + + if (config.extendedConfigFiles?.length && currentRootFiles?.length) { + this.#cachedConfiguration = config; + this.#cachedRootFiles = currentRootFiles; + } + + return config; } protected readonly sourceFiles = new Map(); protected invalidateFiles(files: Iterable): void { for (const file of files) { - this.sourceFiles.delete(toPosixPath(file)); + const posixFile = toPosixPath(file); + this.sourceFiles.delete(posixFile); + + if (!this.#extendedConfigCache.size) { + continue; + } + + let cacheKey: string | undefined; + if (this.#extendedConfigCache.has(posixFile)) { + cacheKey = posixFile; + } else { + // Check with lowercased key because TypeScript lowercases the keys + // of the extended config cache on case-insensitive operating systems. + const lowerCasedPosixFile = posixFile.toLowerCase(); + if (this.#extendedConfigCache.has(lowerCasedPosixFile)) { + cacheKey = lowerCasedPosixFile; + } + } + + if (!cacheKey) { + continue; + } + + // If a tsconfig changes, we need to re-read the configuration. + this.#cachedConfiguration = undefined; + this.#cachedRootFiles = undefined; + this.#extendedConfigCache.delete(cacheKey); } } From 40ede4fcd0a4f4c2e3963b03c5812088fec84f26 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:04:39 +0000 Subject: [PATCH 2/2] fixup! perf(@angular/build): cache transformed configuration in TypeScript compilation --- .../src/tools/angular/compilation/angular-compilation_spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts b/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts index 71eacd562259..077a296d0af3 100644 --- a/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts +++ b/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts @@ -158,6 +158,8 @@ describe('AngularCompilation', () => { suppressOutputPathCheck: true, outDir: undefined, }), + undefined, + jasmine.any(Map), ); expect(result.rootNames).toEqual(['/src/main.ts']); expect(result.compilerOptions.target).toBe(ts.ScriptTarget.ES2022);