From 5ce398f16055fe5dfea8c249e8a480b3cdfec762 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:53:06 -0400 Subject: [PATCH 1/2] refactor(@angular/build): remove unused options and dead code from i18n inliner Remove obsolete and unused options from the I18nInliner options and workerData. The `shouldOptimize` option is removed from `I18nInlinerOptions`, `workerData`, and cache base key calculation since inliner AST transformations do not perform code optimization. The `translations` option is also removed from `I18nInlinerOptions` and `workerData` because translations are provided per batch request. In addition, the unused single-file `inlineFile` worker action and interface are removed, and `InlineFileBatchRequest` is updated to model `locales` as a `ReadonlyMap` to enforce locale key uniqueness. --- .../build/src/builders/application/i18n.ts | 3 +- .../src/tools/esbuild/i18n-inliner-worker.ts | 75 ++----------------- .../build/src/tools/esbuild/i18n-inliner.ts | 14 +--- 3 files changed, 12 insertions(+), 80 deletions(-) diff --git a/packages/angular/build/src/builders/application/i18n.ts b/packages/angular/build/src/builders/application/i18n.ts index f212659a14be..058a2fa3d6a4 100644 --- a/packages/angular/build/src/builders/application/i18n.ts +++ b/packages/angular/build/src/builders/application/i18n.ts @@ -42,14 +42,13 @@ export async function inlineI18n( warnings: string[]; prerenderedRoutes: PrerenderedRoutesRecord; }> { - const { i18nOptions, optimizationOptions, baseHref, cacheOptions } = options; + const { i18nOptions, baseHref, cacheOptions } = options; // Create the multi-threaded inliner with common options and the files generated from the build. const inliner = new I18nInliner( { missingTranslation: i18nOptions.missingTranslationBehavior ?? 'warning', outputFiles: executionResult.outputFiles, - shouldOptimize: optimizationOptions.scripts, persistentCachePath: cacheOptions.enabled ? cacheOptions.path : undefined, localizeVersion: i18nOptions.localizeVersion, }, 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 af9d9dce134b..0b22c7412ba1 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts @@ -16,29 +16,6 @@ import { parseSync, visitorKeys } from 'oxc-parser'; import { loadLocaleData } from './i18n-locale-plugin'; import { createSharedTranslationProxy } from './i18n-translation-reader'; -/** - * The options passed to the inliner for each file request - */ -interface InlineFileRequest { - /** - * The filename that should be processed. The data for the file is provided to the Worker - * during Worker initialization. - */ - filename: string; - - /** - * The locale specifier that should be used during the inlining process of the file. - */ - locale: string; - - /** - * The serialized translation messages for the locale that should be used during the inlining - * process of the file. A SharedArrayBuffer or Blob is used so that the messages are shared with - * the Worker by reference instead of being copied into it for every request. - */ - translation?: Blob | SharedArrayBuffer; -} - /** * The options passed to the inliner for each code request */ @@ -77,9 +54,9 @@ interface InlineFileBatchRequest { filename: string; /** - * The locale specifiers or locale objects that should be used during the inlining process of the file. + * The locale specifiers and optional translations to use during the inlining process of the file. */ - locales: (string | { locale: string; translation?: Blob | SharedArrayBuffer })[]; + locales: ReadonlyMap; /** * Whether the file data should be treated as ephemeral and not cached long-term in the Worker. @@ -113,10 +90,9 @@ interface InlineFileBatchResult { } // Extract the application files and common options used for inline requests from the Worker context -const { files, missingTranslation, translations } = (workerData || {}) as { +const { files, missingTranslation } = (workerData || {}) as { files: ReadonlyMap; missingTranslation: 'error' | 'warning' | 'ignore'; - translations?: ReadonlyMap; }; /** @@ -184,15 +160,14 @@ function loadTranslation( locale: string, translation?: Blob | SharedArrayBuffer, ): Promise> | undefined { - const translationData = translation ?? translations?.get(locale); - if (!translationData) { + if (!translation) { return undefined; } let messagesPromise = deserializedTranslations.get(locale); if (!messagesPromise) { - if (translationData instanceof Blob) { - messagesPromise = translationData + if (translation instanceof Blob) { + messagesPromise = translation .arrayBuffer() .then((buffer) => deserialize(new Uint8Array(buffer)) as Record) .catch((error) => { @@ -200,7 +175,7 @@ function loadTranslation( throw error; }); } else { - messagesPromise = Promise.resolve(createSharedTranslationProxy(translationData)); + messagesPromise = Promise.resolve(createSharedTranslationProxy(translation)); } deserializedTranslations.set(locale, messagesPromise); } @@ -208,38 +183,6 @@ function loadTranslation( return messagesPromise; } -/** - * Inlines the provided locale and translation into a JavaScript file that contains `$localize` usage. - * This function is the main entry for the Worker's action that is called by the worker pool. - * - * @param request An InlineRequest object representing the options for inlining - * @returns An object containing the inlined file and optional map content. - */ -export default async function inlineFile(request: InlineFileRequest) { - const { code, metadata } = await loadFileData(request.filename, true); - - // Sourcemaps are parsed on demand per request rather than cached long-term to prevent - // monotonic memory growth as a worker processes multiple files across the build. - const rawMap = await files.get(request.filename + '.map')?.text(); - const map = rawMap ? (JSON.parse(rawMap) as SourceMapInput) : undefined; - - const result = await inlineLocalize( - code, - map, - metadata, - request.locale, - await loadTranslation(request.locale, request.translation), - request.filename, - ); - - return { - file: request.filename, - code: result.code, - map: result.map, - messages: result.diagnostics.messages, - }; -} - /** * Inlines multiple locales and translations into a JavaScript file that contains `$localize` usage. * @@ -266,9 +209,7 @@ export async function inlineFileBatch( const map = rawMap ? (JSON.parse(rawMap) as SourceMapInput) : undefined; const results = await Promise.all( - request.locales.map(async (entry) => { - const locale = typeof entry === 'string' ? entry : entry.locale; - const translation = typeof entry === 'string' ? undefined : entry.translation; + Array.from(request.locales, async ([locale, translation]) => { const result = await inlineLocalize( code, map, diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index 41de55de666f..689fc7736995 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -57,10 +57,8 @@ function serializeTranslation( export interface I18nInlinerOptions { missingTranslation: 'error' | 'warning' | 'ignore'; outputFiles: BuildOutputFile[]; - shouldOptimize?: boolean; persistentCachePath?: string; localizeVersion?: string; - translations?: ReadonlyMap; } /** @@ -155,7 +153,7 @@ export class I18nInliner { maxThreads?: number, ) { this.#unmodifiedFiles = []; - const { outputFiles, shouldOptimize, missingTranslation, translations } = options; + const { outputFiles, missingTranslation } = options; const files = new Map(); const pendingMaps = []; @@ -206,8 +204,6 @@ export class I18nInliner { // Extract options to ensure only the named options are serialized and sent to the worker workerData: { missingTranslation, - shouldOptimize, - translations, // A Blob is an immutable data structure that allows sharing the data between workers // without copying until the data is actually used within a Worker. This is useful here // since each file may not actually be processed in each Worker and the Blob avoids @@ -233,7 +229,7 @@ export class I18nInliner { ): Promise> { await this.initCache(); - const { shouldOptimize, missingTranslation, localizeVersion } = this.options; + const { missingTranslation, localizeVersion } = this.options; const localeList = Array.from(locales); if (localeList.length === 0) { @@ -270,7 +266,6 @@ export class I18nInliner { locale, translation: translationIntegrity || translation, missingTranslation, - shouldOptimize, localizeVersion, }), ), @@ -426,10 +421,7 @@ export class I18nInliner { const batchResult = (await this.#workerPool.run( { filename, - locales: batchEntries.map((e) => ({ - locale: e.locale, - translation: e.translation, - })), + locales: new Map(batchEntries.map((e) => [e.locale, e.translation])), ephemeral, activeLocales, }, From 65b7a2b53b9e54414375afe838c5eca4dbd95b6f Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:50:48 -0400 Subject: [PATCH 2/2] =?UTF-8?q?refactor(@angular/build):=20use=20=C9=B5Par?= =?UTF-8?q?sedTranslation=20in=20i18n=20inliner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace untyped Record translation dictionaries with Record throughout the i18n inliner and related utilities. The SharedTranslationDictionary and createSharedTranslationProxy utilities now default to ɵParsedTranslation with generic support for custom value types in unit tests. --- .../src/tools/esbuild/i18n-inliner-worker.ts | 9 ++--- .../build/src/tools/esbuild/i18n-inliner.ts | 9 ++--- .../src/tools/esbuild/i18n-inliner_spec.ts | 36 +++++++++++-------- .../tools/esbuild/i18n-translation-encoder.ts | 6 +++- .../esbuild/i18n-translation-encoder_spec.ts | 12 +++---- .../tools/esbuild/i18n-translation-reader.ts | 15 ++++---- .../angular/build/src/utils/i18n-options.ts | 5 +-- 7 files changed, 55 insertions(+), 37 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 0b22c7412ba1..d6a56ad10d74 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts @@ -7,6 +7,7 @@ */ import remapping, { type DecodedSourceMap, type SourceMapInput } from '@ampproject/remapping'; +import type { ɵParsedTranslation } from '@angular/localize'; import type { Node } from '@oxc-project/types'; import { MagicString } from 'magic-string'; import assert from 'node:assert'; @@ -111,7 +112,7 @@ const fileDataCache = new Map>(); /** * Cache of deserialized translation messages keyed by locale. */ -const deserializedTranslations = new Map>>(); +const deserializedTranslations = new Map>>(); /** * Retrieves the file data for a filename, loading and extracting localization metadata. @@ -159,7 +160,7 @@ function loadFileData(filename: string, cache = true): Promise { function loadTranslation( locale: string, translation?: Blob | SharedArrayBuffer, -): Promise> | undefined { +): Promise> | undefined { if (!translation) { return undefined; } @@ -169,7 +170,7 @@ function loadTranslation( if (translation instanceof Blob) { messagesPromise = translation .arrayBuffer() - .then((buffer) => deserialize(new Uint8Array(buffer)) as Record) + .then((buffer) => deserialize(new Uint8Array(buffer)) as Record) .catch((error) => { deserializedTranslations.delete(locale); throw error; @@ -431,7 +432,7 @@ async function inlineLocalize( map: SourceMapInput | undefined, metadata: FileLocalizeMetadata, locale: string, - translation: Record | undefined, + translation: Record | undefined, filename: string, ) { const magicString = new MagicString(code); diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index 689fc7736995..33b570a46017 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import type { ɵParsedTranslation } from '@angular/localize'; import assert from 'node:assert'; import { extname, join } from 'node:path'; import { serialize } from 'node:v8'; @@ -38,7 +39,7 @@ const DEFAULT_LOCALE_WINDOW_SIZE = 8; * @returns A SharedArrayBuffer or Blob containing the serialized messages, or undefined if none. */ function serializeTranslation( - translation: Record | undefined, + translation: Record | undefined, ): SharedArrayBuffer | Blob | undefined { if (!translation) { return undefined; @@ -73,7 +74,7 @@ export interface LocaleInlineOptions { /** * The translation messages for the locale, or undefined for the source/untranslated locale. */ - translation?: Record; + translation?: Record; /** * An optional content integrity hash of the translation file(s) for fast cache key calculation. @@ -470,7 +471,7 @@ export class I18nInliner { */ async inlineForLocale( locale: string, - translation: Record | undefined, + translation: Record | undefined, translationIntegrity?: string, ): Promise { const results = await this.inlineAll([{ locale, translation, translationIntegrity }]); @@ -482,7 +483,7 @@ export class I18nInliner { async inlineTemplateUpdate( locale: string, - translation: Record | undefined, + translation: Record | undefined, templateCode: string, templateId: string, ): Promise<{ code: string; errors: string[]; warnings: string[] }> { 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 46d1cb4145d4..acceab7513b5 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import type { ɵParsedTranslation } from '@angular/localize'; import { transform } from 'esbuild'; import fs from 'node:fs/promises'; import os from 'node:os'; @@ -21,11 +22,22 @@ import { I18nInliner } from './i18n-inliner'; const GREETING_SOURCE = 'export const greeting = $localize`:@@greeting:Hello`;\n'; /** - * Creates the parsed translation form that `@angular/localize` expects for a message without - * placeholders. + * Creates the parsed translation form that `@angular/localize` expects. */ -function translationFor(message: string): Record { - return { messageParts: [message], placeholderNames: [], text: message }; +function parsedTranslation( + parts: string[], + placeholderNames: string[] = [], + text?: string, +): ɵParsedTranslation { + return { + messageParts: Object.assign([...parts], { raw: [...parts] }), + placeholderNames, + text: text ?? parts.join(''), + }; +} + +function translationFor(message: string): ɵParsedTranslation { + return parsedTranslation([message], [], message); } function browserFile(path: string, contents: string): BuildOutputFile { @@ -222,11 +234,7 @@ describe('I18nInliner', () => { const { outputFiles, errors, warnings } = await createInliner([ browserFile('main.js', source), ]).inlineForLocale('fr', { - welcome: { - messageParts: ['Bonjour ', ' !'], - placeholderNames: ['PH'], - text: 'Bonjour {$PH} !', - }, + welcome: parsedTranslation(['Bonjour ', ' !'], ['PH'], 'Bonjour {$PH} !'), }); expect(errors).toEqual([]); @@ -294,11 +302,11 @@ describe('I18nInliner', () => { browserFile('main.js', source), ]).inlineForLocale('fr', { inner: translationFor('Pomme'), - outer: { - messageParts: ['Vous avez sélectionné ', ' pour la livraison.'], - placeholderNames: ['PH'], - text: 'Vous avez sélectionné {$PH} pour la livraison.', - }, + outer: parsedTranslation( + ['Vous avez sélectionné ', ' pour la livraison.'], + ['PH'], + 'Vous avez sélectionné {$PH} pour la livraison.', + ), }); expect(errors).toEqual([]); diff --git a/packages/angular/build/src/tools/esbuild/i18n-translation-encoder.ts b/packages/angular/build/src/tools/esbuild/i18n-translation-encoder.ts index 091d9ba0ff26..cc8f1ae50f31 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-translation-encoder.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-translation-encoder.ts @@ -6,6 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ +import type { ɵParsedTranslation } from '@angular/localize'; + /** * Magic header identifier for i18n SharedArrayBuffer translation tables ('I18N'). */ @@ -19,7 +21,9 @@ export const I18N_MAGIC_ID = 0x4931384e; * @param translation The translation dictionary object. * @returns A SharedArrayBuffer containing the binary encoded translation catalog. */ -export function encodeTranslationToBuffer(translation: Record): SharedArrayBuffer { +export function encodeTranslationToBuffer( + translation: Record, +): SharedArrayBuffer { const encoder = new TextEncoder(); const entries = Object.entries(translation); const entryCount = entries.length; diff --git a/packages/angular/build/src/tools/esbuild/i18n-translation-encoder_spec.ts b/packages/angular/build/src/tools/esbuild/i18n-translation-encoder_spec.ts index c3b6409d40b5..dd660f9a4059 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-translation-encoder_spec.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-translation-encoder_spec.ts @@ -22,7 +22,7 @@ describe('SharedArrayBuffer Translation Encoder & Reader', () => { }; const buffer = encodeTranslationToBuffer(translation); - const dictionary = new SharedTranslationDictionary(buffer); + const dictionary = new SharedTranslationDictionary(buffer); expect(dictionary.get('greeting')).toEqual('Hello'); expect(dictionary.get('farewell')).toEqual('Goodbye'); @@ -38,7 +38,7 @@ describe('SharedArrayBuffer Translation Encoder & Reader', () => { }; const buffer = encodeTranslationToBuffer(translation); - const proxy = createSharedTranslationProxy(buffer); + const proxy = createSharedTranslationProxy(buffer); expect(proxy['msg1']).toEqual('Message 1'); expect(proxy['msg2']).toEqual('Message 2'); @@ -54,7 +54,7 @@ describe('SharedArrayBuffer Translation Encoder & Reader', () => { }; const buffer = encodeTranslationToBuffer(translation); - const proxy = createSharedTranslationProxy(buffer); + const proxy = createSharedTranslationProxy(buffer); expect(Object.prototype.hasOwnProperty.call(proxy, 'msg1')).toBeTrue(); expect(Object.prototype.hasOwnProperty.call(proxy, 'unknown')).toBeFalse(); @@ -83,7 +83,7 @@ describe('SharedArrayBuffer Translation Encoder & Reader', () => { }; const buffer = encodeTranslationToBuffer(translation); - const dictionary = new SharedTranslationDictionary(buffer); + const dictionary = new SharedTranslationDictionary(buffer); expect(dictionary.get('😀')).toEqual('emoji message'); expect(dictionary.get('\uE000')).toEqual('uE000 message'); @@ -102,8 +102,8 @@ describe('SharedArrayBuffer Translation Encoder & Reader', () => { msg1: 'Message 1', }; - const buffer = encodeTranslationToBuffer(translation); - const dictionary = new SharedTranslationDictionary(buffer); + const buffer = encodeTranslationToBuffer(translation); + const dictionary = new SharedTranslationDictionary(buffer); // First lookup for missing key expect(dictionary.get('missingKey')).toBeUndefined(); diff --git a/packages/angular/build/src/tools/esbuild/i18n-translation-reader.ts b/packages/angular/build/src/tools/esbuild/i18n-translation-reader.ts index bc87633da5dc..c332b47f1941 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-translation-reader.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-translation-reader.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import type { ɵParsedTranslation } from '@angular/localize'; import { I18N_MAGIC_ID } from './i18n-translation-encoder'; /** @@ -43,13 +44,13 @@ function compareBytes( * A zero-copy reader that queries translation messages directly from a SharedArrayBuffer * using binary search over a sorted key index. */ -export class SharedTranslationDictionary { +export class SharedTranslationDictionary { private readonly entryCount: number; private readonly uint32Index: Uint32Array; private readonly uint8Pool: Uint8Array; private readonly decoder = new TextDecoder(); private readonly encoder = new TextEncoder(); - private readonly lazyCache = new Map(); + private readonly lazyCache = new Map(); constructor(buffer: SharedArrayBuffer) { if (buffer.byteLength < 16) { @@ -86,7 +87,7 @@ export class SharedTranslationDictionary { * @param targetKey The message key ID to search for. * @returns The parsed translation message, or undefined if not found. */ - get(targetKey: string): unknown | undefined { + get(targetKey: string): T | undefined { const cached = this.lazyCache.get(targetKey); if (cached !== undefined) { return cached === NOT_FOUND ? undefined : cached; @@ -117,7 +118,7 @@ export class SharedTranslationDictionary { const valJson = this.decoder.decode(valBytes); try { - const val = JSON.parse(valJson); + const val = JSON.parse(valJson) as T; this.lazyCache.set(targetKey, val); return val; @@ -150,8 +151,10 @@ export class SharedTranslationDictionary { * @param buffer The SharedArrayBuffer containing binary encoded translation catalog. * @returns A Proxy object that intercepts property reads and queries the SharedTranslationDictionary. */ -export function createSharedTranslationProxy(buffer: SharedArrayBuffer): Record { - const dictionary = new SharedTranslationDictionary(buffer); +export function createSharedTranslationProxy( + buffer: SharedArrayBuffer, +): Record { + const dictionary = new SharedTranslationDictionary(buffer); return new Proxy( {}, diff --git a/packages/angular/build/src/utils/i18n-options.ts b/packages/angular/build/src/utils/i18n-options.ts index 6a288622d053..9a55656e6798 100644 --- a/packages/angular/build/src/utils/i18n-options.ts +++ b/packages/angular/build/src/utils/i18n-options.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import type { ɵParsedTranslation } from '@angular/localize'; import path from 'node:path'; import type { TranslationLoader } from './load-translations'; @@ -15,7 +16,7 @@ export interface LocaleDescription { integrity?: string; format?: string; }[]; - translation?: Record; + translation?: Record; dataPath?: string; baseHref?: string; subPath: string; @@ -244,7 +245,7 @@ export function loadTranslations( usedFormats?: Set, duplicateTranslation?: 'ignore' | 'error' | 'warning', ) { - let translations: Record | undefined = undefined; + let translations: Record | undefined = undefined; for (const file of desc.files) { const loadResult = loader(path.join(workspaceRoot, file.path));