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', }; });