From 9580df8d72a2127dd4e8afe91fc51879f401796f Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:56:17 -0400 Subject: [PATCH 1/3] refactor(@angular/build): add typed helper for worker pool execution in i18n inliner Add a strongly-typed private helper method #runWorkerTask to I18nInliner to encapsulate worker pool execution. The helper maps worker task names ('inlineFileBatch' and 'inlineCode') to their exact request and result types exported from the worker module. This eliminates manual type assertions and untyped return values while centralizing worker pool task dispatch. --- .../src/tools/esbuild/i18n-inliner-worker.ts | 18 +++- .../build/src/tools/esbuild/i18n-inliner.ts | 95 ++++++++++--------- 2 files changed, 64 insertions(+), 49 deletions(-) diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts index 2804e0ef8a9e..bc471dcb675e 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts @@ -21,7 +21,7 @@ import { createSharedTranslationProxy } from './i18n-translation-reader'; /** * The options passed to the inliner for each code request */ -interface InlineCodeRequest { +export interface InlineCodeRequest { /** * The code that should be processed. */ @@ -45,10 +45,18 @@ interface InlineCodeRequest { translation?: Blob | SharedArrayBuffer; } +/** + * The response returned from a code request. + */ +export interface InlineCodeResult { + output: string; + messages: { type: 'error' | 'warning'; message: string }[]; +} + /** * The options passed to the inliner for a batch file request */ -interface InlineFileBatchRequest { +export interface InlineFileBatchRequest { /** * The filename that should be processed. */ @@ -91,7 +99,7 @@ interface InlineFileBatchRequest { /** * The result for a single locale within a batch file request. */ -interface InlineLocaleResult { +export interface InlineLocaleResult { locale: string; code?: string; map?: string; @@ -101,7 +109,7 @@ interface InlineLocaleResult { /** * The response returned from a batch file request. */ -type InlineFileBatchResult = +export type InlineFileBatchResult = | { file: string; unmodified: true; @@ -292,7 +300,7 @@ export async function inlineFileBatch( * @param request An InlineRequest object representing the options for inlining * @returns An object containing the inlined code. */ -export async function inlineCode(request: InlineCodeRequest) { +export async function inlineCode(request: InlineCodeRequest): Promise { const metadata = extractLocalizeMetadata(request.filename, request.code); const result = await inlineLocalize( request.code, diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index 84d302428342..be3c73bdfce4 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -14,8 +14,25 @@ import { calculateHash, createContentHash, initializeHash } from '../../utils/ha import { WorkerPool } from '../../utils/worker-pool'; import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files'; import { type Cache, type PersistentCacheStore, createPersistentCacheStore } from './cache'; +import type { + InlineCodeRequest, + InlineCodeResult, + InlineFileBatchRequest, + InlineFileBatchResult, +} from './i18n-inliner-worker'; import { encodeTranslationToBuffer } from './i18n-translation-encoder'; +interface WorkerTaskMap { + inlineFileBatch: { + request: InlineFileBatchRequest; + result: InlineFileBatchResult; + }; + inlineCode: { + request: InlineCodeRequest; + result: InlineCodeResult; + }; +} + /** * A keyword used to indicate if a JavaScript file may require inlining of translations. * This keyword is used to avoid processing files that would not otherwise need i18n processing. @@ -492,28 +509,15 @@ export class I18nInliner { for (let i = 0; i < entries.length; i += localesPerBatch) { const batchEntries = entries.slice(i, i + localesPerBatch); const task = (async () => { - const batchResult = (await this.#workerPool.run( - { - filename, - code: codeBlob, - map: mapBlob, - locales: new Map(batchEntries.map((e) => [e.locale, e.translation])), - ephemeral, - activeLocales, - generation, - }, - { name: 'inlineFileBatch' }, - )) as - | { - file: string; - unmodified: true; - messages: { type: 'error' | 'warning'; message: string }[]; - } - | { - file: string; - unmodified?: false; - results: Array; - }; + const batchResult = await this.#runWorkerTask('inlineFileBatch', { + filename, + code: codeBlob, + map: mapBlob, + locales: new Map(batchEntries.map((e) => [e.locale, e.translation])), + ephemeral, + activeLocales, + generation, + }); if (batchResult.unmodified) { const unmodifiedResult: TransformedFileResult = { @@ -535,19 +539,18 @@ export class I18nInliner { for (const res of batchResult.results) { const matchingEntry = batchEntries.find((e) => e.locale === res.locale); const cacheKey = matchingEntry?.cacheKey; + const fileResult: TransformedFileResult = { + file: filename, + code: res.code, + map: res.map, + messages: res.messages, + }; if (this.#transformedFileCache && cacheKey) { - cachePromises.push( - this.#transformedFileCache.put(cacheKey, { - file: filename, - code: res.code, - map: res.map, - messages: res.messages, - }), - ); + cachePromises.push(this.#transformedFileCache.put(cacheKey, fileResult)); } - fileResultsByLocale.get(res.locale)?.set(filename, res); + fileResultsByLocale.get(res.locale)?.set(filename, fileResult); } await Promise.allSettled(cachePromises); } @@ -560,6 +563,13 @@ export class I18nInliner { await Promise.all(workerTasks); } + #runWorkerTask( + name: T, + request: WorkerTaskMap[T]['request'], + ): Promise { + return this.#workerPool.run(request, { name }) as Promise; + } + /** * Performs inlining of translations for the provided locale and translations. * @@ -599,19 +609,16 @@ export class I18nInliner { }; } - const { output, messages } = await this.#workerPool.run( - { - code: templateCode, - filename: templateId, - locale, - translation: await serializeTranslation( - translation, - translationIntegrity, - this.#translationCache, - ), - }, - { name: 'inlineCode' }, - ); + const { output, messages } = await this.#runWorkerTask('inlineCode', { + code: templateCode, + filename: templateId, + locale, + translation: await serializeTranslation( + translation, + translationIntegrity, + this.#translationCache, + ), + }); const errors: string[] = []; const warnings: string[] = []; From e9baeb742e8d9ec9b3f05e3aa184a0239cf8b3ef Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:07:09 -0400 Subject: [PATCH 2/3] refactor(@angular/build): pass missingTranslation per request in i18n inliner Remove workerData from the i18n inliner worker pool initialization and pass missingTranslation per task in the request payload. This decouples the worker pool from inliner-specific options, allowing worker threads to be safely shared without hardcoding the missingTranslation handling behavior at pool creation time. --- .../src/tools/esbuild/i18n-inliner-worker.ts | 20 ++++++++++----- .../build/src/tools/esbuild/i18n-inliner.ts | 8 ++---- .../src/tools/esbuild/i18n-inliner_spec.ts | 25 +++++++++++++++++++ 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts index bc471dcb675e..ac75ab246d8d 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts @@ -12,7 +12,6 @@ import type { Node } from '@oxc-project/types'; import { MagicString } from 'magic-string'; import assert from 'node:assert'; import { deserialize } from 'node:v8'; -import { workerData } from 'node:worker_threads'; import { parseSync } from 'oxc-parser'; import { traversePostOrder } from '../oxc/traversal'; import { loadLocaleData } from './i18n-locale-plugin'; @@ -43,6 +42,11 @@ export interface InlineCodeRequest { * the Worker by reference instead of being copied into it for every request. */ translation?: Blob | SharedArrayBuffer; + + /** + * How to handle missing translations. + */ + missingTranslation?: 'error' | 'warning' | 'ignore'; } /** @@ -77,6 +81,11 @@ export interface InlineFileBatchRequest { */ locales: ReadonlyMap; + /** + * How to handle missing translations. + */ + missingTranslation?: 'error' | 'warning' | 'ignore'; + /** * Whether the file data should be treated as ephemeral and not cached long-term in the Worker. * Typically true when all remaining locales for the file are processed in a single batch. @@ -121,11 +130,6 @@ export type InlineFileBatchResult = results: InlineLocaleResult[]; }; -// Extract common options used for inline requests from the Worker context -const { missingTranslation } = (workerData || {}) as { - missingTranslation: 'error' | 'warning' | 'ignore'; -}; - /** * Cached file data including code and extracted localization metadata. */ @@ -276,6 +280,7 @@ export async function inlineFileBatch( locale, await loadTranslation(locale, translation), request.filename, + request.missingTranslation, ); return { @@ -309,6 +314,7 @@ export async function inlineCode(request: InlineCodeRequest): Promise | undefined, filename: string, + missingTranslation: 'error' | 'warning' | 'ignore' = 'warning', ) { const magicString = new MagicString(code); const { Diagnostics, translate } = await loadLocalizeTools(); diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index be3c73bdfce4..0b5cef389de3 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -186,15 +186,9 @@ export class I18nInliner { private readonly options: I18nInlinerOptions, maxThreads?: number, ) { - const { missingTranslation } = options; - this.#workerPool = new WorkerPool({ filename: require.resolve('./i18n-inliner-worker'), maxThreads, - // Extract options to ensure only the named options are serialized and sent to the worker - workerData: { - missingTranslation, - }, }); } @@ -514,6 +508,7 @@ export class I18nInliner { code: codeBlob, map: mapBlob, locales: new Map(batchEntries.map((e) => [e.locale, e.translation])), + missingTranslation: this.options.missingTranslation, ephemeral, activeLocales, generation, @@ -613,6 +608,7 @@ export class I18nInliner { code: templateCode, filename: templateId, locale, + missingTranslation: this.options.missingTranslation, translation: await serializeTranslation( translation, translationIntegrity, 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 8059270145bb..e64efe2fa05b 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts @@ -144,6 +144,31 @@ describe('I18nInliner', () => { expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"'); }); + it('errors and retains the original message when missingTranslation is "error"', async () => { + const { outputFiles, errors, warnings } = await createInliner({ + missingTranslation: 'error', + }).inlineForLocale([browserFile('main.js', GREETING_SOURCE)], 'fr', { + unrelated: translationFor('Sans rapport'), + }); + + expect(errors.length).toBe(1); + expect(errors[0]).toContain('greeting'); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"'); + }); + + it('ignores missing translations when missingTranslation is "ignore"', async () => { + const { outputFiles, errors, warnings } = await createInliner({ + missingTranslation: 'ignore', + }).inlineForLocale([browserFile('main.js', GREETING_SOURCE)], 'fr', { + unrelated: translationFor('Sans rapport'), + }); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"'); + }); + it('replaces the locale placeholder with the locale being inlined', async () => { // The placeholder is only inlined for files that use `$localize`, which is where the build // inserts it, so the message is present alongside it here. From 52f6570e2e9ffb564453da1c8b0e441cb8d9cbde Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:37:42 -0400 Subject: [PATCH 3/3] refactor(@angular/build): explicitly target worker script in i18n inliner task execution Specify the worker target file directly in each task run option via #runWorkerTask rather than hardcoding a default filename during WorkerPool instantiation. To prepare for ESM transition and avoid reliance on ambient require, createRequire is used to resolve the inliner worker script path. Explicitly specifying the worker file per task enables the inliner to run against general-purpose or shared worker pools that do not have a preconfigured default script. --- .../angular/build/src/tools/esbuild/i18n-inliner.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index 0b5cef389de3..212095c38761 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -8,6 +8,7 @@ import type { ɵParsedTranslation } from '@angular/localize'; import assert from 'node:assert'; +import { createRequire } from 'node:module'; import { extname, join } from 'node:path'; import { serialize } from 'node:v8'; import { calculateHash, createContentHash, initializeHash } from '../../utils/hash'; @@ -22,6 +23,10 @@ import type { } from './i18n-inliner-worker'; import { encodeTranslationToBuffer } from './i18n-translation-encoder'; +// TODO: Convert to import.meta usage during ESM transition +const localRequire = createRequire(__filename); +const INLINER_WORKER_PATH = localRequire.resolve('./i18n-inliner-worker'); + interface WorkerTaskMap { inlineFileBatch: { request: InlineFileBatchRequest; @@ -187,7 +192,6 @@ export class I18nInliner { maxThreads?: number, ) { this.#workerPool = new WorkerPool({ - filename: require.resolve('./i18n-inliner-worker'), maxThreads, }); } @@ -562,7 +566,10 @@ export class I18nInliner { name: T, request: WorkerTaskMap[T]['request'], ): Promise { - return this.#workerPool.run(request, { name }) as Promise; + return this.#workerPool.run(request, { + filename: INLINER_WORKER_PATH, + name, + }) as Promise; } /**