From 1713a1ea8e0d901b3a30daabed1dee8fbfd3e86e Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:57:05 -0400 Subject: [PATCH] refactor(@angular/build): support supplying worker pool to i18n inliner The I18nInliner class previously created a dedicated WorkerPool instance on every construction and unconditionally destroyed it when closed. In watch mode and across build executions, this led to unnecessary worker thread lifecycle overhead and prevented reusing existing thread pools across post-bundle phases. This change updates the I18nInliner constructor to accept an optional workerPool argument directly as a second parameter. When provided, the supplied worker pool is used for all transformation task executions and is treated as an external borrowed dependency that is not destroyed when close is invoked. When omitted, the inliner continues to create and manage its own dedicated worker pool as before. The inlineI18n application builder helper is also updated to accept an optional workerPool parameter and forward it to the inliner constructor, enabling callers and future shared build orchestration to pass through a common worker pool. --- .../build/src/builders/application/i18n.ts | 18 +++-- .../build/src/tools/esbuild/i18n-inliner.ts | 22 ++++-- .../src/tools/esbuild/i18n-inliner_spec.ts | 74 +++++++++++++++++++ 3 files changed, 102 insertions(+), 12 deletions(-) diff --git a/packages/angular/build/src/builders/application/i18n.ts b/packages/angular/build/src/builders/application/i18n.ts index f775acdc491d..c450da07cdcb 100644 --- a/packages/angular/build/src/builders/application/i18n.ts +++ b/packages/angular/build/src/builders/application/i18n.ts @@ -21,6 +21,7 @@ import { maxWorkers } from '../../utils/environment-options'; import { loadTranslations } from '../../utils/i18n-options'; import { createTranslationLoader } from '../../utils/load-translations'; import { createProjectResolver } from '../../utils/resolve-project'; +import type { WorkerPool } from '../../utils/worker-pool'; import { executePostBundleSteps } from './execute-post-bundle'; import { NormalizedApplicationBuildOptions, getLocaleBaseHref } from './options'; @@ -31,12 +32,14 @@ import { NormalizedApplicationBuildOptions, getLocaleBaseHref } from './options' * @param options The normalized application builder options used to create the build. * @param executionResult The result of an executed build. * @param initialFiles A map containing initial file information for the executed build. + * @param workerPool An optional worker pool to use for running transformation tasks. */ export async function inlineI18n( metafile: Metafile, options: NormalizedApplicationBuildOptions, executionResult: ExecutionResult, initialFiles: Map, + workerPool?: WorkerPool, ): Promise<{ errors: string[]; warnings: string[]; @@ -45,12 +48,15 @@ export async function inlineI18n( const { i18nOptions, baseHref, cacheOptions } = options; // Create the multi-threaded inliner with common options. - const inliner = new I18nInliner({ - missingTranslation: i18nOptions.missingTranslationBehavior ?? 'warning', - maxConcurrency: maxWorkers, - persistentCachePath: cacheOptions.enabled ? cacheOptions.path : undefined, - localizeVersion: i18nOptions.localizeVersion, - }); + const inliner = new I18nInliner( + { + missingTranslation: i18nOptions.missingTranslationBehavior ?? 'warning', + maxConcurrency: maxWorkers, + persistentCachePath: cacheOptions.enabled ? cacheOptions.path : undefined, + localizeVersion: i18nOptions.localizeVersion, + }, + workerPool, + ); const inlineResult: { errors: string[]; diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index d76b00c0a31a..e5ec68f48e26 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -187,7 +187,8 @@ interface UncachedLocaleEntry { */ export class I18nInliner { #cacheInitFailed = false; - #workerPool: WorkerPool; + readonly #workerPool: WorkerPool; + readonly #ownsWorkerPool: boolean; #cacheStore: PersistentCacheStore | undefined; #transformedFileCache: Cache | undefined; #translationCache: Cache | undefined; @@ -197,7 +198,10 @@ export class I18nInliner { return this.options.maxConcurrency ?? (this.#workerPool.maxThreads || 1); } - constructor(private readonly options: I18nInlinerOptions) { + constructor( + private readonly options: I18nInlinerOptions, + workerPool?: WorkerPool, + ) { if ( options.maxConcurrency !== undefined && (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency < 1) @@ -205,12 +209,15 @@ export class I18nInliner { throw new RangeError('options.maxConcurrency must be an integer greater than or equal to 1.'); } + this.#ownsWorkerPool = !workerPool; // Piscina uses object spread against default options internally. Only define // maxThreads when specified to avoid overwriting Piscina's default thread count // with undefined. - this.#workerPool = new WorkerPool({ - ...(options.maxConcurrency !== undefined && { maxThreads: options.maxConcurrency }), - }); + this.#workerPool = + workerPool ?? + new WorkerPool({ + ...(options.maxConcurrency !== undefined && { maxThreads: options.maxConcurrency }), + }); } #partitionFiles(files: Iterable): { @@ -662,7 +669,10 @@ export class I18nInliner { * @returns A void promise that resolves when closing is complete. */ async close(): Promise { - await Promise.allSettled([this.#cacheStore?.close(), this.#workerPool.destroy()]); + await Promise.allSettled([ + this.#cacheStore?.close(), + this.#ownsWorkerPool ? this.#workerPool.destroy() : undefined, + ]); } /** diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts index b02b171cf399..8f73c018e4bd 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts @@ -1092,4 +1092,78 @@ describe('I18nInliner', () => { expect(maxThreadsSpy.calls.mostRecent().returnValue).toBeGreaterThanOrEqual(1); }); }); + + describe('supplied workerPool', () => { + let externalPool: WorkerPool | undefined; + + afterEach(async () => { + await externalPool?.destroy(); + externalPool = undefined; + }); + + it('uses the supplied workerPool to execute transformation tasks', async () => { + externalPool = new WorkerPool({ maxThreads: 1 }); + const runSpy = spyOn(externalPool, 'run').and.callThrough(); + + inliner = new I18nInliner({ missingTranslation: 'warning' }, externalPool); + + const files = [browserFile('main.js', GREETING_SOURCE)]; + const results = await inliner.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + }, + ]); + + expect(results.size).toBe(1); + expect(findFile(results.get('fr')?.outputFiles ?? [], 'main.js').text).toContain('"Bonjour"'); + expect(runSpy).toHaveBeenCalled(); + }); + + it('does not destroy the supplied workerPool on close()', async () => { + externalPool = new WorkerPool({ maxThreads: 1 }); + const destroySpy = spyOn(externalPool, 'destroy').and.callThrough(); + + inliner = new I18nInliner({ missingTranslation: 'warning' }, externalPool); + await inliner.close(); + + expect(destroySpy).not.toHaveBeenCalled(); + }); + + it('destroys internally created workerPool on close()', async () => { + const destroySpy = spyOn(WorkerPool.prototype, 'destroy').and.callThrough(); + + inliner = new I18nInliner({ missingTranslation: 'warning', maxConcurrency: 1 }); + await inliner.close(); + + expect(destroySpy).toHaveBeenCalled(); + }); + + it('supports multiple sequential inlining passes with the same supplied workerPool', async () => { + externalPool = new WorkerPool({ maxThreads: 1 }); + + inliner = new I18nInliner({ missingTranslation: 'warning' }, externalPool); + + const files1 = [browserFile('main.js', GREETING_SOURCE)]; + const results1 = await inliner.inlineAll(files1, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + }, + ]); + expect(findFile(results1.get('fr')?.outputFiles ?? [], 'main.js').text).toContain( + '"Bonjour"', + ); + + // Second inlining pass on the same pool + const files2 = [browserFile('main.js', GREETING_SOURCE)]; + const results2 = await inliner.inlineAll(files2, [ + { + locale: 'es', + translation: { greeting: translationFor('Hola') }, + }, + ]); + expect(findFile(results2.get('es')?.outputFiles ?? [], 'main.js').text).toContain('"Hola"'); + }); + }); });