From bfc906e9382ae9c07dce9b0ffb4ef54d820b02ef Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:29:56 -0400 Subject: [PATCH 1/4] refactor(@angular/build): support maxConcurrency option in JavaScriptTransformer Aligns the JavaScriptTransformer concurrency configuration and semantics with the I18nInliner API. An optional maxConcurrency property is added to JavaScriptTransformerOptions with validation to ensure it is an integer greater than or equal to 1. The required positional maxThreads parameter is removed from the constructor, allowing concurrency to default to the available worker pool threads when omitted. The previous concurrency multiplier that allowed up to double the worker count in active transformation tasks has also been removed. With the significantly faster oxc-based transformation pipeline, individual file transformations complete in milliseconds, eliminating the need for deep I/O task pre-buffering. Bounding active tasks directly to maxConcurrency also prevents task bursts from prematurely forcing the worker pool to expand to its maximum thread count. The minThreads pinning in WorkerPool creation is removed so that Piscina defaults to a single initial thread instead of eagerly allocating all workers. --- .../src/builders/dev-server/vite/index.ts | 3 +- .../tools/esbuild/angular/compiler-plugin.ts | 2 +- .../tools/esbuild/javascript-transformer.ts | 32 +- .../esbuild/javascript-transformer_spec.ts | 347 +++++++++--------- 4 files changed, 188 insertions(+), 196 deletions(-) diff --git a/packages/angular/build/src/builders/dev-server/vite/index.ts b/packages/angular/build/src/builders/dev-server/vite/index.ts index 8c8f23c3a339..4800559a2171 100644 --- a/packages/angular/build/src/builders/dev-server/vite/index.ts +++ b/packages/angular/build/src/builders/dev-server/vite/index.ts @@ -186,8 +186,7 @@ export async function* serveWithVite( // Always enable JIT linking to support applications built with and without AOT. // In a development environment the additional scope information does not // have a negative effect unlike production where final output size is relevant. - { sourcemap: true, jit: true, thirdPartySourcemaps }, - 1, + { sourcemap: true, jit: true, thirdPartySourcemaps, maxConcurrency: 1 }, ); // The index HTML path will be updated from the build results if provided by the builder 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 92af13dfe497..091e6953527b 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts @@ -112,8 +112,8 @@ export function createCompilerPlugin( thirdPartySourcemaps: pluginOptions.thirdPartySourcemaps, advancedOptimizations: pluginOptions.advancedOptimizations, jit: pluginOptions.jit || pluginOptions.includeTestMetadata, + maxConcurrency: maxTransformWorkers, }, - maxTransformWorkers, cacheStore?.createCache('jstransformer'), ); diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts index 7cd4ffd1bf3f..de5e7035657c 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts @@ -118,6 +118,12 @@ export interface JavaScriptTransformerOptions { thirdPartySourcemaps?: boolean; advancedOptimizations?: boolean; jit?: boolean; + + /** + * The maximum number of concurrent transformation operations. + * When omitted, concurrency defaults to the available worker pool threads. + */ + maxConcurrency?: number; } /** @@ -146,7 +152,7 @@ export interface TransformOptions { */ export class JavaScriptTransformer { #workerPool: WorkerPool | undefined; - #commonOptions: Required; + #commonOptions: Required>; #fileCacheKeyBase: Uint8Array; /** Queue of pending transformation tasks waiting for an active concurrency slot. */ @@ -155,16 +161,21 @@ export class JavaScriptTransformer { /** Current count of actively executing transformation tasks. */ #activeTasks = 0; - /** Maximum number of transformation tasks allowed to execute concurrently. */ - #maxConcurrent: number; + get #maxConcurrency(): number { + return this.options.maxConcurrency ?? (this.#workerPool?.maxThreads || 1); + } constructor( - options: JavaScriptTransformerOptions, - readonly maxThreads: number, + private readonly options: JavaScriptTransformerOptions, private readonly cache?: Cache, ) { - // Maintain 2 active tasks per worker thread to keep transformation pipelines fully saturated - this.#maxConcurrent = Math.max(1, maxThreads * 2); + if ( + options.maxConcurrency !== undefined && + (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency < 1) + ) { + throw new RangeError('options.maxConcurrency must be an integer greater than or equal to 1.'); + } + // Extract options to ensure only the named options are serialized and sent to the worker const { sourcemap, @@ -189,7 +200,7 @@ export class JavaScriptTransformer { * @returns A promise resolving to the transformation result. */ async #runWithThrottle(action: () => Promise): Promise { - if (this.#activeTasks >= this.#maxConcurrent) { + if (this.#activeTasks >= this.#maxConcurrency) { await new Promise((resolve, reject) => { this.#pendingTasks.push({ resolve, reject }); }); @@ -216,9 +227,10 @@ export class JavaScriptTransformer { const workerPoolOptions: WorkerPoolOptions = { filename: require.resolve('./javascript-transformer-worker'), - maxThreads: this.maxThreads, - minThreads: this.maxThreads, workerData: this.#commonOptions, + ...(this.options.maxConcurrency !== undefined && { + maxThreads: this.options.maxConcurrency, + }), }; // Prevent passing SSR `--import` (loader-hooks) from parent to child worker. diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts index b818077be49b..94d671ef2a72 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts @@ -27,13 +27,11 @@ describe('JavaScriptTransformer sourcemaps', () => { } it('should remap correctly when only advanced optimizations are applied', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: true, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputMap = { version: 3, @@ -57,13 +55,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should remap correctly when only linking is applied', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: true, - thirdPartySourcemaps: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: true, + thirdPartySourcemaps: true, + maxConcurrency: 1, + }); const inputMap = { version: 3, @@ -100,14 +96,12 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should defer and chain remapping when both linking and advanced optimizations are applied', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: true, - thirdPartySourcemaps: true, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: true, + thirdPartySourcemaps: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputMap = { version: 3, @@ -145,13 +139,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should produce a valid sourcemap when no input sourcemap is present', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: true, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); const input = 'export class MyClass { static ɵprov = 42; }'; const result = await transformer.transformData('src/app.js', input, { skipLinker: true }); @@ -166,12 +158,10 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should remap correctly when coverage instrumentation is applied with an input sourcemap', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: true, + maxConcurrency: 1, + }); const inputMap = { version: 3, @@ -198,13 +188,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should defer and chain remapping when coverage instrumentation and advanced optimizations are applied', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: true, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputMap = { version: 3, @@ -231,13 +219,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should accept a Uint8Array input in transformData', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: true, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from('export class MyClass { static ɵprov = 42; }', 'utf-8'); const result = await transformer.transformData('src/app.js', inputBuffer, { skipLinker: true }); @@ -251,12 +237,10 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should strip trailing sourcemap comments from Uint8Array input on fast-path', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from( 'console.log("hello");\n//# sourceMappingURL=app.js.map', @@ -271,12 +255,10 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should return Uint8Array input untouched on fast-path when no sourcemap comment is present', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from('console.log("hello");\nconst x = 1;', 'utf-8'); const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, { @@ -287,12 +269,10 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should return Uint8Array untouched when skipLinker is false but file contains no linker declarations', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from('console.log("no linking required");\nconst x = 1;', 'utf-8'); const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, { @@ -303,12 +283,10 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should bypass worker and skip linking for @angular/core and @angular/compiler paths', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from('export const ɵɵngDeclareDirective = () => {};', 'utf-8'); const result = await transformer.transformData( @@ -321,12 +299,10 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should bypass worker and skip linking for TypeScript file extensions (.ts, .tsx, .mts, .cts)', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from('export const ɵɵngDeclareDirective = () => {};', 'utf-8'); @@ -340,12 +316,10 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should not exclude packages with similar prefixes such as @angular/compiler-cli', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); const input = ` import * as i0 from "@angular/core"; @@ -371,13 +345,11 @@ describe('JavaScriptTransformer sourcemaps', () => { describe('advanced optimizations fast-path pre-filter', () => { it('should bypass worker and return input buffer directly when no candidate tokens are present', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from( 'function add(a, b) { return a + b; }\nconst result = add(1, 2);', @@ -391,13 +363,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should bypass worker for standard classes without static properties', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from( `export class UserService { @@ -414,13 +384,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should bypass worker for default exports without static properties or Angular metadata', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from( `export default class UserService { @@ -437,13 +405,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should bypass worker for classes with static members when no Angular metadata is present', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from('export class MyComponent { static prop = 42; }', 'utf-8'); const result = await transformer.transformData('src/component.js', inputBuffer, { @@ -454,13 +420,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should dispatch to worker when Angular tokens are present', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); const input = 'export class MyService { static ɵprov = true; }'; const result = await transformer.transformData('src/service.js', input, { skipLinker: true }); @@ -470,13 +434,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should dispatch to worker when decorator tokens are present and sideEffects is false', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from('const MyClass = __decorate([], class {});', 'utf-8'); const result = await transformer.transformData('src/class.js', inputBuffer, { @@ -488,13 +450,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should bypass worker and return converted buffer when no candidate tokens are present in string input', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputString = 'function multiply(a, b) { return a * b; }'; const result = await transformer.transformData('src/math.js', inputString, { @@ -505,13 +465,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should dispatch to worker when candidate tokens are present in string input', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputString = 'export class MyService { static ɵprov = true; }'; const result = await transformer.transformData('src/service.js', inputString, { @@ -523,13 +481,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should not query sideEffects when no candidate tokens are present', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); let queried = false; const inputBuffer = Buffer.from('function add(a, b) { return a + b; }', 'utf-8'); @@ -546,13 +502,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should not query sideEffects when primary tokens are present', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); let queried = false; const input = 'export class MyService { static ɵprov = true; }'; @@ -569,13 +523,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should query sideEffects for @angular/ packages and dispatch to worker when sideEffects is false', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); let queryCount = 0; const inputBuffer = Buffer.from('export const foo = someCall();', 'utf-8'); @@ -597,13 +549,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should query sideEffects for decorator tokens and evaluate at most once', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); let queryCount = 0; const inputBuffer = Buffer.from('const MyClass = __decorate([], class {});', 'utf-8'); @@ -621,13 +571,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should query sideEffects and wrap decorators when both primary and decorator tokens are present', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); let queryCount = 0; const input = ` @@ -650,13 +598,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should query sideEffects when both primary and decorator tokens are present in Buffer', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); let queryCount = 0; const inputBuffer = Buffer.from( @@ -678,4 +624,39 @@ describe('JavaScriptTransformer sourcemaps', () => { expect(text).toContain('__decorate'); }); }); + + describe('maxConcurrency', () => { + it('should throw RangeError if maxConcurrency is less than 1', () => { + expect(() => { + new JavaScriptTransformer({ sourcemap: false, maxConcurrency: 0 }); + }).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + + expect(() => { + new JavaScriptTransformer({ sourcemap: false, maxConcurrency: -1 }); + }).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + }); + + it('should throw RangeError if maxConcurrency is not an integer', () => { + expect(() => { + new JavaScriptTransformer({ sourcemap: false, maxConcurrency: 1.5 }); + }).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + }); + + it('should allow omitting maxConcurrency', async () => { + transformer = new JavaScriptTransformer({ sourcemap: false }); + const result = await transformer.transformData('src/app.js', 'const x = 1;', { + skipLinker: true, + }); + expect(result).toBeDefined(); + }); + }); }); From 59a9bc21fee956088bc0907ed86fd253dddd0294 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:59:12 -0400 Subject: [PATCH 2/4] refactor(@angular/build): ensure transformData respects concurrency throttle Ensures that direct calls to transformData are bounded by the concurrency throttle. Previously, transformData bypassed the semaphore and directly dispatched tasks to the worker pool. A private #transform method now contains the core transformation logic, allowing both transformFile and transformData to use the throttle without double-throttling or unbounded worker dispatch. --- .../build/src/tools/esbuild/javascript-transformer.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts index de5e7035657c..049934830eac 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts @@ -255,7 +255,7 @@ export class JavaScriptTransformer { return this.#runWithThrottle(async () => { const data = await readFile(filename); - return this.transformData(filename, data, options); + return this.#transform(filename, data, options); }); } @@ -271,6 +271,14 @@ export class JavaScriptTransformer { filename: string, data: string | Uint8Array, options?: TransformOptions, + ): Promise { + return this.#runWithThrottle(() => this.#transform(filename, data, options)); + } + + async #transform( + filename: string, + data: string | Uint8Array, + options?: TransformOptions, ): Promise { let resolvedSideEffects: boolean | undefined; let sideEffectsQueried = false; From ec953cffc6fb9b59d8f70fac2bac4456e1d3d4a4 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:22:18 -0400 Subject: [PATCH 3/4] refactor(@angular/build): budget JavaScript transformation worker concurrency during bundling JavaScript transformation tasks with the OXC linker are very fast (roughly 10ms per file), whereas worker thread startup in Node.js costs around 140ms per thread. Concurrently, esbuild utilizes all available CPU cores for bundling. Budgeting transformation concurrency to a quarter of available cores balances parallel processing capacity with thread startup costs and avoids starving esbuild. A minimum of 1 ensures transformation progress on small or constrained environments, while an upper bound of 6 accommodates large builds on many-core systems without inducing thread scheduling overhead or excessive memory footprint. --- .../tools/esbuild/angular/compiler-plugin.ts | 10 +-- .../build/src/utils/environment-options.ts | 10 +++ .../src/utils/environment-options_spec.ts | 67 +++++++++++++++++++ 3 files changed, 78 insertions(+), 9 deletions(-) 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 091e6953527b..00c82b328085 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts @@ -20,11 +20,7 @@ import type { import assert from 'node:assert'; import { readFile } from 'node:fs/promises'; import * as path from 'node:path'; -import { - hasCustomMaxWorkers, - maxWorkers, - useTypeChecking, -} from '../../../utils/environment-options'; +import { maxTransformWorkers, useTypeChecking } from '../../../utils/environment-options'; import { calculateHash, initializeHash } from '../../../utils/hash'; import { AngularHostOptions } from '../../angular/angular-host'; import { AngularCompilation, DiagnosticModes } from '../../angular/compilation'; @@ -102,10 +98,6 @@ export function createCompilerPlugin( }); } } - // During bundling, esbuild runs its own multi-threaded Go process across all available cores. - // Unless explicitly configured via NG_BUILD_MAX_WORKERS, cap transformation concurrency to at - // most 4 to prevent CPU contention during bundling. - const maxTransformWorkers = hasCustomMaxWorkers ? maxWorkers : Math.min(4, maxWorkers); const javascriptTransformer = new JavaScriptTransformer( { sourcemap: !!pluginOptions.sourcemap, diff --git a/packages/angular/build/src/utils/environment-options.ts b/packages/angular/build/src/utils/environment-options.ts index 1e225d6b7386..29faf3e7bbae 100644 --- a/packages/angular/build/src/utils/environment-options.ts +++ b/packages/angular/build/src/utils/environment-options.ts @@ -131,6 +131,16 @@ export const hasCustomMaxWorkers = customMaxWorkers !== undefined; */ export const maxWorkers = customMaxWorkers ?? Math.max(availableParallelism() - 1, 1); +/** + * The maximum number of workers to use for JavaScript transformations during bundling. + * Transformation tasks are short-lived, and esbuild concurrently utilizes all CPU cores + * for bundling. To prevent CPU starvation and thread startup overhead, concurrency is + * budgeted to a fraction of available cores, capped at 6, unless overridden by + * `NG_BUILD_MAX_WORKERS`. + */ +export const maxTransformWorkers = + customMaxWorkers ?? Math.max(1, Math.min(6, Math.floor(availableParallelism() / 4))); + /** * When `NG_BUILD_PARALLEL_TS` is set to `0` or `false`, parallel TypeScript compilation is disabled. */ diff --git a/packages/angular/build/src/utils/environment-options_spec.ts b/packages/angular/build/src/utils/environment-options_spec.ts index 0d2ac7fa0c61..4146bec8bec1 100644 --- a/packages/angular/build/src/utils/environment-options_spec.ts +++ b/packages/angular/build/src/utils/environment-options_spec.ts @@ -114,3 +114,70 @@ describe('environment options - maxWorkers', () => { expect(maxWorkers).toBe(8); }); }); + +describe('environment options - maxTransformWorkers', () => { + const originalEnvValue = process.env['NG_BUILD_MAX_WORKERS']; + + function loadEnvironmentOptions(): typeof import('./environment-options') { + delete require.cache[require.resolve('./environment-options')]; + + return require('./environment-options'); + } + + afterEach(() => { + if (originalEnvValue !== undefined) { + process.env['NG_BUILD_MAX_WORKERS'] = originalEnvValue; + } else { + delete process.env['NG_BUILD_MAX_WORKERS']; + } + delete require.cache[require.resolve('./environment-options')]; + }); + + it('defaults maxTransformWorkers to availableParallelism / 4 (bounded between 1 and 6) when NG_BUILD_MAX_WORKERS is unset', () => { + delete process.env['NG_BUILD_MAX_WORKERS']; + const { maxTransformWorkers } = loadEnvironmentOptions(); + + const expected = Math.max(1, Math.min(6, Math.floor(availableParallelism() / 4))); + expect(maxTransformWorkers).toBe(expected); + }); + + it('uses configured positive integer when NG_BUILD_MAX_WORKERS is set', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '8'; + const { maxTransformWorkers } = loadEnvironmentOptions(); + + expect(maxTransformWorkers).toBe(8); + }); + + it('allows maxTransformWorkers greater than 6 when explicitly configured', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '32'; + const { maxTransformWorkers } = loadEnvironmentOptions(); + + expect(maxTransformWorkers).toBe(32); + }); + + it('supports maxTransformWorkers set to 1', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '1'; + const { maxTransformWorkers } = loadEnvironmentOptions(); + + expect(maxTransformWorkers).toBe(1); + }); + + it('falls back to default calculation when NG_BUILD_MAX_WORKERS is 0 or negative', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '0'; + const { maxTransformWorkers: zeroWorkers } = loadEnvironmentOptions(); + const expected = Math.max(1, Math.min(6, Math.floor(availableParallelism() / 4))); + expect(zeroWorkers).toBe(expected); + + process.env['NG_BUILD_MAX_WORKERS'] = '-4'; + const { maxTransformWorkers: negativeWorkers } = loadEnvironmentOptions(); + expect(negativeWorkers).toBe(expected); + }); + + it('falls back to default calculation when NG_BUILD_MAX_WORKERS is invalid', () => { + process.env['NG_BUILD_MAX_WORKERS'] = 'invalid'; + const { maxTransformWorkers } = loadEnvironmentOptions(); + const expected = Math.max(1, Math.min(6, Math.floor(availableParallelism() / 4))); + + expect(maxTransformWorkers).toBe(expected); + }); +}); From 682f6aeeeeaaac927bdae4d667b56fc0a127042c Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:18:09 -0400 Subject: [PATCH 4/4] refactor(@angular/build): pre-allocate workers to maxConcurrency in JavaScriptTransformer Worker thread startup in Node.js incurs a non-trivial initialization delay (roughly 100ms to 160ms) primarily driven by V8 module evaluation and loading transitive dependencies of the linker, such as @angular/compiler-cli, @angular/compiler, and TypeScript. While individual OXC file transformations take only around 10ms, on-demand thread creation during burst requests exposes this startup latency directly on the critical path. Configuring minThreads to match maxConcurrency ensures that worker threads are pre-allocated upfront. During initial bundling, this allows workers to complete their initialization concurrently while TypeScript compilation executes, hiding module loading overhead and preventing transformation bottlenecks when esbuild emits files. If linker initialization costs are reduced in the future or a pre-warmed shared worker pool is introduced, this pre-allocation strategy can be revisited. --- .../angular/build/src/tools/esbuild/javascript-transformer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts index 049934830eac..2917222a3277 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts @@ -229,6 +229,7 @@ export class JavaScriptTransformer { filename: require.resolve('./javascript-transformer-worker'), workerData: this.#commonOptions, ...(this.options.maxConcurrency !== undefined && { + minThreads: this.options.maxConcurrency, maxThreads: this.options.maxConcurrency, }), };