From 8148520d1d67835fbe2ab2ede0eab598718f9107 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:23:03 +0000 Subject: [PATCH 1/3] perf(@angular/build): cache pre-declaration transformed SourceFiles during AoT emit Cache transformed `SourceFile` ASTs in a `Map` in `ensurePreDeclarationTransformers` instead of only tracking visited files in a `Set`. When `.d.ts`-only emit runs prior to `.js` emit (such as for transitive importers during incremental library builds), the subsequent `.js` emit now reuses the cached transformed `SourceFile` instead of executing `before` transformers a second time. --- .../compilation/angular-compilation_spec.ts | 138 ++++++++++++++++++ .../angular/compilation/aot-compilation.ts | 35 +++-- 2 files changed, 160 insertions(+), 13 deletions(-) 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 58c492ac5214..7cc2047ae634 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 @@ -8,6 +8,7 @@ import ts from 'typescript'; import type { AngularHostOptions } from '../angular-host'; +import { ensurePreDeclarationTransformers } from './aot-compilation'; import { CompilerOptionOverrides, transformCompilerOptions } from './compiler-options'; import { TypeScriptCompilation } from './typescript-compilation'; import { @@ -480,4 +481,141 @@ describe('AngularCompilation', () => { expect(compilerOptions.composite).toBe(false); }); }); + + describe('ensurePreDeclarationTransformers', () => { + function createTestProgram( + fileName: string, + content: string, + ): { + program: ts.Program; + sourceFile: ts.SourceFile; + } { + const options: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + declaration: true, + noLib: true, + }; + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ES2022, true); + const host = ts.createCompilerHost(options); + host.getSourceFile = (name) => (name === fileName ? sourceFile : undefined); + host.fileExists = (name) => name === fileName; + host.readFile = (name) => (name === fileName ? content : undefined); + host.writeFile = () => {}; + + const program = ts.createProgram([fileName], options, host); + + return { program, sourceFile }; + } + + it('transforms each SourceFile only once when emitting .d.ts followed by .js and reuses the cached SourceFile', () => { + const { program, sourceFile } = createTestProgram('/index.ts', 'export const value = 1;'); + + let preDeclStatePopulated = false; + const beforeSpy1 = jasmine + .createSpy('beforeTransformer1') + .and.callFake((sf: ts.SourceFile) => { + preDeclStatePopulated = true; + const addedStmt = ts.factory.createVariableStatement( + [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + 'fromBefore1', + undefined, + undefined, + ts.factory.createNumericLiteral(2), + ), + ], + ts.NodeFlags.Const, + ), + ); + + return ts.factory.updateSourceFile(sf, [...sf.statements, addedStmt]); + }); + + const beforeSpy2 = jasmine + .createSpy('beforeTransformer2') + .and.callFake((sf: ts.SourceFile) => { + const addedStmt = ts.factory.createVariableStatement( + [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + 'fromBefore2', + undefined, + undefined, + ts.factory.createNumericLiteral(3), + ), + ], + ts.NodeFlags.Const, + ), + ); + + return ts.factory.updateSourceFile(sf, [...sf.statements, addedStmt]); + }); + + let sawPopulatedPreDeclState = false; + const afterDeclSpy = jasmine + .createSpy('afterDeclarationsTransformer') + .and.callFake((sf: ts.SourceFile) => { + sawPopulatedPreDeclState = preDeclStatePopulated; + + return sf; + }); + + const transformers: ts.CustomTransformers = { + before: [() => beforeSpy1, () => beforeSpy2], + afterDeclarations: [() => afterDeclSpy], + }; + + ensurePreDeclarationTransformers(transformers); + + const emittedFiles = new Map(); + const writeFile: ts.WriteFileCallback = (fileName, text) => { + emittedFiles.set(fileName, text); + }; + + // Emit .d.ts first (emitOnlyDtsFiles = true) + program.emit(sourceFile, writeFile, undefined, true, transformers); + + expect(sawPopulatedPreDeclState).toBeTrue(); + expect(beforeSpy1).toHaveBeenCalledTimes(1); + expect(beforeSpy2).toHaveBeenCalledTimes(1); + expect(afterDeclSpy).toHaveBeenCalledTimes(1); + expect(emittedFiles.has('/index.d.ts')).toBeTrue(); + expect(emittedFiles.has('/index.js')).toBeFalse(); + + // Emit .js second; before transformers must not execute again and cached AST must be emitted + program.emit(sourceFile, writeFile, undefined, 0 as unknown as boolean, transformers); + + expect(beforeSpy1).toHaveBeenCalledTimes(1); + expect(beforeSpy2).toHaveBeenCalledTimes(1); + expect(emittedFiles.get('/index.js')).toContain('export const fromBefore1 = 2;'); + expect(emittedFiles.get('/index.js')).toContain('export const fromBefore2 = 3;'); + }); + + it('transforms each SourceFile only once when emitting .js and .d.ts together', () => { + const { program, sourceFile } = createTestProgram('/index.ts', 'export const value = 1;'); + + const beforeSpy = jasmine + .createSpy('beforeTransformer') + .and.callFake((sf: ts.SourceFile) => sf); + const afterDeclSpy = jasmine + .createSpy('afterDeclarationsTransformer') + .and.callFake((sf: ts.SourceFile) => sf); + + const transformers: ts.CustomTransformers = { + before: [() => beforeSpy], + afterDeclarations: [() => afterDeclSpy], + }; + + ensurePreDeclarationTransformers(transformers); + + program.emit(sourceFile, () => {}, undefined, false, transformers); + + expect(beforeSpy).toHaveBeenCalledTimes(1); + expect(afterDeclSpy).toHaveBeenCalledTimes(1); + }); + }); }); 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 88171a44fc69..4d92bb44a0e5 100644 --- a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts @@ -657,26 +657,35 @@ export class AotCompilation extends TypeScriptCompilation { * `afterDeclarations`, and caches the transformed `SourceFile` so a subsequent `.js` emit for the * same file does not re-execute `before` transformers. */ -function ensurePreDeclarationTransformers(transformers: ts.CustomTransformers): void { +export function ensurePreDeclarationTransformers(transformers: ts.CustomTransformers): void { if (!transformers.before?.length || !transformers.afterDeclarations?.length) { return; } const beforeTransformers = transformers.before as ts.TransformerFactory[]; - const transformedSourceFiles = new Set(); - transformers.before = beforeTransformers.map((factory, index) => - index === 0 - ? (context) => { - const transform = factory(context); + const transformedSourceFiles = new Map(); + transformers.before = [ + (context) => { + let beforeTransforms: ts.Transformer[] | undefined; - return (sourceFile) => { - transformedSourceFiles.add(ts.getOriginalNode(sourceFile, ts.isSourceFile)); + return (sourceFile) => { + const originalSourceFile = ts.getOriginalNode(sourceFile, ts.isSourceFile); + const cached = transformedSourceFiles.get(originalSourceFile); + if (cached) { + return cached; + } - return transform(sourceFile); - }; + beforeTransforms ??= beforeTransformers.map((factory) => factory(context)); + let current = sourceFile; + for (const transform of beforeTransforms) { + current = transform(current); } - : factory, - ); + transformedSourceFiles.set(originalSourceFile, current); + + return current; + }; + }, + ]; transformers.afterDeclarations = [ (context) => { let beforeTransforms: ts.Transformer[] | undefined; @@ -685,12 +694,12 @@ function ensurePreDeclarationTransformers(transformers: ts.CustomTransformers): if (!ts.isBundle(fileOrBundle)) { const originalSourceFile = ts.getOriginalNode(fileOrBundle, ts.isSourceFile); if (!transformedSourceFiles.has(originalSourceFile)) { - transformedSourceFiles.add(originalSourceFile); beforeTransforms ??= beforeTransformers.map((factory) => factory(context)); let current = originalSourceFile; for (const transform of beforeTransforms) { current = transform(current); } + transformedSourceFiles.set(originalSourceFile, current); } } From 9eaaae0f4d166712d48df88fb0af960e0a31c427 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:02:50 +0000 Subject: [PATCH 2/3] fixup! perf(@angular/build): cache pre-declaration transformed SourceFiles during AoT emit --- .../angular/compilation/aot-compilation.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 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 4d92bb44a0e5..dff4a4319be5 100644 --- a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts @@ -663,24 +663,27 @@ export function ensurePreDeclarationTransformers(transformers: ts.CustomTransfor } const beforeTransformers = transformers.before as ts.TransformerFactory[]; - const transformedSourceFiles = new Map(); + const seenSourceFiles = new Set(); + const preTransformedSourceFiles = new Map(); transformers.before = [ (context) => { let beforeTransforms: ts.Transformer[] | undefined; return (sourceFile) => { const originalSourceFile = ts.getOriginalNode(sourceFile, ts.isSourceFile); - const cached = transformedSourceFiles.get(originalSourceFile); - if (cached) { - return cached; + const preTransformed = preTransformedSourceFiles.get(originalSourceFile); + if (preTransformed !== undefined) { + preTransformedSourceFiles.delete(originalSourceFile); + + return preTransformed; } + seenSourceFiles.add(originalSourceFile); beforeTransforms ??= beforeTransformers.map((factory) => factory(context)); let current = sourceFile; for (const transform of beforeTransforms) { current = transform(current); } - transformedSourceFiles.set(originalSourceFile, current); return current; }; @@ -693,13 +696,14 @@ export function ensurePreDeclarationTransformers(transformers: ts.CustomTransfor return (fileOrBundle) => { if (!ts.isBundle(fileOrBundle)) { const originalSourceFile = ts.getOriginalNode(fileOrBundle, ts.isSourceFile); - if (!transformedSourceFiles.has(originalSourceFile)) { + if (!seenSourceFiles.has(originalSourceFile)) { + seenSourceFiles.add(originalSourceFile); beforeTransforms ??= beforeTransformers.map((factory) => factory(context)); let current = originalSourceFile; for (const transform of beforeTransforms) { current = transform(current); } - transformedSourceFiles.set(originalSourceFile, current); + preTransformedSourceFiles.set(originalSourceFile, current); } } From 88d993829699524311679e840a4f1b61dcb9851c Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:05:07 +0000 Subject: [PATCH 3/3] fixup! perf(@angular/build): cache pre-declaration transformed SourceFiles during AoT emit --- .../angular/compilation/aot-compilation.ts | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 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 dff4a4319be5..68fbfc8ad1a7 100644 --- a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts @@ -665,6 +665,20 @@ export function ensurePreDeclarationTransformers(transformers: ts.CustomTransfor const beforeTransformers = transformers.before as ts.TransformerFactory[]; const seenSourceFiles = new Set(); const preTransformedSourceFiles = new Map(); + const runBefore = ( + beforeTransforms: ts.Transformer[], + sourceFile: ts.SourceFile, + originalSourceFile: ts.SourceFile, + ): ts.SourceFile => { + seenSourceFiles.add(originalSourceFile); + let current = sourceFile; + for (const transform of beforeTransforms) { + current = transform(current); + } + + return current; + }; + transformers.before = [ (context) => { let beforeTransforms: ts.Transformer[] | undefined; @@ -678,14 +692,9 @@ export function ensurePreDeclarationTransformers(transformers: ts.CustomTransfor return preTransformed; } - seenSourceFiles.add(originalSourceFile); beforeTransforms ??= beforeTransformers.map((factory) => factory(context)); - let current = sourceFile; - for (const transform of beforeTransforms) { - current = transform(current); - } - return current; + return runBefore(beforeTransforms, sourceFile, originalSourceFile); }; }, ]; @@ -697,13 +706,11 @@ export function ensurePreDeclarationTransformers(transformers: ts.CustomTransfor if (!ts.isBundle(fileOrBundle)) { const originalSourceFile = ts.getOriginalNode(fileOrBundle, ts.isSourceFile); if (!seenSourceFiles.has(originalSourceFile)) { - seenSourceFiles.add(originalSourceFile); beforeTransforms ??= beforeTransformers.map((factory) => factory(context)); - let current = originalSourceFile; - for (const transform of beforeTransforms) { - current = transform(current); - } - preTransformedSourceFiles.set(originalSourceFile, current); + preTransformedSourceFiles.set( + originalSourceFile, + runBefore(beforeTransforms, originalSourceFile, originalSourceFile), + ); } }