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