diff --git a/packages/angular/build/src/builders/application/build-action.ts b/packages/angular/build/src/builders/application/build-action.ts index d7dbdc8cfe21..dbec8d687b9f 100644 --- a/packages/angular/build/src/builders/application/build-action.ts +++ b/packages/angular/build/src/builders/application/build-action.ts @@ -81,7 +81,7 @@ export async function* runEsBuildBuildAction( const withProgress: typeof withSpinner = progress ? withSpinner : withNoProgress; // Initial build - let result: ExecutionResult; + let result: ExecutionResult | undefined; try { // Perform the build action result = await withProgress('Building...', () => action()); @@ -89,8 +89,8 @@ export async function* runEsBuildBuildAction( // Log all diagnostic (error/warning/logs) messages await logMessages(logger, result, colors, jsonLogs); } finally { - // Ensure Sass workers are shutdown if not watching - if (!watch) { + // Ensure Sass workers are shutdown if not watching or if the initial build failed + if (!watch || !result) { shutdownSassWorkerPool(); } } diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index 21beb57bf478..faa61c6e523b 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -74,12 +74,19 @@ export async function executeBuild( let bundlingResult: BundleContextResult; let templateUpdates: Map | undefined; let angularCompilationContext: AngularCompilationContext | undefined; + let executionResult: ExecutionResult | undefined; try { if (rebuildState) { bundlerContexts = rebuildState.rebuildContexts; componentStyleBundler = rebuildState.componentStyleBundler; codeBundleCache = rebuildState.codeBundleCache; templateUpdates = rebuildState.templateUpdates; + executionResult = new ExecutionResult( + bundlerContexts, + componentStyleBundler, + codeBundleCache, + templateUpdates, + ); // Reset template updates for new rebuild templateUpdates?.clear(); @@ -122,6 +129,12 @@ export async function executeBuild( angularCompilationContext, templateUpdates, ); + executionResult = new ExecutionResult( + bundlerContexts, + componentStyleBundler, + codeBundleCache, + templateUpdates, + ); // Bundle everything on initial build bundlingResult = await BundlerContext.bundleAll([ @@ -138,234 +151,241 @@ export async function executeBuild( const componentResults = await componentStyleBundler.bundleAllFiles(true, true); bundlingResult = BundlerContext.mergeResults([bundlingResult, ...componentResults]); } - } catch (error) { - await angularCompilationContext?.dispose(); - throw error; - } - const executionResult = new ExecutionResult( - bundlerContexts, - componentStyleBundler, - codeBundleCache, - templateUpdates, - ); - executionResult.addWarnings(bundlingResult.warnings); - - // Add used external component style referenced files to be watched - if (options.externalRuntimeStyles) { - executionResult.extraWatchFiles.push(...componentStyleBundler.collectReferencedFiles()); - } - - // Return if the bundling has errors - if (bundlingResult.errors) { - executionResult.addErrors(bundlingResult.errors); - - return executionResult; - } + executionResult.addWarnings(bundlingResult.warnings); - // Optimize chunks if enabled and threshold is met. - // This pass uses Rollup/Rolldown to further optimize chunks generated by esbuild. - if (options.optimizationOptions.scripts) { - // Count lazy chunks (files not needed for initial load). - // Advanced chunk optimization is most beneficial when there are multiple lazy chunks. - const { metafile, initialFiles } = bundlingResult; - const lazyChunksCount = Object.keys(metafile.outputs).filter( - (path) => path.endsWith('.js') && !initialFiles.has(path), - ).length; - - // Only run if the number of lazy chunks meets the configured threshold. - // This avoids overhead for small projects with few chunks. - if (lazyChunksCount >= optimizeChunksThreshold) { - const { optimizeChunks } = await import('./chunk-optimizer'); - const optimizationResult = await profileAsync('OPTIMIZE_CHUNKS', () => - optimizeChunks( - bundlingResult, - options.sourcemapOptions.scripts ? !options.sourcemapOptions.hidden || 'hidden' : false, - ), - ); + // Add used external component style referenced files to be watched + if (options.externalRuntimeStyles) { + executionResult.extraWatchFiles.push(...componentStyleBundler.collectReferencedFiles()); + } - if (optimizationResult.errors) { - executionResult.addErrors(optimizationResult.errors); + // In non-watch mode, the bundler rebuild contexts and Angular compilation + // are no longer needed once bundling completes. Dispose them early to free + // worker processes, TypeScript ASTs, and caches before post-bundling steps + // execute or before returning bundling errors. + // This is fire-and-forget so worker teardown runs concurrently in the background + // without blocking the critical path of post-bundle optimization steps. + // Any in-flight disposal is awaited in the builder action's finally block. + if (!options.watch) { + void angularCompilationContext?.dispose(); + void executionResult.dispose(); + } - return executionResult; - } + // Return if the bundling has errors + if (bundlingResult.errors) { + executionResult.addErrors(bundlingResult.errors); - bundlingResult = optimizationResult; + return executionResult; } - } - // Analyze external imports if external options are enabled - if (options.externalPackages || bundlingResult.externalConfiguration) { - const { - externalConfiguration = [], - externalImports: { browser = [], server = [] }, - } = bundlingResult; - // Similar to esbuild, --external:@foo/bar automatically implies --external:@foo/bar/*, - // which matches import paths like @foo/bar/baz. - // This means all paths within the @foo/bar package are also marked as external. - const exclusionsPrefixes = externalConfiguration.map((exclusion) => exclusion + '/'); - const exclusions = new Set(externalConfiguration); - const explicitExternal = new Set(); - - const isExplicitExternal = (dep: string): boolean => { - // Locale-data imports are injected by the builder itself (see i18n-locale-plugin) and must stay resolvable - // even when the containing package is externalized by the user. - if (dep.startsWith(`${LOCALE_DATA_BASE_MODULE}/`)) { - return false; - } + // Optimize chunks if enabled and threshold is met. + // This pass uses Rollup/Rolldown to further optimize chunks generated by esbuild. + if (options.optimizationOptions.scripts) { + // Count lazy chunks (files not needed for initial load). + // Advanced chunk optimization is most beneficial when there are multiple lazy chunks. + const { metafile, initialFiles } = bundlingResult; + const lazyChunksCount = Object.keys(metafile.outputs).filter( + (path) => path.endsWith('.js') && !initialFiles.has(path), + ).length; + + // Only run if the number of lazy chunks meets the configured threshold. + // This avoids overhead for small projects with few chunks. + if (lazyChunksCount >= optimizeChunksThreshold) { + const { optimizeChunks } = await import('./chunk-optimizer'); + const optimizationResult = await profileAsync('OPTIMIZE_CHUNKS', () => + optimizeChunks( + bundlingResult, + options.sourcemapOptions.scripts ? !options.sourcemapOptions.hidden || 'hidden' : false, + ), + ); + + if (optimizationResult.errors) { + executionResult.addErrors(optimizationResult.errors); + + return executionResult; + } - if (exclusions.has(dep)) { - return true; + bundlingResult = optimizationResult; } + } - for (const prefix of exclusionsPrefixes) { - if (dep.startsWith(prefix)) { + // Analyze external imports if external options are enabled + if (options.externalPackages || bundlingResult.externalConfiguration) { + const { + externalConfiguration = [], + externalImports: { browser = [], server = [] }, + } = bundlingResult; + // Similar to esbuild, --external:@foo/bar automatically implies --external:@foo/bar/*, + // which matches import paths like @foo/bar/baz. + // This means all paths within the @foo/bar package are also marked as external. + const exclusionsPrefixes = externalConfiguration.map((exclusion) => exclusion + '/'); + const exclusions = new Set(externalConfiguration); + const explicitExternal = new Set(); + + const isExplicitExternal = (dep: string): boolean => { + // Locale-data imports are injected by the builder itself (see i18n-locale-plugin) and must stay resolvable + // even when the containing package is externalized by the user. + if (dep.startsWith(`${LOCALE_DATA_BASE_MODULE}/`)) { + return false; + } + + if (exclusions.has(dep)) { return true; } - } - return false; - }; + for (const prefix of exclusionsPrefixes) { + if (dep.startsWith(prefix)) { + return true; + } + } - const implicitBrowser: string[] = []; - for (const dep of browser) { - if (isExplicitExternal(dep)) { - explicitExternal.add(dep); - } else { - implicitBrowser.push(dep); + return false; + }; + + const implicitBrowser: string[] = []; + for (const dep of browser) { + if (isExplicitExternal(dep)) { + explicitExternal.add(dep); + } else { + implicitBrowser.push(dep); + } } - } - const implicitServer: string[] = []; - for (const dep of server) { - if (isExplicitExternal(dep)) { - explicitExternal.add(dep); - } else { - implicitServer.push(dep); + const implicitServer: string[] = []; + for (const dep of server) { + if (isExplicitExternal(dep)) { + explicitExternal.add(dep); + } else { + implicitServer.push(dep); + } } - } - executionResult.setExternalMetadata(implicitBrowser, implicitServer, [...explicitExternal]); - } + executionResult.setExternalMetadata(implicitBrowser, implicitServer, [...explicitExternal]); + } - const { metafile, initialFiles, outputFiles } = bundlingResult; + const { metafile, initialFiles, outputFiles } = bundlingResult; - executionResult.outputFiles.push(...outputFiles); + executionResult.outputFiles.push(...outputFiles); - // Analyze files for bundle budget failures if present - let budgetFailures: BudgetCalculatorResult[] | undefined; - if (options.budgets) { - const compatStats = generateBudgetStats(metafile, outputFiles, initialFiles); - budgetFailures = [...checkBudgets(options.budgets, compatStats, true)]; - for (const { message, severity } of budgetFailures) { - if (severity === 'error') { - executionResult.addError(message); - } else { - executionResult.addWarning(message); + // Analyze files for bundle budget failures if present + let budgetFailures: BudgetCalculatorResult[] | undefined; + if (options.budgets) { + const compatStats = generateBudgetStats(metafile, outputFiles, initialFiles); + budgetFailures = [...checkBudgets(options.budgets, compatStats, true)]; + for (const { message, severity } of budgetFailures) { + if (severity === 'error') { + executionResult.addError(message); + } else { + executionResult.addWarning(message); + } } } - } - // Calculate estimated transfer size if scripts are optimized - let estimatedTransferSizes; - if (optimizationOptions.scripts || optimizationOptions.styles.minify) { - estimatedTransferSizes = await calculateEstimatedTransferSizes(executionResult.outputFiles); - } - - // Check metafile for CommonJS module usage if optimizing scripts - if (optimizationOptions.scripts) { - const messages = checkCommonJSModules(metafile, options.allowedCommonJsDependencies); - executionResult.addWarnings(messages); - } + // Calculate estimated transfer size if scripts are optimized + let estimatedTransferSizes; + if (optimizationOptions.scripts || optimizationOptions.styles.minify) { + estimatedTransferSizes = await calculateEstimatedTransferSizes(executionResult.outputFiles); + } - // Copy assets - if (assets) { - executionResult.addAssets(await resolveAssets(assets, workspaceRoot)); - } + // Check metafile for CommonJS module usage if optimizing scripts + if (optimizationOptions.scripts) { + const messages = checkCommonJSModules(metafile, options.allowedCommonJsDependencies); + executionResult.addWarnings(messages); + } - // Extract and write licenses for used packages - if (options.extractLicenses) { - executionResult.addOutputFile( - '3rdpartylicenses.txt', - await extractLicenses(metafile, workspaceRoot), - BuildOutputFileType.Root, - ); - } + // Copy assets + if (assets) { + executionResult.addAssets(await resolveAssets(assets, workspaceRoot)); + } - // Watch input index HTML file if configured - if (options.indexHtmlOptions) { - executionResult.extraWatchFiles.push(options.indexHtmlOptions.input); - executionResult.htmlIndexPath = options.indexHtmlOptions.output; - executionResult.htmlBaseHref = options.baseHref; - } + // Extract and write licenses for used packages + if (options.extractLicenses) { + executionResult.addOutputFile( + '3rdpartylicenses.txt', + await extractLicenses(metafile, workspaceRoot), + BuildOutputFileType.Root, + ); + } - // Create server app engine manifest - if (serverEntryPoint) { - executionResult.addOutputFile( - SERVER_APP_ENGINE_MANIFEST_FILENAME, - generateAngularServerAppEngineManifest(i18nOptions, security.allowedHosts, baseHref), - BuildOutputFileType.ServerRoot, - ); - } + // Watch input index HTML file if configured + if (options.indexHtmlOptions) { + executionResult.extraWatchFiles.push(options.indexHtmlOptions.input); + executionResult.htmlIndexPath = options.indexHtmlOptions.output; + executionResult.htmlBaseHref = options.baseHref; + } - // Perform i18n translation inlining if enabled - if (i18nOptions.shouldInline) { - const result = await inlineI18n(metafile, options, executionResult, initialFiles); - executionResult.addErrors(result.errors); - executionResult.addWarnings(result.warnings); - executionResult.addPrerenderedRoutes(result.prerenderedRoutes); - } else { - const result = await executePostBundleSteps( - metafile, - options, - executionResult.outputFiles, - executionResult.assetFiles, - initialFiles, - // Set lang attribute to the defined source locale if present - i18nOptions.hasDefinedSourceLocale ? i18nOptions.sourceLocale : undefined, - ); + // Create server app engine manifest + if (serverEntryPoint) { + executionResult.addOutputFile( + SERVER_APP_ENGINE_MANIFEST_FILENAME, + generateAngularServerAppEngineManifest(i18nOptions, security.allowedHosts, baseHref), + BuildOutputFileType.ServerRoot, + ); + } - // Deduplicate and add errors and warnings - executionResult.addErrors([...new Set(result.errors)]); - executionResult.addWarnings([...new Set(result.warnings)]); + // Perform i18n translation inlining if enabled + if (i18nOptions.shouldInline) { + const result = await inlineI18n(metafile, options, executionResult, initialFiles); + executionResult.addErrors(result.errors); + executionResult.addWarnings(result.warnings); + executionResult.addPrerenderedRoutes(result.prerenderedRoutes); + } else { + const result = await executePostBundleSteps( + metafile, + options, + executionResult.outputFiles, + executionResult.assetFiles, + initialFiles, + // Set lang attribute to the defined source locale if present + i18nOptions.hasDefinedSourceLocale ? i18nOptions.sourceLocale : undefined, + ); - executionResult.addPrerenderedRoutes(result.prerenderedRoutes); - executionResult.outputFiles.push(...result.additionalOutputFiles); - executionResult.assetFiles.push(...result.additionalAssets); - } + // Deduplicate and add errors and warnings + executionResult.addErrors([...new Set(result.errors)]); + executionResult.addWarnings([...new Set(result.warnings)]); - executionResult.addOutputFile( - 'prerendered-routes.json', - JSON.stringify({ routes: executionResult.prerenderedRoutes }, null, 2), - BuildOutputFileType.Root, - ); + executionResult.addPrerenderedRoutes(result.prerenderedRoutes); + executionResult.outputFiles.push(...result.additionalOutputFiles); + executionResult.assetFiles.push(...result.additionalAssets); + } - // Write metafile if stats option is enabled - if (options.stats) { executionResult.addOutputFile( - 'stats.json', - JSON.stringify(metafile, null, 2), + 'prerendered-routes.json', + JSON.stringify({ routes: executionResult.prerenderedRoutes }, null, 2), BuildOutputFileType.Root, ); - } - if (!jsonLogs && !options.quiet) { - const changedFiles = - rebuildState && executionResult.findChangedFiles(rebuildState.previousOutputInfo); - executionResult.addLog( - logBuildStats( - metafile, - outputFiles, - initialFiles, - budgetFailures, - colors, - changedFiles, - estimatedTransferSizes, - !!ssrOptions, - verbose, - ), - ); - } + // Write metafile if stats option is enabled + if (options.stats) { + executionResult.addOutputFile( + 'stats.json', + JSON.stringify(metafile, null, 2), + BuildOutputFileType.Root, + ); + } + + if (!jsonLogs && !options.quiet) { + const changedFiles = + rebuildState && executionResult.findChangedFiles(rebuildState.previousOutputInfo); + executionResult.addLog( + logBuildStats( + metafile, + outputFiles, + initialFiles, + budgetFailures, + colors, + changedFiles, + estimatedTransferSizes, + !!ssrOptions, + verbose, + ), + ); + } - return executionResult; + return executionResult; + } catch (error) { + await Promise.allSettled([angularCompilationContext?.dispose(), executionResult?.dispose()]); + + throw error; + } } diff --git a/packages/angular/build/src/builders/application/tests/options/index_spec.ts b/packages/angular/build/src/builders/application/tests/options/index_spec.ts index 11228658bbce..b53467caaec6 100644 --- a/packages/angular/build/src/builders/application/tests/options/index_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/index_spec.ts @@ -79,16 +79,17 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { harness.expectFile('dist/browser/index.html').content.toContain('TEST_123'); }); - // TODO: Build needs to be fixed to not throw an unhandled exception for this case - xit('should fail build when a string path to non-existent file', async () => { + it('should fail build and terminate cleanly when a string path to non-existent file is provided', async () => { harness.useTarget('build', { ...BASE_OPTIONS, index: 'src/not-here.html', }); - const { result } = await harness.executeOnce({ outputLogsOnFailure: false }); + const { result, error } = await harness.executeOnce({ outputLogsOnException: false }); - expect(result?.success).toBe(false); + expect(result).toBeUndefined(); + expect(error).toEqual(jasmine.any(Error)); + expect(error?.message).toMatch(/Failed to read index HTML file/i); harness.expectFile('dist/browser/index.html').toNotExist(); }); 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 79fb9715bdbd..286411a06057 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compilation-state.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compilation-state.ts @@ -46,13 +46,14 @@ export class AngularCompilationContext { this.#pendingCompilation = true; } - #disposed = false; + #disposal: Promise | undefined; - async dispose(): Promise { - if (this.#disposed) { - return; - } - this.#disposed = true; + dispose(): Promise { + // Reuse any in progress disposal to ensure all callers can await completion + return (this.#disposal ??= this.#close()); + } + + async #close(): Promise { this.markAsReady(true); try { await this.#compilation.close?.(); diff --git a/packages/angular/build/src/tools/esbuild/angular/source-file-cache.ts b/packages/angular/build/src/tools/esbuild/angular/source-file-cache.ts index 2bc38bd12a89..a408650a4f4f 100644 --- a/packages/angular/build/src/tools/esbuild/angular/source-file-cache.ts +++ b/packages/angular/build/src/tools/esbuild/angular/source-file-cache.ts @@ -25,6 +25,19 @@ export class SourceFileCache extends Map { super(); } + /** + * Releases all cached content. The cached data is only needed for incremental + * rebuilds and can include the emitted contents of every TypeScript file in the + * program. The cache is repopulated if a build is performed after this is called. + */ + override clear(): void { + super.clear(); + this.modifiedFiles.clear(); + this.typeScriptFileCache.clear(); + this.loadResultCache.clear(); + this.referencedFiles = undefined; + } + invalidate(files: Iterable): boolean { if (files !== this.modifiedFiles) { this.modifiedFiles.clear(); diff --git a/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts b/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts index bebfd6a3eedd..53e17e7e427c 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts @@ -194,11 +194,21 @@ export class ExecutionResult { return changed; } - async dispose(): Promise { - await Promise.allSettled([ - ...this.rebuildContexts.typescriptContexts.map((context) => context.dispose()), - ...this.rebuildContexts.otherContexts.map((context) => context.dispose()), - this.componentStyleBundler.dispose(), - ]); + #disposal?: Promise; + + dispose(): Promise { + return (this.#disposal ??= this.#dispose()); + } + + async #dispose(): Promise { + try { + await Promise.allSettled([ + ...this.rebuildContexts.typescriptContexts.map((context) => context.dispose()), + ...this.rebuildContexts.otherContexts.map((context) => context.dispose()), + this.componentStyleBundler.dispose(), + ]); + } finally { + this.codeBundleCache?.clear(); + } } } diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index af13226a68d4..a6d4b5aeccbb 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -252,8 +252,8 @@ export class I18nInliner { * Stops all active transformation tasks and shuts down all workers. * @returns A void promise that resolves when closing is complete. */ - close(): Promise { - return this.#workerPool.destroy(); + async close(): Promise { + await Promise.allSettled([this.#cache?.close(), this.#workerPool.destroy()]); } /** diff --git a/packages/angular/build/src/tools/esbuild/load-result-cache.ts b/packages/angular/build/src/tools/esbuild/load-result-cache.ts index 30067486a384..3abb7bc4c852 100644 --- a/packages/angular/build/src/tools/esbuild/load-result-cache.ts +++ b/packages/angular/build/src/tools/esbuild/load-result-cache.ts @@ -90,4 +90,9 @@ export class MemoryLoadResultCache implements LoadResultCache { // are namespaced request paths and not disk-based file paths. return [...this.#fileDependencies.keys()]; } + + clear(): void { + this.#loadResults.clear(); + this.#fileDependencies.clear(); + } }