Skip to content

Commit

Permalink
perf(@angular/build): use direct transpilation with isolated modules
Browse files Browse the repository at this point in the history
When using the application builder and the TypeScript `isolatedModules`
option is enabled, 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.
  • Loading branch information
clydin committed May 30, 2024
1 parent e5cf3c8 commit ea8e2fc
Show file tree
Hide file tree
Showing 4 changed files with 127 additions and 17 deletions.
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,11 @@ export class AotCompilation extends AngularCompilation {

emitAffectedFiles(): Iterable<EmitFileResult> {
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;

const emittedFiles = new Map<ts.SourceFile, EmitFileResult>();
const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => {
Expand All @@ -228,11 +230,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
Expand All @@ -245,11 +269,37 @@ 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<ts.SourceFile>[],
compilerOptions,
);
const printer = ts.createPrinter(compilerOptions, transformResult);

assert(
transformResult.transformed.length === 1,
'TypeScript transforms should not produce multiple outputs for ' + sourceFile.fileName,
);
const contents = printer.printFile(transformResult.transformed[0]);

angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile);
emittedFiles.set(sourceFile, { filename: sourceFile.fileName, contents });
}

return emittedFiles.values();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,11 @@ 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` are the only field needed currently.
compilerOptions: {
allowJs: compilerOptions.allowJs,
isolatedModules: compilerOptions.isolatedModules,
},
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,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(
Expand Down Expand Up @@ -249,6 +251,8 @@ export function createCompilerPlugin(
createCompilerOptionsTransformer(setupWarnings, pluginOptions, preserveSymlinks),
);
shouldTsIgnoreJs = !initializationResult.compilerOptions.allowJs;
// Isolated modules option ensures safe non-TypeScript transpilation
useTypeScriptTranspilation = !initializationResult.compilerOptions.isolatedModules;
referencedFiles = initializationResult.referencedFiles;
} catch (error) {
(result.errors ??= []).push({
Expand Down Expand Up @@ -334,9 +338,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;
}

Expand All @@ -355,7 +360,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;
}

Expand All @@ -365,8 +370,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,
Expand All @@ -381,7 +387,7 @@ export function createCompilerPlugin(

return {
contents,
loader: 'js',
loader: useTypeScriptTranspilation || isJS ? 'js' : 'ts',
};
});

Expand Down

0 comments on commit ea8e2fc

Please sign in to comment.