From 7ce3b5963f31f65a4d7121aef8ebe79d807571c2 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:18:10 -0400 Subject: [PATCH 1/6] refactor(@angular/build): precompute expression indexes in i18n inliner worker Previously, callSite.expressions.map((_, index) => index) was evaluated on every $localize call site for every locale inside inlineLocalize. In bundles with hundreds of call sites processed across multiple locales in a batch, this resulted in thousands of redundant small array allocations. expressionIndexes is now precomputed once during AST extraction in extractLocalizeMetadata and stored on LocalizeCallSite, eliminating per-locale index array allocations in the worker transformation loop. --- .../angular/build/src/tools/esbuild/i18n-inliner-worker.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 269341c3df81..d3f72808b90d 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts @@ -321,6 +321,7 @@ interface LocalizeCallSite { end: number; messageParts: TemplateStringsArray; expressions: { start: number; end: number }[]; + expressionIndexes: number[]; } /** @@ -381,12 +382,14 @@ function extractLocalizeMetadata(filename: string, code: string): FileLocalizeMe start: expr.start, end: expr.end, })); + const expressionIndexes = expressions.map((_, index) => index); callSites.push({ start: node.start, end: node.end, messageParts, expressions, + expressionIndexes, }); } } @@ -448,7 +451,7 @@ async function inlineLocalize( diagnostics, translation || {}, callSite.messageParts, - callSite.expressions.map((_, index) => index), + callSite.expressionIndexes, translation === undefined ? 'ignore' : missingTranslation, ); From 76a29b44364cd4fffe2f618570e754afecbc5da3 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:20:58 -0400 Subject: [PATCH 2/6] refactor(@angular/build): hoist blob instantiation in i18n inliner batch loop Instantiate codeBlob and mapBlob once per file outside the batch loop instead of re-creating new Blob instances on each batch slice. Blobs are immutable, read-only binary handles and can be safely shared across multiple worker batch tasks concurrently. --- packages/angular/build/src/tools/esbuild/i18n-inliner.ts | 6 ++++-- 1 file changed, 4 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 e8f8e67462c2..c1fc01455c73 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -459,6 +459,8 @@ export class I18nInliner { const codeFile = this.#localizeFiles.get(filename); assert(codeFile !== undefined, 'Localize file must exist: ' + filename); const mapFile = this.#localizeFiles.get(filename + '.map'); + const codeBlob = new Blob([codeFile.contents]); + const mapBlob = mapFile ? new Blob([mapFile.contents]) : undefined; const ephemeral = isLastWindow && entries.length <= localesPerBatch; for (let i = 0; i < entries.length; i += localesPerBatch) { @@ -467,8 +469,8 @@ export class I18nInliner { const batchResult = (await this.#workerPool.run( { filename, - code: new Blob([codeFile.contents]), - map: mapFile ? new Blob([mapFile.contents]) : undefined, + code: codeBlob, + map: mapBlob, locales: new Map(batchEntries.map((e) => [e.locale, e.translation])), ephemeral, activeLocales, From 634a8942bf0be84ee32de1c823ca9c17d5b50246 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:31:46 -0400 Subject: [PATCH 3/6] refactor(@angular/build): streamline cache check resolution in i18n inliner Streamline the asynchronous cache resolution in inlineAll by removing the intermediate CacheCheckItem interface and array mapping allocations. Results are now assigned directly to fileResultsByLocale on hit or pushed to uncachedByFile on miss. When no persistent cache is configured, a fast path directly queues uncached files without promise or hash overhead. --- .../build/src/tools/esbuild/i18n-inliner.ts | 119 +++++++----------- 1 file changed, 48 insertions(+), 71 deletions(-) diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index c1fc01455c73..b2d32da7f8bd 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -131,31 +131,6 @@ interface TransformedFileResult { messages: { type: 'error' | 'warning'; message: string }[]; } -/** - * Represents an in-flight asynchronous cache lookup for a single (file x locale) transformation. - */ -interface CacheCheckItem { - /** - * The relative file path of the JavaScript file to transform. - */ - filename: string; - - /** - * The locale specifier being targeted for translation. - */ - locale: string; - - /** - * The computed cache key hash, or undefined if persistent caching is not configured. - */ - cacheKey: string | undefined; - - /** - * A promise that resolves to the cached transform result, or null if uncached or on lookup failure. - */ - cachedResult: Promise; -} - /** * An uncached transformation request entry for a file within a specific locale. */ @@ -306,17 +281,18 @@ export class I18nInliner { }), ); - const cacheChecks: CacheCheckItem[] = []; + const uncachedByFile = new Map(); - for (const filename of filenames) { - const file = this.#localizeFiles.get(filename); - assert(file !== undefined, 'Localize file must exist: ' + filename); + if (this.#transformedFileCache) { + const cacheChecks: Promise[] = []; - for (const { locale } of windowLocales) { - let cacheKey: string | undefined; - let cachedResultPromise: Promise = Promise.resolve(null); + for (const filename of filenames) { + const file = this.#localizeFiles.get(filename); + assert(file !== undefined, 'Localize file must exist: ' + filename); + + const fileEntries: UncachedLocaleEntry[] = []; - if (this.#transformedFileCache) { + for (const { locale } of windowLocales) { const fileCacheKeyBase = localeCacheBases.get(locale); assert(fileCacheKeyBase !== undefined, 'Cache base must exist for locale: ' + locale); @@ -324,50 +300,51 @@ export class I18nInliner { hasher.update(file.hash); hasher.update(filename); hasher.update(fileCacheKeyBase); - cacheKey = hasher.digest(); - - cachedResultPromise = this.#transformedFileCache - .get(cacheKey) - .then((val) => val ?? null) - .catch(() => null); + const cacheKey = hasher.digest(); + + cacheChecks.push( + this.#transformedFileCache + .get(cacheKey) + .then((result) => { + if (result) { + fileResultsByLocale.get(locale)?.set(filename, result); + } else { + fileEntries.push({ + locale, + cacheKey, + translation: localeBlobs.get(locale), + }); + } + }) + .catch(() => { + fileEntries.push({ + locale, + cacheKey, + translation: localeBlobs.get(locale), + }); + }), + ); } - cacheChecks.push({ - filename, - locale, - cacheKey, - cachedResult: cachedResultPromise, - }); + uncachedByFile.set(filename, fileEntries); } - } - - // Await all cache checks for this window - const resolvedChecks = await Promise.all( - cacheChecks.map(async (item) => ({ - ...item, - result: await item.cachedResult, - })), - ); - // Group uncached items by filename for this window - const uncachedByFile = new Map(); + await Promise.all(cacheChecks); - for (const item of resolvedChecks) { - if (item.result) { - // Cache hit: store directly in locale file results - fileResultsByLocale.get(item.locale)?.set(item.filename, item.result); - } else { - // Cache miss: needs worker processing - let fileEntries = uncachedByFile.get(item.filename); - if (!fileEntries) { - fileEntries = []; - uncachedByFile.set(item.filename, fileEntries); + for (const [filename, entries] of uncachedByFile) { + if (entries.length === 0) { + uncachedByFile.delete(filename); } - fileEntries.push({ - locale: item.locale, - cacheKey: item.cacheKey, - translation: localeBlobs.get(item.locale), - }); + } + } else { + for (const filename of filenames) { + uncachedByFile.set( + filename, + windowLocales.map(({ locale }) => ({ + locale, + translation: localeBlobs.get(locale), + })), + ); } } From 2bdd441ff9ab6c2c812271773cbb76ac6f897739 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:55:11 -0400 Subject: [PATCH 4/6] refactor(@angular/build): support generation-based worker cache clearing in i18n inliner Introduce generation tracking in I18nInliner and worker batch tasks to guarantee long-term worker caches (fileDataCache and deserializedTranslations) are wiped whenever a new inlining pass or rebuild begins (e.g. watch mode or shared worker pools). --- .../src/tools/esbuild/i18n-inliner-worker.ts | 21 +++++++++++++ .../build/src/tools/esbuild/i18n-inliner.ts | 5 ++++ .../src/tools/esbuild/i18n-inliner_spec.ts | 30 +++++++++++++++++++ 3 files changed, 56 insertions(+) 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 d3f72808b90d..d19de3d6d4b7 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts @@ -80,6 +80,12 @@ interface InlineFileBatchRequest { * not present in this list will be evicted from the Worker's memory cache. */ activeLocales?: string[]; + + /** + * The current inlining generation counter. When a request with a new generation is received, + * all long-term worker caches are cleared. + */ + generation?: number; } /** @@ -130,6 +136,11 @@ const fileDataCache = new Map>(); */ const deserializedTranslations = new Map>>(); +/** + * The current inlining generation for this worker. + */ +let currentGeneration: number | undefined; + /** * Retrieves the file data for a filename, loading and extracting localization metadata. * If `cache` is true, the result is cached in `fileDataCache` across requests in this Worker. @@ -144,6 +155,10 @@ const deserializedTranslations = new Map { const existing = fileDataCache.get(filename); if (existing) { + if (!cache) { + fileDataCache.delete(filename); + } + return existing; } @@ -207,6 +222,12 @@ function loadTranslation( export async function inlineFileBatch( request: InlineFileBatchRequest, ): Promise { + if (request.generation !== undefined && request.generation !== currentGeneration) { + currentGeneration = request.generation; + fileDataCache.clear(); + deserializedTranslations.clear(); + } + if (request.activeLocales) { const activeSet = new Set(request.activeLocales); for (const locale of deserializedTranslations.keys()) { diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index b2d32da7f8bd..2a3c97f0aed0 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -152,6 +152,7 @@ export class I18nInliner { #cacheStore: PersistentCacheStore | undefined; #transformedFileCache: Cache | undefined; #translationCache: Cache | undefined; + #generation = 0; readonly #localizeFiles: ReadonlyMap; readonly #unmodifiedFiles: Array; @@ -229,6 +230,7 @@ export class I18nInliner { ): Promise> { await this.initCache(); + const generation = ++this.#generation; const { missingTranslation, localizeVersion } = this.options; const localeList = Array.from(locales); @@ -356,6 +358,7 @@ export class I18nInliner { fileResultsByLocale, activeLocales, isLastWindow, + generation, ); } } @@ -422,6 +425,7 @@ export class I18nInliner { fileResultsByLocale: Map>, activeLocales?: string[], isLastWindow = true, + generation?: number, ): Promise { const workerCount = this.#workerPool.maxThreads || 1; const targetTaskCount = Math.max(uncachedByFile.size, workerCount * 2); @@ -451,6 +455,7 @@ export class I18nInliner { locales: new Map(batchEntries.map((e) => [e.locale, e.translation])), ephemeral, activeLocales, + generation, }, { name: 'inlineFileBatch' }, )) as 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 00d5260fc591..21c42d66777d 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts @@ -823,4 +823,34 @@ describe('I18nInliner', () => { ]), ).toBeRejectedWithError(/Duplicate locale provided to inliner: fr/); }); + + it('correctly transforms files across multiple inlineAll runs on the same inliner instance', async () => { + const localeInliner = new I18nInliner( + { + missingTranslation: 'warning', + outputFiles: [browserFile('main.js', GREETING_SOURCE)], + }, + 2, + ); + + try { + // First generation + const results1 = await localeInliner.inlineAll([ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + ]); + expect(findFile(results1.get('fr')?.outputFiles ?? [], 'main.js').text).toContain( + '"Bonjour"', + ); + + // Second generation (e.g. watch mode rebuild with updated translation) + const results2 = await localeInliner.inlineAll([ + { locale: 'fr', translation: { greeting: translationFor('Salut') } }, + { locale: 'de', translation: { greeting: translationFor('Hallo') } }, + ]); + expect(findFile(results2.get('fr')?.outputFiles ?? [], 'main.js').text).toContain('"Salut"'); + expect(findFile(results2.get('de')?.outputFiles ?? [], 'main.js').text).toContain('"Hallo"'); + } finally { + await localeInliner.close(); + } + }); }); From d329040eca7c8b369dc8a95b7c9615d030568949 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:32:43 -0400 Subject: [PATCH 5/6] refactor(@angular/build): use single pass template literal escaping in i18n inliner worker Consolidate the 3 chained string replacement passes (unescaping double quotes, escaping backticks, and escaping ${ delimiters) into a single regex pass in escapeTemplatePart. This eliminates 3 intermediate string allocations and reduces 3 regex scans to 1 on every translated template literal part. --- .../src/tools/esbuild/i18n-inliner-worker.ts | 18 ++++++++++++------ .../src/tools/esbuild/i18n-inliner_spec.ts | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 6 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 d19de3d6d4b7..2804e0ef8a9e 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts @@ -420,6 +420,17 @@ function extractLocalizeMetadata(filename: string, code: string): FileLocalizeMe return { callSites, localeInsertSites, diagnostics }; } +/** + * Escapes a template literal string part for insertion into an ES template literal (backticks). + * Uses JSON.stringify for base escaping of control characters and backslashes, then unescapes + * double quotes and escapes backticks and `${` expression delimiters in a single pass. + */ +function escapeTemplatePart(part: string): string { + return JSON.stringify(part) + .slice(1, -1) + .replace(/\\"|`|\$\{/g, (match) => (match === '\\"' ? '"' : '\\' + match)); +} + /** * Inlines translations into code using previously extracted localization metadata. * @@ -483,12 +494,7 @@ async function inlineLocalize( } else { replacement = '`'; for (let i = 0; i < translatedParts.length; i++) { - const escapedPart = JSON.stringify(translatedParts[i]) - .slice(1, -1) - .replace(/\\"/g, '"') - .replace(/`/g, '\\`') - .replace(/\$\{/g, '\\${'); - replacement += escapedPart; + replacement += escapeTemplatePart(translatedParts[i]); if (i < translatedSubstitutions.length) { const originalIndex = translatedSubstitutions[i]; 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 21c42d66777d..ff86793cef9c 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts @@ -853,4 +853,21 @@ describe('I18nInliner', () => { await localeInliner.close(); } }); + + it('correctly escapes backticks, double quotes, and expression delimiters in translated template literals', async () => { + const source = 'export const msg = $localize`:@@msg:Hello ${name}:name:!`;\n'; + const inliner = createInliner([browserFile('main.js', source)]); + + const results = await inliner.inlineAll([ + { + locale: 'fr', + translation: { + msg: parsedTranslation(['Bonjour "', '` with ${injected} and \\backslash!'], ['name']), + }, + }, + ]); + + const outputText = findFile(results.get('fr')?.outputFiles ?? [], 'main.js').text; + expect(outputText).toContain('`Bonjour "${name}\\` with \\${injected} and \\\\backslash!`'); + }); }); From f80745ec249ba9d3d6322d082a19a0139cc446c5 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:19:36 -0400 Subject: [PATCH 6/6] refactor(@angular/build): ensure deterministic locale ordering in cache checks Resolve asynchronous cache checks on a per-file basis using Promise.all to ensure windowLocales ordering is preserved within uncachedByFile regardless of disk I/O resolution timing. Also avoids pre-populating and deleting empty arrays in uncachedByFile. --- .../build/src/tools/esbuild/i18n-inliner.ts | 78 +++++++++---------- 1 file changed, 36 insertions(+), 42 deletions(-) diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index 2a3c97f0aed0..7c4e19cb0268 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -286,58 +286,52 @@ export class I18nInliner { const uncachedByFile = new Map(); if (this.#transformedFileCache) { + const cache = this.#transformedFileCache; const cacheChecks: Promise[] = []; for (const filename of filenames) { const file = this.#localizeFiles.get(filename); assert(file !== undefined, 'Localize file must exist: ' + filename); - const fileEntries: UncachedLocaleEntry[] = []; - - for (const { locale } of windowLocales) { - const fileCacheKeyBase = localeCacheBases.get(locale); - assert(fileCacheKeyBase !== undefined, 'Cache base must exist for locale: ' + locale); - - const hasher = createContentHash(); - hasher.update(file.hash); - hasher.update(filename); - hasher.update(fileCacheKeyBase); - const cacheKey = hasher.digest(); - - cacheChecks.push( - this.#transformedFileCache - .get(cacheKey) - .then((result) => { - if (result) { - fileResultsByLocale.get(locale)?.set(filename, result); - } else { - fileEntries.push({ - locale, - cacheKey, - translation: localeBlobs.get(locale), - }); - } - }) - .catch(() => { - fileEntries.push({ - locale, - cacheKey, - translation: localeBlobs.get(locale), - }); - }), - ); - } + const fileEntriesPromises = windowLocales.map( + async ({ locale }): Promise => { + const fileCacheKeyBase = localeCacheBases.get(locale); + assert(fileCacheKeyBase !== undefined, 'Cache base must exist for locale: ' + locale); + + const hasher = createContentHash(); + hasher.update(file.hash); + hasher.update(filename); + hasher.update(fileCacheKeyBase); + const cacheKey = hasher.digest(); + + try { + const result = await cache.get(cacheKey); + if (result) { + fileResultsByLocale.get(locale)?.set(filename, result); + + return; + } + } catch {} + + return { + locale, + cacheKey, + translation: localeBlobs.get(locale), + }; + }, + ); - uncachedByFile.set(filename, fileEntries); + cacheChecks.push( + Promise.all(fileEntriesPromises).then((entries) => { + const filtered = entries.filter((e): e is UncachedLocaleEntry => e !== undefined); + if (filtered.length > 0) { + uncachedByFile.set(filename, filtered); + } + }), + ); } await Promise.all(cacheChecks); - - for (const [filename, entries] of uncachedByFile) { - if (entries.length === 0) { - uncachedByFile.delete(filename); - } - } } else { for (const filename of filenames) { uncachedByFile.set(