From 5cce78a284b0987a7a21a4d9f0941550ae2f2459 Mon Sep 17 00:00:00 2001 From: Matt Lewis Date: Wed, 5 Aug 2026 00:44:22 +0100 Subject: [PATCH 1/4] test(@angular/build): add unit tests for the i18n inliner The inliner is covered end to end by the localized builder specs and the `i18n` e2e suite, but `I18nInliner` itself has no unit tests, so behaviour that only shows up across several inline requests is untested. These add that layer: each locale gets its own translations when several are inlined in sequence through one pool, a locale without translations keeps the original messages without reporting them as missing, a locale with translations reports the ones it is missing, a modified file's source map is remapped back to the original sources, and template updates are inlined for both a translated and an untranslated locale. A single thread is used so that every request of every locale is served by the same Worker, which is what makes translation state retained between requests observable. --- .../src/tools/esbuild/i18n-inliner_spec.ts | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts new file mode 100644 index 000000000000..e3e7320e391d --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { transform } from 'esbuild'; +import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files'; +import { I18nInliner } from './i18n-inliner'; + +/** + * A module that uses a `$localize` message with an explicit message identifier so that the + * translations for a test can be keyed by a known name. + */ +const GREETING_SOURCE = 'export const greeting = $localize`:@@greeting:Hello`;\n'; + +/** + * Creates the parsed translation form that `@angular/localize` expects for a message without + * placeholders. + */ +function translationFor(message: string): Record { + return { messageParts: [message], placeholderNames: [], text: message }; +} + +function browserFile(path: string, contents: string): BuildOutputFile { + return createOutputFile(path, contents, BuildOutputFileType.Browser); +} + +function findFile(outputFiles: BuildOutputFile[], path: string): BuildOutputFile { + const file = outputFiles.find((output) => output.path === path); + if (!file) { + throw new Error(`Expected output files to contain '${path}'.`); + } + + return file; +} + +describe('I18nInliner', () => { + let inliner: I18nInliner | undefined; + + // A single thread is used throughout so that every file of every locale is inlined by the same + // Worker. Any translation state that a Worker retains between requests is then observable. + function createInliner(outputFiles: BuildOutputFile[]): I18nInliner { + inliner = new I18nInliner({ missingTranslation: 'warning', outputFiles }, 1); + + return inliner; + } + + afterEach(async () => { + await inliner?.close(); + inliner = undefined; + }); + + it('inlines the translations of a locale', async () => { + const { outputFiles, errors, warnings } = await createInliner([ + browserFile('main.js', GREETING_SOURCE), + ]).inlineForLocale('fr', { greeting: translationFor('Bonjour') }); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize'); + }); + + it('inlines the translations of each locale when several are inlined in sequence', async () => { + const localeInliner = createInliner([browserFile('main.js', GREETING_SOURCE)]); + + const french = await localeInliner.inlineForLocale('fr', { + greeting: translationFor('Bonjour'), + }); + const german = await localeInliner.inlineForLocale('de', { greeting: translationFor('Hallo') }); + // Repeats the first locale to cover a locale being inlined again after another has been. + const frenchAgain = await localeInliner.inlineForLocale('fr', { + greeting: translationFor('Bonjour'), + }); + + expect(findFile(french.outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(german.outputFiles, 'main.js').text).toContain('"Hallo"'); + expect(findFile(frenchAgain.outputFiles, 'main.js').text).toContain('"Bonjour"'); + }); + + it('inlines the translations of a locale into every file that uses them', async () => { + const { outputFiles } = await createInliner([ + browserFile('main.js', GREETING_SOURCE), + browserFile('chunk.js', GREETING_SOURCE), + ]).inlineForLocale('fr', { greeting: translationFor('Bonjour') }); + + expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(outputFiles, 'chunk.js').text).toContain('"Bonjour"'); + }); + + it('retains the original messages for a locale without translations', async () => { + const { outputFiles, errors, warnings } = await createInliner([ + browserFile('main.js', GREETING_SOURCE), + ]).inlineForLocale('en-US', undefined); + + // A locale without translations is the source locale, so its messages are not missing. + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"'); + }); + + it('warns and retains the original message when a locale is missing a translation', async () => { + const { outputFiles, errors, warnings } = await createInliner([ + browserFile('main.js', GREETING_SOURCE), + ]).inlineForLocale('fr', { unrelated: translationFor('Sans rapport') }); + + expect(errors).toEqual([]); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain('greeting'); + 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. + const { outputFiles } = await createInliner([ + browserFile('main.js', `export const locale = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`), + ]).inlineForLocale('fr', { greeting: translationFor('Bonjour') }); + + expect(findFile(outputFiles, 'main.js').text).toContain('"fr"'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('___NG_LOCALE_INSERT___'); + }); + + it('remaps the source map of a file it modifies', async () => { + // esbuild provides a map from the emitted code back to an original file, matching what the + // inliner receives during a build. + const { code, map } = await transform(GREETING_SOURCE, { + sourcefile: 'greeting.ts', + loader: 'ts', + sourcemap: 'external', + }); + + const { outputFiles } = await createInliner([ + browserFile('main.js', code), + browserFile('main.js.map', map), + ]).inlineForLocale('fr', { greeting: translationFor('Bonjour') }); + + const outputMap = JSON.parse(findFile(outputFiles, 'main.js.map').text) as { + version: number; + sources: string[]; + mappings: string; + }; + + expect(outputMap.version).toBe(3); + // The map must still resolve to the original file rather than to the inliner's input. + expect(outputMap.sources).toContain('greeting.ts'); + expect(outputMap.mappings.length).toBeGreaterThan(0); + }); + + describe('inlineTemplateUpdate', () => { + it('inlines the translations of a locale into a template update', async () => { + const { code, errors, warnings } = await createInliner([]).inlineTemplateUpdate( + 'fr', + { greeting: translationFor('Bonjour') }, + GREETING_SOURCE, + 'template-id', + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(code).toContain('"Bonjour"'); + expect(code).not.toContain('$localize'); + }); + + it('retains the original messages for a locale without translations', async () => { + const { code, errors, warnings } = await createInliner([]).inlineTemplateUpdate( + 'en-US', + undefined, + GREETING_SOURCE, + 'template-id', + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(code).toContain('"Hello"'); + }); + + it('returns the code untouched when it has no localize calls', async () => { + const source = 'export const answer = 42;\n'; + const { code } = await createInliner([]).inlineTemplateUpdate( + 'fr', + { greeting: translationFor('Bonjour') }, + source, + 'template-id', + ); + + expect(code).toBe(source); + }); + }); + + it('leaves files without localize calls unmodified', async () => { + const { outputFiles } = await createInliner([ + browserFile('main.js', GREETING_SOURCE), + browserFile('other.js', 'export const answer = 42;\n'), + ]).inlineForLocale('fr', { greeting: translationFor('Bonjour') }); + + expect(findFile(outputFiles, 'other.js').text).toBe('export const answer = 42;\n'); + }); +}); From c741fc318c179ed32181c69bde2d4d1733f49667 Mon Sep 17 00:00:00 2001 From: Matt Lewis Date: Wed, 5 Aug 2026 00:45:12 +0100 Subject: [PATCH 2/4] perf(@angular/build): share i18n translations with the inliner workers by reference `inlineForLocale` passes the locale's translations to `workerPool.run()` once per file, so every request structure-clones the whole set of messages into a Worker. For an application with a few thousand localized chunks and a catalog of tens of thousands of messages, that is tens of gigabytes of short-lived allocation and minutes of serialization on the builder's main thread. Because each clone is several megabytes it lands in V8's large object space, where only a major collection reclaims it, so a build with a heap ceiling sized for the machine can exhaust the machine before the collector intervenes. The messages are now serialized once per locale and passed as a Blob. Cloning a Blob shares its data by reference, which is the same reason the application files are already passed that way. Each Worker deserializes the messages once per locale and retains only the active locale, so at most one set of messages is held per Worker. `node:v8` serialization is used rather than JSON so that the messages arrive in the Worker exactly as the structured clone delivered them today. `translate` only reads from the messages, so sharing one deserialized set across the files of a locale is safe. Measured on an application with 1,721 localized chunks across 9 locales, where the largest catalog serializes to 6.0MB: inlining 400 chunks across 4 locales went from 23,679ms and 14,504MB peak RSS to 915ms and 1,118MB, with byte-identical output. --- .../src/tools/esbuild/i18n-inliner-worker.ts | 69 ++++++++++++++++--- .../build/src/tools/esbuild/i18n-inliner.ts | 32 ++++++++- 2 files changed, 90 insertions(+), 11 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 71a055cea676..9dd6114b663a 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts @@ -9,6 +9,7 @@ import remapping, { type EncodedSourceMap, type SourceMapInput } from '@ampproject/remapping'; import { MagicString } from 'magic-string'; import assert from 'node:assert'; +import { deserialize } from 'node:v8'; import { workerData } from 'node:worker_threads'; import { Visitor, parseSync } from 'oxc-parser'; @@ -28,9 +29,11 @@ interface InlineFileRequest { locale: string; /** - * The translation messages for the locale that should be used during the inlining process of the file. + * The serialized translation messages for the locale that should be used during the inlining + * process of the file. A Blob is used so that the messages are shared with the Worker by + * reference instead of being copied into it for every request. */ - translation?: Record; + translation?: Blob; } /** @@ -53,19 +56,55 @@ interface InlineCodeRequest { locale: string; /** - * The translation messages for the locale that should be used during the inlining process of the file. + * The serialized translation messages for the locale that should be used during the inlining + * process of the file. A Blob is used so that the messages are shared with the Worker by + * reference instead of being copied into it for every request. */ - translation?: Record; + translation?: Blob; } // Extract the application files and common options used for inline requests from the Worker context -// TODO: Evaluate overall performance difference of passing translations here as well const { files, missingTranslation, shouldOptimize } = (workerData || {}) as { files: ReadonlyMap; missingTranslation: 'error' | 'warning' | 'ignore'; shouldOptimize: boolean; }; +/** + * The translation messages deserialized for the locale most recently requested of this Worker. + * Locales are inlined one at a time, so retaining only the active locale is enough to avoid + * deserializing the messages once per file while holding at most one set of messages in memory. + */ +let activeTranslation: { locale: string; messages: Promise> } | undefined; + +/** + * Deserializes the translation messages for an inline request, reusing the result for any + * subsequent request that targets the same locale. + * @param request An inline request containing the locale and its serialized messages. + * @returns The translation messages, or undefined if the locale has no translations. + */ +function loadTranslation( + request: InlineFileRequest | InlineCodeRequest, +): Promise> | undefined { + const { locale, translation } = request; + if (!translation) { + return undefined; + } + + if (activeTranslation?.locale !== locale) { + activeTranslation = { + locale, + // Deserializing within the stored promise ensures that concurrent requests for a locale + // share the one deserialization instead of each performing their own. + messages: translation + .arrayBuffer() + .then((buffer) => deserialize(new Uint8Array(buffer)) as Record), + }; + } + + return activeTranslation.messages; +} + /** * 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. @@ -80,7 +119,12 @@ export default async function inlineFile(request: InlineFileRequest) { const code = await data.text(); const map = await files.get(request.filename + '.map')?.text(); - const result = await transformWithOxc(code, map && (JSON.parse(map) as SourceMapInput), request); + const result = await transformWithOxc( + code, + map && (JSON.parse(map) as SourceMapInput), + request, + await loadTranslation(request), + ); return { file: request.filename, @@ -98,7 +142,12 @@ export default async function inlineFile(request: InlineFileRequest) { * @returns An object containing the inlined code. */ export async function inlineCode(request: InlineCodeRequest) { - const result = await transformWithOxc(request.code, undefined, request); + const result = await transformWithOxc( + request.code, + undefined, + request, + await loadTranslation(request), + ); return { output: result.code, @@ -136,12 +185,14 @@ async function loadLocalizeTools(): Promise { * @param code A string containing the JavaScript code to transform. * @param map A sourcemap object for the provided JavaScript code. * @param options The inline request options to use. + * @param translation The translation messages to inline, or undefined for an untranslated locale. * @returns An object containing the code, map, and diagnostics from the transformation. */ async function transformWithOxc( code: string, map: SourceMapInput | undefined, options: InlineFileRequest, + translation: Record | undefined, ) { const { program } = parseSync(options.filename, code, { sourceType: 'unambiguous', @@ -169,10 +220,10 @@ async function transformWithOxc( const [translatedParts, translatedSubstitutions] = translate( diagnostics, - options.translation || {}, + translation || {}, messageParts, node.quasi.expressions.map((_, index) => index), - options.translation === undefined ? 'ignore' : missingTranslation, + translation === undefined ? 'ignore' : missingTranslation, ); // Reconstruct the new template/string literal replacement diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index a6d4b5aeccbb..c09139d058bd 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -9,6 +9,7 @@ import assert from 'node:assert'; import { createHash } from 'node:crypto'; import { extname, join } from 'node:path'; +import { serialize } from 'node:v8'; import { WorkerPool } from '../../utils/worker-pool'; import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files'; import { type PersistentCacheStore, createPersistentCacheStore } from './cache'; @@ -19,6 +20,20 @@ import { type PersistentCacheStore, createPersistentCacheStore } from './cache'; */ const LOCALIZE_KEYWORD = '$localize'; +/** + * Serializes the translation messages for a locale for transfer to an inliner Worker. + * + * A Blob is used because cloning one shares its data by reference, whereas sending the messages + * themselves copies them into a Worker for every request that carries them. A locale can contain + * tens of thousands of messages, which makes that copy the dominant cost of inlining a locale. + * + * @param translation The translation messages for a locale, if the locale has any. + * @returns A Blob containing the serialized messages, or undefined if the locale has none. + */ +function serializeTranslation(translation: Record | undefined): Blob | undefined { + return translation && new Blob([serialize(translation)]); +} + /** * Inlining options that should apply to all transformed code. */ @@ -125,6 +140,10 @@ export class I18nInliner { await this.initCache(); const { shouldOptimize, missingTranslation } = this.options; + + // Serialized once here and then shared by the request for every file of this locale + const translationBlob = serializeTranslation(translation); + // Request inlining for each file that contains localize calls const requests = []; @@ -160,7 +179,11 @@ export class I18nInliner { return cachedResult; } - const result = await this.#workerPool.run({ filename, locale, translation }); + const result = await this.#workerPool.run({ + filename, + locale, + translation: translationBlob, + }); if (this.#cache && cacheKey) { try { // Failure to set the value should not fail the transform @@ -227,7 +250,12 @@ export class I18nInliner { } const { output, messages } = await this.#workerPool.run( - { code: templateCode, filename: templateId, locale, translation }, + { + code: templateCode, + filename: templateId, + locale, + translation: serializeTranslation(translation), + }, { name: 'inlineCode' }, ); From 8b45395fe41764b67103ce9edec4cd4a31887eba Mon Sep 17 00:00:00 2001 From: Matt Lewis Date: Wed, 5 Aug 2026 00:45:39 +0100 Subject: [PATCH 3/4] perf(@angular/build): hash the i18n inline cache key options once per locale Each file's persistent cache key is built from `file.hash`, the filename, and the inline options. The options include the locale's translations, so the multi-megabyte set of messages was fed into a fresh SHA-256 for every localized file: for an application with a few thousand localized chunks that is tens of gigabytes of hash input per build, all of it recomputing the same value. The options are digested once per locale instead, so each file's key is derived from a fixed 32 bytes. This changes the keys, so the first build after this change repopulates the i18n cache. --- .../angular/build/src/tools/esbuild/i18n-inliner.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index c09139d058bd..071097315e5b 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -157,10 +157,12 @@ export class I18nInliner { let cacheResultPromise = Promise.resolve(null); if (this.#cache) { - fileCacheKeyBase ??= Buffer.from( - JSON.stringify({ locale, translation, missingTranslation, shouldOptimize }), - 'utf-8', - ); + // The options are digested here so that each file's key is derived from a fixed number + // of bytes. Hashing the options directly would re-hash the full set of messages, which + // can be several megabytes, once for every file. + fileCacheKeyBase ??= createHash('sha256') + .update(JSON.stringify({ locale, translation, missingTranslation, shouldOptimize })) + .digest(); // NOTE: If additional options are added, this may need to be updated. // TODO: Consider xxhash or similar instead of SHA256 From 688b6c3ac93c469f5232a1822d206d5e1a218f86 Mon Sep 17 00:00:00 2001 From: Matt Lewis Date: Wed, 5 Aug 2026 00:46:12 +0100 Subject: [PATCH 4/4] perf(@angular/build): avoid encoding the inline source map before remapping `generateMap` is `generateDecodedMap` followed by encoding the mappings to VLQ, and remapping decodes whatever it is handed. The encoded string was therefore built only to be parsed straight back, holding two representations of the largest structure involved in inlining a file at the point where a build is already at its peak. `generateDecodedMap` is used instead. The mapping resolution is unchanged: `hires`, `source` and `includeContent` are untouched, and VLQ round-trips integers exactly, so the remapped output is byte-identical. Verified over the output of a 10MB chunk with 488 messages, whose map carries 78,036 mapping segments across 303 sources. --- .../build/src/tools/esbuild/i18n-inliner-worker.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 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 9dd6114b663a..bfec753f6b2d 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import remapping, { type EncodedSourceMap, type SourceMapInput } from '@ampproject/remapping'; +import remapping, { type DecodedSourceMap, type SourceMapInput } from '@ampproject/remapping'; import { MagicString } from 'magic-string'; import assert from 'node:assert'; import { deserialize } from 'node:v8'; @@ -260,12 +260,15 @@ async function transformWithOxc( const outputCode = magicString.toString(); let outputMap; if (map && magicString.hasChanged()) { - const rawMap = magicString.generateMap({ + // A decoded map is generated here rather than an encoded one because remapping decodes its + // inputs. Encoding the mappings only for remapping to immediately decode them again doubles + // the peak memory of the largest structure involved in inlining a file. + const rawMap = magicString.generateDecodedMap({ source: options.filename, includeContent: true, hires: 'boundary', }); - outputMap = remapping([rawMap as EncodedSourceMap, map], () => null); + outputMap = remapping([{ ...rawMap, version: 3 } satisfies DecodedSourceMap, map], () => null); } return {