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..25acd7cc07f2 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,11 +158,48 @@ 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); expect(result.compilerOptions.inlineSources).toBe(true); expect(result.warnings.length).toBeGreaterThan(0); + expect(result.tsConfigFiles).toEqual(['tsconfig.json']); + }); + + it('caches transformed configuration when rootFiles are provided and invalidates on config change', async () => { + const compilation = new MockTypeScriptCompilation(); + const mockReadConfig = jasmine.createSpy('readConfiguration').and.returnValue({ + options: { target: ts.ScriptTarget.ES2020 }, + rootNames: ['/src/main.ts'], + errors: [], + }); + spyOn(TypeScriptCompilation, 'loadCompilerCli').and.resolveTo({ + readConfiguration: mockReadConfig, + } as unknown as typeof import('@angular/compiler-cli')); + + const overrides: CompilerOptionOverrides = { rootFiles: ['/src/main.ts'] }; + + const result1 = await compilation.testLoadConfiguration('tsconfig.json', overrides); + expect(mockReadConfig).toHaveBeenCalledTimes(1); + + // Re-loading with same rootFiles should return cached configuration without calling readConfiguration + const result2 = await compilation.testLoadConfiguration('tsconfig.json', overrides); + expect(mockReadConfig).toHaveBeenCalledTimes(1); + expect(result2).toBe(result1); + + // Invalidation of non-config file should keep cache intact + await compilation.update?.(new Set(['/src/main.ts'])); + const result3 = await compilation.testLoadConfiguration('tsconfig.json', overrides); + expect(mockReadConfig).toHaveBeenCalledTimes(1); + expect(result3).toBe(result1); + + // Invalidation of tsconfig file should clear cache + await compilation.update?.(new Set(['tsconfig.json'])); + const result4 = await compilation.testLoadConfiguration('tsconfig.json', overrides); + expect(mockReadConfig).toHaveBeenCalledTimes(2); + expect(result4).toBeDefined(); }); }); 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..c5b485344416 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, + tsConfigFiles, } = 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 = [ + ...tsConfigFiles, + ...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..ec88987369b0 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, + tsConfigFiles, } = 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 = [ + ...tsConfigFiles, + ...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..86e6237ad8b7 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,15 @@ export interface TransformedConfiguration { rootNames: string[]; errors: ts.Diagnostic[]; warnings: PartialMessage[]; + tsConfigFiles: readonly string[]; } export abstract class TypeScriptCompilation extends AngularCompilation { static #angularCompilerCliModule?: typeof ng; + #cachedConfiguration?: TransformedConfiguration; + #cachedRootFiles?: readonly string[]; + #tsConfigFiles?: Set; + readonly #extendedConfigCache = new Map(); static async loadCompilerCli(): Promise { TypeScriptCompilation.#angularCompilerCliModule ??= await import('@angular/compiler-cli'); @@ -36,6 +41,19 @@ export abstract class TypeScriptCompilation extends AngularCompilation { compilerOptionOverrides?: CompilerOptionOverrides, buildType: 'application' | 'library' = 'application', ): Promise { + // 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; + } + const { readConfiguration } = await TypeScriptCompilation.loadCompilerCli(); const { @@ -43,24 +61,32 @@ export abstract class TypeScriptCompilation extends AngularCompilation { 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, + ), ); + const tsConfigFiles = [toPosixPath(tsconfig), ...this.#extendedConfigCache.keys()]; + this.#tsConfigFiles = new Set(tsConfigFiles); + let rootNames = originalRootNames; if (compilerOptionOverrides?.rootFiles?.length) { const rootFilesSet = new Set( @@ -82,19 +108,55 @@ export abstract class TypeScriptCompilation extends AngularCompilation { buildType, ); - return { + const config: TransformedConfiguration = { compilerOptions, rootNames, errors, warnings, + tsConfigFiles, }; + + if (currentRootFiles?.length) { + this.#cachedConfiguration = config; + this.#cachedRootFiles = currentRootFiles; + } + + return config; } protected readonly sourceFiles = new Map(); protected invalidateFiles(files: Iterable): void { + const tsConfigFiles = this.#tsConfigFiles; + for (const file of files) { - this.sourceFiles.delete(toPosixPath(file)); + const posixFile = toPosixPath(file); + this.sourceFiles.delete(posixFile); + + if (!tsConfigFiles) { + continue; + } + + let tsConfigCachedPath: string | undefined; + if (tsConfigFiles.has(posixFile)) { + tsConfigCachedPath = 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 (tsConfigFiles.has(lowerCasedPosixFile)) { + tsConfigCachedPath = lowerCasedPosixFile; + } + } + + if (!tsConfigCachedPath) { + continue; + } + + // If a tsconfig changes, we need to re-read the configuration. + this.#cachedConfiguration = undefined; + this.#cachedRootFiles = undefined; + this.#extendedConfigCache.delete(tsConfigCachedPath); } }