From 3acb7768317bb05a9cd73fa64e081b5ca0326189 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 29 May 2024 07:47:24 -0400 Subject: [PATCH] perf(@angular/build): use direct transpilation with isolated modules When using the application builder and the TypeScript `isolatedModules` option is enabled and script sourcemaps are disabled, TypeScript code will be transpiled via the bundler instead of the current behavior of using TypeScript. The use of the `isolatedModules` option ensures that TypeScript code can be safely transpiled without the need for the type-checker. This mode of operation has several advantages. The bundler (esbuild in this case) will know have knowledge of the TypeScript code constructs, such as enums, and can optimize the output code based on that knowledge including inlining both const and regular enums where possible. Additionally, this allows for the removal of the babel-based optimization passes for all TypeScript code. These passes are still present for all JavaScript code such as from third-party libraries/packages. These advantages lead to an improvement in build time, especially in production configurations. To ensure optimal output code size in this setup, the `useDefineForClassFields` TypeScript option should either be removed or set to `true` which enables ECMAScript standard compliant behavior. Initial testing reduced a warm production build of a newly generated project from ~2.3 seconds to ~2.0 seconds. --- .../typescript-isolated-modules_spec.ts | 51 ++++++++++++ .../angular/compilation/aot-compilation.ts | 79 ++++++++++++++++--- .../angular/compilation/parallel-worker.ts | 8 +- .../tools/esbuild/angular/compiler-plugin.ts | 19 +++-- 4 files changed, 140 insertions(+), 17 deletions(-) create mode 100644 packages/angular/build/src/builders/application/tests/behavior/typescript-isolated-modules_spec.ts diff --git a/packages/angular/build/src/builders/application/tests/behavior/typescript-isolated-modules_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-isolated-modules_spec.ts new file mode 100644 index 000000000000..738e454adb01 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-isolated-modules_spec.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "TypeScript isolated modules direct transpilation"', () => { + it('should successfully build with isolated modules enabled and disabled optimizations', async () => { + // Enable tsconfig isolatedModules option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.isolatedModules = true; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + + it('should successfully build with isolated modules enabled and enabled optimizations', async () => { + // Enable tsconfig isolatedModules option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.isolatedModules = true; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/angular/compilation/aot-compilation.ts b/packages/angular/build/src/tools/esbuild/angular/compilation/aot-compilation.ts index 3b073e5fee6b..4b7e168a74f7 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compilation/aot-compilation.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compilation/aot-compilation.ts @@ -199,9 +199,12 @@ export class AotCompilation extends AngularCompilation { emitAffectedFiles(): Iterable { assert(this.#state, 'Angular compilation must be initialized prior to emitting files.'); - const { angularCompiler, compilerHost, typeScriptProgram, webWorkerTransform } = this.#state; - const buildInfoFilename = - typeScriptProgram.getCompilerOptions().tsBuildInfoFile ?? '.tsbuildinfo'; + const { affectedFiles, angularCompiler, compilerHost, typeScriptProgram, webWorkerTransform } = + this.#state; + const compilerOptions = typeScriptProgram.getCompilerOptions(); + const buildInfoFilename = compilerOptions.tsBuildInfoFile ?? '.tsbuildinfo'; + const useTypeScriptTranspilation = + !compilerOptions.isolatedModules || !!compilerOptions.sourceMap; const emittedFiles = new Map(); const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => { @@ -228,11 +231,33 @@ export class AotCompilation extends AngularCompilation { ); transformers.before.push(webWorkerTransform); - // TypeScript will loop until there are no more affected files in the program - while ( - typeScriptProgram.emitNextAffectedFile(writeFileCallback, undefined, undefined, transformers) - ) { - /* empty */ + // Emit is handled in write file callback when using TypeScript + if (useTypeScriptTranspilation) { + // TypeScript will loop until there are no more affected files in the program + while ( + typeScriptProgram.emitNextAffectedFile( + writeFileCallback, + undefined, + undefined, + transformers, + ) + ) { + /* empty */ + } + } else if (compilerOptions.tsBuildInfoFile) { + // Manually get the builder state for the persistent cache + // The TypeScript API currently embeds this behavior inside the program emit + // via emitNextAffectedFile but that also applies all internal transforms. + const programWithGetState = typeScriptProgram.getProgram() as ts.Program & { + emitBuildInfo(writeFileCallback?: ts.WriteFileCallback): void; + }; + + assert( + typeof programWithGetState.emitBuildInfo === 'function', + 'TypeScript program emitBuildInfo is missing.', + ); + + programWithGetState.emitBuildInfo(); } // Angular may have files that must be emitted but TypeScript does not consider affected @@ -245,11 +270,45 @@ export class AotCompilation extends AngularCompilation { continue; } - if (angularCompiler.incrementalCompilation.safeToSkipEmit(sourceFile)) { + if ( + angularCompiler.incrementalCompilation.safeToSkipEmit(sourceFile) && + !affectedFiles.has(sourceFile) + ) { + continue; + } + + if (useTypeScriptTranspilation) { + typeScriptProgram.emit(sourceFile, writeFileCallback, undefined, undefined, transformers); continue; } - typeScriptProgram.emit(sourceFile, writeFileCallback, undefined, undefined, transformers); + // When not using TypeScript transpilation, directly apply only Angular specific transformations + const transformResult = ts.transform( + sourceFile, + [ + ...(transformers.before ?? []), + ...(transformers.after ?? []), + ] as ts.TransformerFactory[], + compilerOptions, + ); + + assert( + transformResult.transformed.length === 1, + 'TypeScript transforms should not produce multiple outputs for ' + sourceFile.fileName, + ); + + let contents; + if (sourceFile === transformResult.transformed[0]) { + // Use original content if no changes were made + contents = sourceFile.text; + } else { + // Otherwise, print the transformed source file + const printer = ts.createPrinter(compilerOptions, transformResult); + contents = printer.printFile(transformResult.transformed[0]); + } + + angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile); + emittedFiles.set(sourceFile, { filename: sourceFile.fileName, contents }); } return emittedFiles.values(); diff --git a/packages/angular/build/src/tools/esbuild/angular/compilation/parallel-worker.ts b/packages/angular/build/src/tools/esbuild/angular/compilation/parallel-worker.ts index 4ed510d6d269..b388805c7e2a 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compilation/parallel-worker.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compilation/parallel-worker.ts @@ -94,8 +94,12 @@ export async function initialize(request: InitRequest) { return { referencedFiles, - // TODO: Expand? `allowJs` is the only field needed currently. - compilerOptions: { allowJs: compilerOptions.allowJs }, + // TODO: Expand? `allowJs`, `isolatedModules`, `sourceMap` are the only fields needed currently. + compilerOptions: { + allowJs: compilerOptions.allowJs, + isolatedModules: compilerOptions.isolatedModules, + sourceMap: compilerOptions.sourceMap, + }, }; } diff --git a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts index 9aa391e8331d..3ad1a8c456fc 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts @@ -101,6 +101,8 @@ export function createCompilerPlugin( // Determines if TypeScript should process JavaScript files based on tsconfig `allowJs` option let shouldTsIgnoreJs = true; + // Determines if transpilation should be handle by TypeScript or esbuild + let useTypeScriptTranspilation = true; // Track incremental component stylesheet builds const stylesheetBundler = new ComponentStylesheetBundler( @@ -250,6 +252,11 @@ export function createCompilerPlugin( createCompilerOptionsTransformer(setupWarnings, pluginOptions, preserveSymlinks), ); shouldTsIgnoreJs = !initializationResult.compilerOptions.allowJs; + // Isolated modules option ensures safe non-TypeScript transpilation. + // Typescript printing support for sourcemaps is not yet integrated. + useTypeScriptTranspilation = + !initializationResult.compilerOptions.isolatedModules || + !!initializationResult.compilerOptions.sourceMap; referencedFiles = initializationResult.referencedFiles; } catch (error) { (result.errors ??= []).push({ @@ -335,9 +342,10 @@ export function createCompilerPlugin( const request = path.normalize( pluginOptions.fileReplacements?.[path.normalize(args.path)] ?? args.path, ); + const isJS = /\.[cm]?js$/.test(request); // Skip TS load attempt if JS TypeScript compilation not enabled and file is JS - if (shouldTsIgnoreJs && /\.[cm]?js$/.test(request)) { + if (shouldTsIgnoreJs && isJS) { return undefined; } @@ -356,7 +364,7 @@ export function createCompilerPlugin( // No TS result indicates the file is not part of the TypeScript program. // If allowJs is enabled and the file is JS then defer to the next load hook. - if (!shouldTsIgnoreJs && /\.[cm]?js$/.test(request)) { + if (!shouldTsIgnoreJs && isJS) { return undefined; } @@ -366,8 +374,9 @@ export function createCompilerPlugin( createMissingFileError(request, args.path, build.initialOptions.absWorkingDir ?? ''), ], }; - } else if (typeof contents === 'string') { - // A string indicates untransformed output from the TS/NG compiler + } else if (typeof contents === 'string' && (useTypeScriptTranspilation || isJS)) { + // A string indicates untransformed output from the TS/NG compiler. + // This step is unneeded when using esbuild transpilation. const sideEffects = await hasSideEffects(request); contents = await javascriptTransformer.transformData( request, @@ -382,7 +391,7 @@ export function createCompilerPlugin( return { contents, - loader: 'js', + loader: useTypeScriptTranspilation || isJS ? 'js' : 'ts', }; });