Skip to content

Commit fe96bc9

Browse files
committed
perf(@angular/build): enable fast-path AST printing with sourcemaps in AotCompilation
Previously, when sourcemaps were enabled, AotCompilation was forced to run the full, slow TypeScript compilation/emit path (`useTypeScriptTranspilation = true`) because the standard `ts.createPrinter().printFile` method does not support generating sourcemaps. This commit leverages TypeScript's internal `createTextWriter`, `createSourceMapGenerator`, and `printer.writeFile` APIs to generate sourcemaps directly during AST printing. This allows AotCompilation to use the fast AST transformation and printing path even when sourcemaps are enabled (as long as `isolatedModules` is true), drastically improving rebuild performance for dev server builds.
1 parent 92da0d6 commit fe96bc9

4 files changed

Lines changed: 205 additions & 14 deletions

File tree

packages/angular/build/src/tools/angular/compilation/aot-compilation.ts

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { lazyRoutesTransformer } from '../transformers/lazy-routes-transformer';
2121
import { createWorkerTransformer } from '../transformers/web-worker-transformer';
2222
import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation';
2323
import { collectHmrCandidates } from './hmr-candidates';
24+
import { printSourceFileWithMap } from './typescript-printer';
2425

2526
/**
2627
* The modified files count limit for performing component HMR analysis.
@@ -37,6 +38,7 @@ class AngularCompilationState {
3738
public readonly affectedFiles: ReadonlySet<ts.SourceFile>,
3839
public readonly templateDiagnosticsOptimization: ng.OptimizeFor,
3940
public readonly webWorkerTransform: ts.TransformerFactory<ts.SourceFile>,
41+
public readonly useTypeScriptTranspilation: boolean,
4042
public readonly diagnosticCache = new WeakMap<ts.SourceFile, ts.Diagnostic[]>(),
4143
) {}
4244

@@ -76,6 +78,10 @@ export class AotCompilation extends AngularCompilation {
7678
const compilerOptions =
7779
compilerOptionsTransformer?.(originalCompilerOptions) ?? originalCompilerOptions;
7880

81+
const useTypeScriptTranspilation =
82+
(compilerOptions['_useTypeScriptTranspilation'] as boolean | undefined) ??
83+
!compilerOptions.isolatedModules;
84+
7985
if (compilerOptions.externalRuntimeStyles) {
8086
hostOptions.externalStylesheets ??= new Map();
8187
}
@@ -209,6 +215,7 @@ export class AotCompilation extends AngularCompilation {
209215
affectedFiles,
210216
affectedFiles.size === 1 ? OptimizeFor.SingleFile : OptimizeFor.WholeProgram,
211217
createWorkerTransformer(hostOptions.processWebWorker.bind(hostOptions)),
218+
useTypeScriptTranspilation,
212219
this.#state?.diagnosticCache,
213220
);
214221

@@ -297,14 +304,16 @@ export class AotCompilation extends AngularCompilation {
297304

298305
emitAffectedFiles(): Iterable<EmitFileResult> {
299306
assert(this.#state, 'Angular compilation must be initialized prior to emitting files.');
300-
const { affectedFiles, angularCompiler, compilerHost, typeScriptProgram, webWorkerTransform } =
301-
this.#state;
307+
const {
308+
affectedFiles,
309+
angularCompiler,
310+
compilerHost,
311+
typeScriptProgram,
312+
webWorkerTransform,
313+
useTypeScriptTranspilation,
314+
} = this.#state;
302315
const compilerOptions = typeScriptProgram.getCompilerOptions();
303316
const buildInfoFilename = compilerOptions.tsBuildInfoFile ?? '.tsbuildinfo';
304-
const useTypeScriptTranspilation =
305-
!compilerOptions.isolatedModules ||
306-
!!compilerOptions.sourceMap ||
307-
!!compilerOptions.inlineSourceMap;
308317

309318
const emittedFiles = new Map<ts.SourceFile, EmitFileResult>();
310319
const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => {
@@ -401,14 +410,31 @@ export class AotCompilation extends AngularCompilation {
401410
'TypeScript transforms should not produce multiple outputs for ' + sourceFile.fileName,
402411
);
403412

404-
let contents;
413+
let contents: string;
405414
if (sourceFile === transformResult.transformed[0]) {
406415
// Use original content if no changes were made
407416
contents = sourceFile.text;
408417
} else {
409-
// Otherwise, print the transformed source file
418+
// Otherwise, print the transformed source file with map if needed
410419
const printer = ts.createPrinter(compilerOptions, transformResult);
411-
contents = printer.printFile(transformResult.transformed[0]);
420+
const printResult = printSourceFileWithMap(
421+
transformResult.transformed[0],
422+
printer,
423+
compilerHost,
424+
compilerOptions,
425+
);
426+
427+
contents = printResult.code;
428+
429+
if (printResult.map) {
430+
if (compilerOptions.inlineSourceMap) {
431+
const base64Map = Buffer.from(printResult.map).toString('base64');
432+
contents += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map}`;
433+
} else if (compilerOptions.sourceMap) {
434+
const mapFilename = sourceFile.fileName + '.map';
435+
emittedFiles.set(sourceFile, { filename: mapFilename, contents: printResult.map });
436+
}
437+
}
412438
}
413439

414440
angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile);

packages/angular/build/src/tools/angular/compilation/parallel-worker.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ export async function initialize(request: InitRequest) {
114114
isolatedModules: compilerOptions.isolatedModules,
115115
sourceMap: compilerOptions.sourceMap,
116116
inlineSourceMap: compilerOptions.inlineSourceMap,
117+
_useTypeScriptTranspilation: compilerOptions['_useTypeScriptTranspilation'] as
118+
boolean | undefined,
117119
},
118120
componentResourcesDependencies,
119121
};
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
/**
10+
* @fileoverview Helper functions and typings for utilizing internal TypeScript printer
11+
* and sourcemap generation APIs.
12+
*/
13+
14+
import assert from 'node:assert';
15+
import { dirname } from 'node:path';
16+
import ts from 'typescript';
17+
18+
/**
19+
* Partial interface representing the internal TypeScript `EmitTextWriter` API.
20+
* Used to collect printed text output from the printer.
21+
*/
22+
export interface EmitTextWriter {
23+
write(s: string): void;
24+
rawWrite(s: string): void;
25+
writeLine(force?: boolean): void;
26+
getText(): string;
27+
clear(): void;
28+
isAtStartOfLine(): boolean;
29+
getTextPos(): number;
30+
writeComment(s: string): void;
31+
}
32+
33+
/**
34+
* Partial interface representing the internal TypeScript `SourceMapGenerator` API.
35+
* Used to construct mappings and serialize a sourcemap.
36+
*/
37+
export interface SourceMapGenerator {
38+
getSources(): string[];
39+
addSource(fileName: string): number;
40+
setSourceContent(sourceIndex: number, content: string | null): void;
41+
addMapping(
42+
generatedLine: number,
43+
generatedCharacter: number,
44+
sourceIndex: number,
45+
sourceLine: number,
46+
sourceCharacter: number,
47+
nameIndex?: number,
48+
): void;
49+
toJSON(): object;
50+
toString(): string;
51+
}
52+
53+
/**
54+
* Extended `ts.Printer` interface containing the internal `writeFile` method
55+
* which supports sourcemap generation.
56+
*/
57+
export interface ExtendedPrinter extends ts.Printer {
58+
writeFile(
59+
sourceFile: ts.SourceFile,
60+
writer: EmitTextWriter,
61+
sourceMapGenerator?: SourceMapGenerator,
62+
): void;
63+
}
64+
65+
/**
66+
* Typing structure for internal TypeScript module exports that are not exposed
67+
* in the public `@types/typescript` package.
68+
*/
69+
export interface TypeScriptInternals {
70+
createTextWriter(newLine: string): EmitTextWriter;
71+
createSourceMapGenerator(
72+
host: {
73+
getCurrentDirectory(): string;
74+
getCanonicalFileName(fileName: string): string;
75+
},
76+
file: string,
77+
sourceRoot: string | undefined,
78+
sourcesDirectoryPath: string,
79+
generatorOptions: ts.CompilerOptions,
80+
): SourceMapGenerator;
81+
}
82+
83+
const tsInternals = ts as unknown as TypeScriptInternals;
84+
85+
/**
86+
* Asserts that the required internal TypeScript APIs are present in the currently
87+
* loaded TypeScript module.
88+
*
89+
* @throws {AssertionError} If any required internal API is missing.
90+
*/
91+
export function assertTypeScriptPrinterInternals(): void {
92+
assert(
93+
typeof tsInternals.createTextWriter === 'function',
94+
'TypeScript internal "createTextWriter" is missing.',
95+
);
96+
assert(
97+
typeof tsInternals.createSourceMapGenerator === 'function',
98+
'TypeScript internal "createSourceMapGenerator" is missing.',
99+
);
100+
}
101+
102+
/**
103+
* Result object returned from printing a source file.
104+
*/
105+
export interface PrintResult {
106+
/** The printed source code text. */
107+
code: string;
108+
109+
/** The generated sourcemap JSON string, if sourcemap generation was requested. */
110+
map?: string;
111+
}
112+
113+
/**
114+
* Prints a TypeScript source file AST to a string, optionally generating a sourcemap
115+
* using internal TypeScript APIs.
116+
*
117+
* @param sourceFile The TypeScript AST node representing the file to print.
118+
* @param printer The printer instance to print the file with.
119+
* @param compilerHost The TypeScript compiler host, used for path canonicalization and context.
120+
* @param compilerOptions The compiler options configured for the build target.
121+
* @returns A result containing the printed code and optional sourcemap text.
122+
*/
123+
export function printSourceFileWithMap(
124+
sourceFile: ts.SourceFile,
125+
printer: ts.Printer,
126+
compilerHost: ts.CompilerHost,
127+
compilerOptions: ts.CompilerOptions,
128+
): PrintResult {
129+
const shouldGenerateMap = compilerOptions.sourceMap || compilerOptions.inlineSourceMap;
130+
if (!shouldGenerateMap) {
131+
return { code: printer.printFile(sourceFile) };
132+
}
133+
134+
assertTypeScriptPrinterInternals();
135+
136+
const extendedPrinter = printer as ExtendedPrinter;
137+
assert(
138+
typeof extendedPrinter.writeFile === 'function',
139+
'TypeScript Printer is missing internal "writeFile" method.',
140+
);
141+
142+
const writer = tsInternals.createTextWriter(compilerHost.getNewLine());
143+
144+
const sourceMapGenerator = tsInternals.createSourceMapGenerator(
145+
{
146+
getCurrentDirectory: () => compilerHost.getCurrentDirectory(),
147+
getCanonicalFileName: (fileName) => compilerHost.getCanonicalFileName(fileName),
148+
},
149+
sourceFile.fileName,
150+
compilerOptions.sourceRoot,
151+
dirname(sourceFile.fileName),
152+
compilerOptions,
153+
);
154+
155+
extendedPrinter.writeFile(sourceFile, writer, sourceMapGenerator);
156+
157+
const code = writer.getText();
158+
const map = sourceMapGenerator.toString();
159+
160+
return { code, map };
161+
}

packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -318,12 +318,8 @@ export function createCompilerPlugin(
318318
),
319319
);
320320
shouldTsIgnoreJs = !initializationResult.compilerOptions.allowJs;
321-
// Isolated modules option ensures safe non-TypeScript transpilation.
322-
// Typescript printing support for sourcemaps is not yet integrated.
323321
useTypeScriptTranspilation =
324-
!initializationResult.compilerOptions.isolatedModules ||
325-
!!initializationResult.compilerOptions.sourceMap ||
326-
!!initializationResult.compilerOptions.inlineSourceMap;
322+
!!initializationResult.compilerOptions['_useTypeScriptTranspilation'];
327323
referencedFiles = initializationResult.referencedFiles;
328324
externalStylesheets = initializationResult.externalStylesheets;
329325
if (initializationResult.templateUpdates) {
@@ -753,6 +749,12 @@ function createCompilerOptionsTransformer(
753749
preserveSymlinks,
754750
externalRuntimeStyles: pluginOptions.externalRuntimeStyles,
755751
_enableHmr: !!pluginOptions.templateUpdates,
752+
// TypeScript transpilation is forced if:
753+
// - isolatedModules is disabled (TS needs full module types to emit JS).
754+
// - Karma code coverage is active (the coverage instrumentation transformer is Babel-based
755+
// and cannot parse raw TypeScript code; Vitest handles coverage instrumentation downstream).
756+
_useTypeScriptTranspilation:
757+
!compilerOptions.isolatedModules || !!pluginOptions.instrumentForCoverage,
756758
supportTestBed: !!pluginOptions.includeTestMetadata,
757759
supportJitMode: !!pluginOptions.includeTestMetadata,
758760
};

0 commit comments

Comments
 (0)