From 95c28b8ba82bc1cc1aa011d2d4ad06edf440b5c9 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:21:07 +0000 Subject: [PATCH] perf(@angular/build): avoid TS AST eviction and d.ts re-emit on external resource edits Register external stylesheet dependencies on `stylesheetFile ?? containingFile` in the library builder so modifying an external stylesheet or imported SCSS partial does not add the component `.ts` file to `effectiveModifiedFiles` and evict it from `TypeScriptCompilation`'s AST cache. Instead, `getResourceDependencies` maps the modified stylesheet back to the component `.ts` file via `resourceModifiedFiles` while reusing the cached `ts.SourceFile` AST. Additionally, persist a `WeakSet` of emitted declaration files on `AngularCompilationState` across incremental watch builds so that unchanged `ts.SourceFile`s re-emitted solely due to external resource edits (`templateUrl` / `styleUrl`) pass `EmitOnly.Js` to `program.emit`, skipping redundant `.d.ts` generation and downstream declaration processing. --- .../builders/library/pipeline/compilation.ts | 5 +- .../library/tests/behavior/watch_spec.ts | 74 +++++++++++++++++++ .../angular/compilation/aot-compilation.ts | 5 +- 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/packages/angular/build/src/builders/library/pipeline/compilation.ts b/packages/angular/build/src/builders/library/pipeline/compilation.ts index 1c026c5e9fec..163c6194d6ad 100644 --- a/packages/angular/build/src/builders/library/pipeline/compilation.ts +++ b/packages/angular/build/src/builders/library/pipeline/compilation.ts @@ -89,10 +89,7 @@ export async function compileLibrary( : await stylesheetBundler.bundleInline(data, containingFile); if (result.referencedFiles) { - buildState.addFileReferences(containingFile, result.referencedFiles); - if (stylesheetFile) { - buildState.addFileReferences(stylesheetFile, result.referencedFiles); - } + buildState.addFileReferences(stylesheetFile ?? containingFile, result.referencedFiles); } if (result.warnings.length > 0) { diff --git a/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts index 9f85102fe20c..b648c690951a 100644 --- a/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts +++ b/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts @@ -984,5 +984,79 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { outputLogsOnFailure: false }, ); }); + + it('should rebuild when an external Sass partial or template is modified and recover from template errors', async () => { + await harness.writeFiles({ + 'projects/lib/src/lib/_vars.scss': '$primary-color: rgb(10, 20, 30);', + 'projects/lib/src/lib/lib.component.scss': + '@use "./vars"; :host { color: vars.$primary-color; }', + 'projects/lib/src/lib/lib.component.html': '

{{ title }}

', + 'projects/lib/src/lib/lib.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-external-resources', + templateUrl: './lib.component.html', + styleUrl: './lib.component.scss', + }) + export class LibComponent { + title = 'Hello'; + } + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toContain('h1'); + expect(content).toMatch(/color:\s*(?:#0a141e|rgb\(10,\s*20,\s*30\))/); + expect(harness.readFile('dist/lib/types/lib.d.ts')).toContain('LibComponent'); + + // Modify the imported Sass partial + await harness.writeFile( + 'projects/lib/src/lib/_vars.scss', + '$primary-color: rgb(200, 100, 50);', + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toMatch( + /color:\s*(?:#c86432|rgb\(200,\s*100,\s*50\))/, + ); + expect(harness.readFile('dist/lib/types/lib.d.ts')).toContain('LibComponent'); + + // Introduce a template type-checking error in the external HTML template + await harness.writeFile( + 'projects/lib/src/lib/lib.component.html', + '
{{ nonExistentProp }}
', + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('nonExistentProp'), + }), + ); + + // Fix the external HTML template error + await harness.writeFile( + 'projects/lib/src/lib/lib.component.html', + '', + ); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toContain('footer'); + expect(harness.readFile('dist/lib/types/lib.d.ts')).toContain('LibComponent'); + }, + ]); + }); }); }); 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 92684539c2ca..8a3e559c9c69 100644 --- a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts @@ -83,6 +83,7 @@ class AngularCompilationState { public readonly declarationDiagnostics = new WeakMap(), public readonly filesWithoutDtsSignature = new Set(), public readonly useFallbackRootNames = false, + public readonly emittedDtsSourceFiles = new WeakSet(), ) {} get angularCompiler() { @@ -362,6 +363,7 @@ export class AotCompilation extends TypeScriptCompilation { this.#state?.declarationDiagnostics, filesWithoutDtsSignature, useFallbackRootNames, + this.#state?.emittedDtsSourceFiles, ); return { @@ -498,10 +500,10 @@ export class AotCompilation extends TypeScriptCompilation { override emitAffectedFiles(): Iterable { assert(this.#state, 'Angular compilation must be initialized prior to emitting files.'); const { - affectedFiles, angularCompiler, compilerHost, declarationDiagnostics, + emittedDtsSourceFiles, typeScriptProgram, webWorkerTransform, useTypeScriptTranspilation, @@ -512,7 +514,6 @@ export class AotCompilation extends TypeScriptCompilation { const emittedFiles = new Map(); const emittedSourceFiles = new Set(); - const emittedDtsSourceFiles = new Set(); const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => { if (!sourceFiles?.length && filename.endsWith(buildInfoFilename)) { // Save builder info contents to specified location