diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index d751eb7d298e..c326d277ec61 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -8,6 +8,7 @@ import { BuilderContext } from '@angular-devkit/architect'; import { createAngularCompilation } from '../../tools/angular/compilation'; +import { AngularCompilationContext } from '../../tools/esbuild/angular/compilation-state'; import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache'; import { generateBudgetStats } from '../../tools/esbuild/budget-stats'; import { BundleContextResult, BundlerContext } from '../../tools/esbuild/bundler-context'; @@ -71,61 +72,74 @@ export async function executeBuild( let codeBundleCache; let bundlingResult: BundleContextResult; let templateUpdates: Map | undefined; - if (rebuildState) { - bundlerContexts = rebuildState.rebuildContexts; - componentStyleBundler = rebuildState.componentStyleBundler; - codeBundleCache = rebuildState.codeBundleCache; - templateUpdates = rebuildState.templateUpdates; - // Reset template updates for new rebuild - templateUpdates?.clear(); - - const allFileChanges = rebuildState.fileChanges.all; - - // Bundle all contexts that do not require TypeScript changed file checks. - // These will automatically use cached results based on the changed files. - bundlingResult = await BundlerContext.bundleAll(bundlerContexts.otherContexts, allFileChanges); - - // Check the TypeScript code bundling cache for changes. If invalid, force a rebundle of - // all TypeScript related contexts. - const forceTypeScriptRebuild = codeBundleCache?.invalidate(allFileChanges); - const typescriptResults: BundleContextResult[] = []; - for (const typescriptContext of bundlerContexts.typescriptContexts) { - typescriptContext.invalidate(allFileChanges); - const result = await typescriptContext.bundle(forceTypeScriptRebuild); - typescriptResults.push(result); - } - bundlingResult = BundlerContext.mergeResults([bundlingResult, ...typescriptResults]); - } else { - const target = transformSupportedBrowsersToTargets(browsers); - codeBundleCache = new SourceFileCache(cacheOptions.enabled ? cacheOptions.path : undefined); - componentStyleBundler = createComponentStyleBundler(options, target); - if (options.templateUpdates) { - templateUpdates = new Map(); - } - bundlerContexts = setupBundlerContexts( - options, - target, - codeBundleCache, - componentStyleBundler, - // Create new reusable compilation for the appropriate mode based on the `jit` plugin option - await createAngularCompilation(!!options.jit, !options.serverEntryPoint), - templateUpdates, - ); + let angularCompilationContext: AngularCompilationContext | undefined; + try { + if (rebuildState) { + bundlerContexts = rebuildState.rebuildContexts; + componentStyleBundler = rebuildState.componentStyleBundler; + codeBundleCache = rebuildState.codeBundleCache; + templateUpdates = rebuildState.templateUpdates; + // Reset template updates for new rebuild + templateUpdates?.clear(); + + const allFileChanges = rebuildState.fileChanges.all; + + // Bundle all contexts that do not require TypeScript changed file checks. + // These will automatically use cached results based on the changed files. + bundlingResult = await BundlerContext.bundleAll( + bundlerContexts.otherContexts, + allFileChanges, + ); - // Bundle everything on initial build - bundlingResult = await BundlerContext.bundleAll([ - ...bundlerContexts.typescriptContexts, - ...bundlerContexts.otherContexts, - ]); - } + // Check the TypeScript code bundling cache for changes. If invalid, force a rebundle of + // all TypeScript related contexts. + const forceTypeScriptRebuild = codeBundleCache?.invalidate(allFileChanges); + const typescriptResults: BundleContextResult[] = []; + for (const typescriptContext of bundlerContexts.typescriptContexts) { + typescriptContext.invalidate(allFileChanges); + const result = await typescriptContext.bundle(forceTypeScriptRebuild); + typescriptResults.push(result); + } + bundlingResult = BundlerContext.mergeResults([bundlingResult, ...typescriptResults]); + } else { + const target = transformSupportedBrowsersToTargets(browsers); + codeBundleCache = new SourceFileCache(cacheOptions.enabled ? cacheOptions.path : undefined); + componentStyleBundler = createComponentStyleBundler(options, target); + if (options.templateUpdates) { + templateUpdates = new Map(); + } + const angularCompilation = await createAngularCompilation( + !!options.jit, + !options.serverEntryPoint, + ); + angularCompilationContext = new AngularCompilationContext(angularCompilation); + bundlerContexts = setupBundlerContexts( + options, + target, + codeBundleCache, + componentStyleBundler, + angularCompilationContext, + templateUpdates, + ); - // Update any external component styles if enabled and rebuilding. - // TODO: Only attempt rebundling of invalidated styles once incremental build results are supported. - if (rebuildState && options.externalRuntimeStyles) { - componentStyleBundler.invalidate(rebuildState.fileChanges.all); + // Bundle everything on initial build + bundlingResult = await BundlerContext.bundleAll([ + ...bundlerContexts.typescriptContexts, + ...bundlerContexts.otherContexts, + ]); + } + + // Update any external component styles if enabled and rebuilding. + // TODO: Only attempt rebundling of invalidated styles once incremental build results are supported. + if (rebuildState && options.externalRuntimeStyles) { + componentStyleBundler.invalidate(rebuildState.fileChanges.all); - const componentResults = await componentStyleBundler.bundleAllFiles(true, true); - bundlingResult = BundlerContext.mergeResults([bundlingResult, ...componentResults]); + const componentResults = await componentStyleBundler.bundleAllFiles(true, true); + bundlingResult = BundlerContext.mergeResults([bundlingResult, ...componentResults]); + } + } catch (error) { + await angularCompilationContext?.dispose(); + throw error; } const executionResult = new ExecutionResult( diff --git a/packages/angular/build/src/builders/application/setup-bundling.ts b/packages/angular/build/src/builders/application/setup-bundling.ts index 9b47bc67e49d..1f3fbe56445d 100644 --- a/packages/angular/build/src/builders/application/setup-bundling.ts +++ b/packages/angular/build/src/builders/application/setup-bundling.ts @@ -6,9 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ -import { AngularCompilation } from '../../tools/angular/compilation'; +import type { AngularCompilationContext } from '../../tools/esbuild/angular/compilation-state'; import { ComponentStylesheetBundler } from '../../tools/esbuild/angular/component-stylesheets'; -import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache'; +import type { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache'; import { createBrowserCodeBundleOptions, createBrowserPolyfillBundleOptions, @@ -20,7 +20,7 @@ import { BundlerContext } from '../../tools/esbuild/bundler-context'; import { createGlobalScriptsBundleOptions } from '../../tools/esbuild/global-scripts'; import { createGlobalStylesBundleOptions } from '../../tools/esbuild/global-styles'; import { getSupportedNodeTargets } from '../../tools/esbuild/utils'; -import { NormalizedApplicationBuildOptions } from './options'; +import type { NormalizedApplicationBuildOptions } from './options'; /** * Generates one or more BundlerContext instances based on the builder provided @@ -35,7 +35,7 @@ export function setupBundlerContexts( target: string[], codeBundleCache: SourceFileCache, stylesheetBundler: ComponentStylesheetBundler, - angularCompilation: AngularCompilation, + angularCompilationContext: AngularCompilationContext, templateUpdates: Map | undefined, ): { typescriptContexts: BundlerContext[]; @@ -63,7 +63,7 @@ export function setupBundlerContexts( target, codeBundleCache, stylesheetBundler, - angularCompilation, + angularCompilationContext, templateUpdates, ), ), @@ -75,6 +75,7 @@ export function setupBundlerContexts( target, codeBundleCache, stylesheetBundler, + angularCompilationContext.createSecondaryContext(), ); if (browserPolyfillBundleOptions) { const browserPolyfillContext = new BundlerContext( @@ -117,7 +118,13 @@ export function setupBundlerContexts( new BundlerContext( workspaceRoot, watch, - createServerMainCodeBundleOptions(options, nodeTargets, codeBundleCache, stylesheetBundler), + createServerMainCodeBundleOptions( + options, + nodeTargets, + codeBundleCache, + stylesheetBundler, + angularCompilationContext.createSecondaryContext(), + ), ), ); @@ -127,7 +134,13 @@ export function setupBundlerContexts( new BundlerContext( workspaceRoot, watch, - createSsrEntryCodeBundleOptions(options, nodeTargets, codeBundleCache, stylesheetBundler), + createSsrEntryCodeBundleOptions( + options, + nodeTargets, + codeBundleCache, + stylesheetBundler, + angularCompilationContext.createSecondaryContext(), + ), ), ); } diff --git a/packages/angular/build/src/tools/esbuild/angular/compilation-state.ts b/packages/angular/build/src/tools/esbuild/angular/compilation-state.ts index 79b46313f1ec..79fb9715bdbd 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compilation-state.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compilation-state.ts @@ -6,12 +6,23 @@ * found in the LICENSE file at https://angular.dev/license */ -export class SharedTSCompilationState { +import { type AngularCompilation, NoopCompilation } from '../../angular/compilation'; + +export class AngularCompilationContext { + #compilation: AngularCompilation; #pendingCompilation = true; #resolveCompilationReady: ((value: boolean) => void) | undefined; #compilationReadyPromise: Promise | undefined; #hasErrors = true; + constructor(compilation: AngularCompilation) { + this.#compilation = compilation; + } + + get compilation(): AngularCompilation { + return this.#compilation; + } + get waitUntilReady(): Promise { if (!this.#pendingCompilation) { return Promise.resolve(this.#hasErrors); @@ -35,14 +46,44 @@ export class SharedTSCompilationState { this.#pendingCompilation = true; } - dispose(): void { + #disposed = false; + + async dispose(): Promise { + if (this.#disposed) { + return; + } + this.#disposed = true; this.markAsReady(true); - globalSharedCompilationState = undefined; + try { + await this.#compilation.close?.(); + } catch { + // Suppress closure errors to avoid unhandled rejections during teardown. + } + } + + createSecondaryContext(): AngularCompilationContext { + return new SecondaryCompilationContext(this); } } -let globalSharedCompilationState: SharedTSCompilationState | undefined; +class SecondaryCompilationContext extends AngularCompilationContext { + constructor(private primaryContext: AngularCompilationContext) { + super(new NoopCompilation()); + } -export function getSharedCompilationState(): SharedTSCompilationState { - return (globalSharedCompilationState ??= new SharedTSCompilationState()); + override get waitUntilReady(): Promise { + return this.primaryContext.waitUntilReady; + } + + override markAsReady(hasErrors: boolean): void { + // No-op: secondary contexts do not control compilation state + } + + override markAsInProgress(): void { + // No-op: secondary contexts do not control compilation state + } + + override async dispose(): Promise { + // No-op for secondary context to avoid disposing the primary compilation worker + } } 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 1bcb8c40500a..55818229c523 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts @@ -27,7 +27,7 @@ import { AngularCompilation, DiagnosticModes, NoopCompilation } from '../../angu import { JavaScriptTransformer } from '../javascript-transformer'; import { LoadResultCache, createCachedLoad } from '../load-result-cache'; import { logCumulativeDurations, profileAsync, resetCumulativeDurations } from '../profiling'; -import { SharedTSCompilationState, getSharedCompilationState } from './compilation-state'; +import { AngularCompilationContext } from './compilation-state'; import { ComponentStylesheetBundler } from './component-stylesheets'; import { FileReferenceTracker } from './file-reference-tracker'; import { setupJitPluginCallbacks } from './jit-plugin-callbacks'; @@ -61,7 +61,10 @@ export interface CompilerPluginOptions { // eslint-disable-next-line max-lines-per-function export function createCompilerPlugin( pluginOptions: CompilerPluginOptions, - compilationOrFactory: AngularCompilation | (() => Promise), + compilationContextOrCompilation: + | AngularCompilationContext + | AngularCompilation + | (() => Promise), stylesheetBundler: ComponentStylesheetBundler, ): Plugin { return { @@ -111,10 +114,15 @@ export function createCompilerPlugin( // The factory is only relevant for compatibility purposes with the private API. // TODO: Update private API in the next major to allow compilation function factory removal here. - const compilation = - typeof compilationOrFactory === 'function' - ? await compilationOrFactory() - : compilationOrFactory; + const angularCompilationContext = + compilationContextOrCompilation instanceof AngularCompilationContext + ? compilationContextOrCompilation + : new AngularCompilationContext( + typeof compilationContextOrCompilation === 'function' + ? await compilationContextOrCompilation() + : compilationContextOrCompilation, + ); + const compilation: AngularCompilation = angularCompilationContext.compilation; // The in-memory cache of TypeScript file outputs will be used during the build in `onLoad` callbacks for TS files. // A string value indicates direct TS/NG output and a Uint8Array indicates fully transformed code. @@ -136,17 +144,12 @@ export function createCompilerPlugin( // Determines if transpilation should be handle by TypeScript or esbuild let useTypeScriptTranspilation = true; - let sharedTSCompilationState: SharedTSCompilationState | undefined; - // To fully invalidate files, track resource referenced files and their referencing source const referencedFileTracker = new FileReferenceTracker(); // eslint-disable-next-line max-lines-per-function build.onStart(async () => { - sharedTSCompilationState = getSharedCompilationState(); - if (!(compilation instanceof NoopCompilation)) { - sharedTSCompilationState.markAsInProgress(); - } + angularCompilationContext.markAsInProgress(); const result: OnStartResult = { warnings: setupWarnings, @@ -348,7 +351,7 @@ export function createCompilerPlugin( } if (compilation instanceof NoopCompilation) { - hasCompilationErrors = await sharedTSCompilationState.waitUntilReady; + hasCompilationErrors = await angularCompilationContext.waitUntilReady; return result; } @@ -417,7 +420,7 @@ export function createCompilerPlugin( // Reset the setup warnings so that they are only shown during the first build. setupWarnings = undefined; - sharedTSCompilationState.markAsReady(hasCompilationErrors); + angularCompilationContext.markAsReady(hasCompilationErrors); return result; }); @@ -576,7 +579,7 @@ export function createCompilerPlugin( build.onEnd((result) => { // Ensure other compilations are unblocked if the main compilation throws during start - sharedTSCompilationState?.markAsReady(hasCompilationErrors); + angularCompilationContext.markAsReady(hasCompilationErrors); for (const { outputFiles, metafile } of additionalResults.values()) { // Add any additional output files to the main output files @@ -598,8 +601,7 @@ export function createCompilerPlugin( }); build.onDispose(() => { - sharedTSCompilationState?.dispose(); - void compilation.close?.(); + void angularCompilationContext.dispose(); void javascriptTransformer.close(); void cacheStore?.close(); }); diff --git a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts index adb14dc2d737..37ff846c7400 100644 --- a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts @@ -20,6 +20,7 @@ import { SERVER_GENERATED_EXTERNALS, } from '../../utils/server-rendering/manifest'; import { AngularCompilation, NoopCompilation } from '../angular/compilation'; +import { AngularCompilationContext } from './angular/compilation-state'; import { createCompilerPlugin } from './angular/compiler-plugin'; import { ComponentStylesheetBundler } from './angular/component-stylesheets'; import { SourceFileCache } from './angular/source-file-cache'; @@ -42,7 +43,7 @@ export function createBrowserCodeBundleOptions( target: string[], sourceFileCache: SourceFileCache, stylesheetBundler: ComponentStylesheetBundler, - angularCompilation: AngularCompilation, + angularCompilationContext: AngularCompilationContext, templateUpdates: Map | undefined, ): BundlerOptionsFactory { return (loadCache) => { @@ -77,7 +78,7 @@ export function createBrowserCodeBundleOptions( createCompilerPlugin( // JS/TS options pluginOptions, - angularCompilation, + angularCompilationContext, // Component stylesheet bundler stylesheetBundler, ), @@ -96,6 +97,7 @@ export function createBrowserPolyfillBundleOptions( target: string[], sourceFileCache: SourceFileCache, stylesheetBundler: ComponentStylesheetBundler, + angularCompilationContext: AngularCompilationContext, ): BuildOptions | BundlerOptionsFactory | undefined { const namespace = 'angular:polyfills'; const polyfillBundleOptions = getEsBuildCommonPolyfillsOptions( @@ -138,8 +140,7 @@ export function createBrowserPolyfillBundleOptions( createCompilerPlugin( // JS/TS options pluginOptions, - // Browser compilation handles the actual Angular code compilation - new NoopCompilation(), + angularCompilationContext, // Component stylesheet options are unused for polyfills but required by the plugin stylesheetBundler, ), @@ -238,6 +239,7 @@ export function createServerMainCodeBundleOptions( target: string[], sourceFileCache: SourceFileCache, stylesheetBundler: ComponentStylesheetBundler, + angularCompilationContext: AngularCompilationContext, ): BundlerOptionsFactory { const { serverEntryPoint: mainServerEntryPoint, @@ -288,8 +290,7 @@ export function createServerMainCodeBundleOptions( createCompilerPlugin( // JS/TS options pluginOptions, - // Browser compilation handles the actual Angular code compilation - new NoopCompilation(), + angularCompilationContext, // Component stylesheet bundler stylesheetBundler, ), @@ -381,6 +382,7 @@ export function createSsrEntryCodeBundleOptions( target: string[], sourceFileCache: SourceFileCache, stylesheetBundler: ComponentStylesheetBundler, + angularCompilationContext: AngularCompilationContext, ): BundlerOptionsFactory { const { workspaceRoot, ssrOptions, externalPackages } = options; const serverEntryPoint = ssrOptions?.entry; @@ -432,8 +434,7 @@ export function createSsrEntryCodeBundleOptions( createCompilerPlugin( // JS/TS options pluginOptions, - // Browser compilation handles the actual Angular code compilation - new NoopCompilation(), + angularCompilationContext, // Component stylesheet bundler stylesheetBundler, ),