From 9a2ed624c6dd56008a71550e5aee3572fd709738 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:27:19 -0400 Subject: [PATCH 1/3] refactor(@angular/build): avoid retaining worker file data for single-batch inlining When processing files where all remaining locales fit into a single batch, the file will only be transformed once across all worker threads. Retaining the parsed AST and source code in the worker's long-term `fileDataCache` leads to monotonic memory growth across multi-file builds without providing any cache reuse. This change marks single-batch requests as `ephemeral`, allowing the worker to extract localization metadata and perform inlining within the execution frame without caching the file data long-term. The parsed AST and code are then naturally garbage-collected when the batch action completes. --- .../src/tools/esbuild/i18n-inliner-worker.ts | 50 +++++++++++++------ .../build/src/tools/esbuild/i18n-inliner.ts | 10 +++- .../src/tools/esbuild/i18n-inliner_spec.ts | 5 ++ 3 files changed, 47 insertions(+), 18 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 379368088ad0..13b14d9623e7 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts @@ -78,6 +78,12 @@ interface InlineFileBatchRequest { * The locale specifiers or locale objects that should be used during the inlining process of the file. */ locales: (string | { locale: string; translation?: Blob })[]; + + /** + * 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. + */ + ephemeral?: boolean; } /** @@ -124,23 +130,35 @@ const fileDataCache = new Map>(); const deserializedTranslations = new Map>>(); /** - * Retrieves the cached file data for a filename, loading and extracting it on the first request. + * 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. + * If `cache` is false (ephemeral), the result is not retained in `fileDataCache`, allowing it + * to be garbage-collected once the batch request finishes. * * @param filename The name of the file to load. - * @returns The cached code and localization metadata. + * @param cache Whether to cache the loaded file data in the Worker's long-term cache. + * @returns The cached or newly extracted code and localization metadata. */ -function getFileData(filename: string): Promise { - let fileDataPromise = fileDataCache.get(filename); - if (!fileDataPromise) { - fileDataPromise = (async () => { - const data = files.get(filename); - assert(data !== undefined, `Invalid inline request for file '${filename}'.`); - - const code = await data.text(); - const metadata = extractLocalizeMetadata(filename, code); - - return { code, metadata }; - })(); +function loadFileData(filename: string, cache = true): Promise { + const existing = fileDataCache.get(filename); + if (existing) { + return existing; + } + + const fileDataPromise = (async () => { + const data = files.get(filename); + assert(data !== undefined, `Invalid inline request for file '${filename}'.`); + + const code = await data.text(); + const metadata = extractLocalizeMetadata(filename, code); + + return { code, metadata }; + })(); + + if (cache) { + fileDataPromise.catch(() => { + fileDataCache.delete(filename); + }); fileDataCache.set(filename, fileDataPromise); } @@ -186,7 +204,7 @@ function loadTranslation( * @returns An object containing the inlined file and optional map content. */ export default async function inlineFile(request: InlineFileRequest) { - const { code, metadata } = await getFileData(request.filename); + 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. @@ -219,7 +237,7 @@ export default async function inlineFile(request: InlineFileRequest) { export async function inlineFileBatch( request: InlineFileBatchRequest, ): Promise { - const { code, metadata } = await getFileData(request.filename); + const { code, metadata } = await loadFileData(request.filename, !request.ephemeral); // Parse the sourcemap once for the entire batch. // It will naturally be garbage-collected after this batch action returns. diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index 535aae960b34..983f17956b31 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -9,7 +9,7 @@ import assert from 'node:assert'; import { extname, join } from 'node:path'; import { serialize } from 'node:v8'; -import { calculateHash, createContentHash } from '../../utils/hash'; +import { calculateHash, createContentHash, initializeHash } from '../../utils/hash'; import { WorkerPool } from '../../utils/worker-pool'; import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files'; import { type PersistentCacheStore, createPersistentCacheStore } from './cache'; @@ -377,6 +377,7 @@ export class I18nInliner { const workerTasks: Promise[] = []; for (const [filename, entries] of uncachedByFile) { + const ephemeral = entries.length <= localesPerBatch; for (let i = 0; i < entries.length; i += localesPerBatch) { const batchEntries = entries.slice(i, i + localesPerBatch); const task = (async () => { @@ -387,6 +388,7 @@ export class I18nInliner { locale: e.locale, translation: e.translation, })), + ephemeral, }, { name: 'inlineFileBatch' }, )) as { @@ -518,7 +520,11 @@ export class I18nInliner { // Initialize a persistent cache for i18n transformations. try { - this.#cache = await createPersistentCacheStore(join(persistentCachePath, 'angular-i18n')); + const [, cache] = await Promise.all([ + initializeHash(), + createPersistentCacheStore(join(persistentCachePath, 'angular-i18n')), + ]); + this.#cache = cache; } catch { this.#cacheInitFailed = true; 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 d71f9c0a8830..e8fefeb3a14f 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts @@ -10,6 +10,7 @@ import { transform } from 'esbuild'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { initializeHash } from '../../utils/hash'; import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files'; import { I18nInliner } from './i18n-inliner'; @@ -43,6 +44,10 @@ function findFile(outputFiles: BuildOutputFile[], path: string): BuildOutputFile describe('I18nInliner', () => { let inliner: I18nInliner | undefined; + beforeAll(async () => { + await initializeHash(); + }); + // 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 { From 625ee76a0a4c518575b828a6dfda7a5cf60d5bc8 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:57:11 -0400 Subject: [PATCH 2/3] perf(@angular/build): implement sliding-window batching and worker translation eviction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In enterprise Angular applications with a high number of locales (e.g. 20–50+), deserializing translation dictionaries into native JavaScript objects across all worker threads simultaneously can lead to multi-gigabyte resident memory footprints in V8 worker heaps. This change introduces sliding-window locale batching and lock-free translation eviction: - `I18nInliner.inlineAll` processes locales in sliding windows of up to 8 locales each (`DEFAULT_LOCALE_WINDOW_SIZE`), capping peak memory while retaining maximum multi-locale batching throughput. - Worker tasks receive an `activeLocales` array on each batch request. Workers automatically purge any cached translation dictionaries in `deserializedTranslations` that are not part of the active window when transitioning across window boundaries. --- .../src/tools/esbuild/i18n-inliner-worker.ts | 15 ++ .../build/src/tools/esbuild/i18n-inliner.ts | 180 ++++++++++-------- .../src/tools/esbuild/i18n-inliner_spec.ts | 24 +++ 3 files changed, 140 insertions(+), 79 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 13b14d9623e7..a0ca9e25e6f2 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts @@ -84,6 +84,12 @@ interface InlineFileBatchRequest { * Typically true when all remaining locales for the file are processed in a single batch. */ ephemeral?: boolean; + + /** + * The list of active locales in the current inlining window. Any cached translation dictionaries + * not present in this list will be evicted from the Worker's memory cache. + */ + activeLocales?: string[]; } /** @@ -237,6 +243,15 @@ export default async function inlineFile(request: InlineFileRequest) { export async function inlineFileBatch( request: InlineFileBatchRequest, ): Promise { + if (request.activeLocales) { + const activeSet = new Set(request.activeLocales); + for (const locale of deserializedTranslations.keys()) { + if (!activeSet.has(locale)) { + deserializedTranslations.delete(locale); + } + } + } + const { code, metadata } = await loadFileData(request.filename, !request.ephemeral); // Parse the sourcemap once for the entire batch. diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index 983f17956b31..61ff460579c1 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -20,6 +20,12 @@ import { type PersistentCacheStore, createPersistentCacheStore } from './cache'; */ const LOCALIZE_KEYWORD = '$localize'; +/** + * The maximum number of locales to process concurrently in a single sliding window. + * This caps peak worker memory while maintaining multi-locale batching throughput. + */ +const DEFAULT_LOCALE_WINDOW_SIZE = 8; + /** * Serializes the translation messages for a locale for transfer to an inliner Worker. * @@ -218,101 +224,114 @@ export class I18nInliner { fileResultsByLocale.set(locale, new Map()); } - // Pre-calculate cache key bases and serialized Blobs for each requested locale - const localeCacheBases = new Map(); - const localeBlobs = new Map(); - - for (const { locale, translation, translationIntegrity } of localeList) { - localeBlobs.set(locale, serializeTranslation(translation)); - - if (this.#cache) { - localeCacheBases.set( - locale, - calculateHash( - JSON.stringify({ - locale, - translation: translationIntegrity || translation, - missingTranslation, - shouldOptimize, - localizeVersion, - }), - ), - ); - } - } - const filenames = Array.from(this.#localizeFiles.keys()).filter( (name) => !name.endsWith('.map'), ); - const cacheChecks: CacheCheckItem[] = []; + // Process locales in sliding windows to cap peak worker memory + for (let i = 0; i < localeList.length; i += DEFAULT_LOCALE_WINDOW_SIZE) { + const windowLocales = localeList.slice(i, i + DEFAULT_LOCALE_WINDOW_SIZE); + const activeLocales = windowLocales.map((item) => item.locale); + const isLastWindow = i + DEFAULT_LOCALE_WINDOW_SIZE >= localeList.length; - for (const filename of filenames) { - const file = this.#localizeFiles.get(filename); - assert(file !== undefined, 'Localize file must exist: ' + filename); + // Pre-calculate cache key bases and serialized Blobs for each locale in this window + const localeCacheBases = new Map(); + const localeBlobs = new Map(); - for (const { locale } of localeList) { - let cacheKey: string | undefined; - let cachedResultPromise: Promise = Promise.resolve(null); + for (const { locale, translation, translationIntegrity } of windowLocales) { + localeBlobs.set(locale, serializeTranslation(translation)); if (this.#cache) { - const fileCacheKeyBase = localeCacheBases.get(locale); - assert(fileCacheKeyBase !== undefined, 'Cache base must exist for locale: ' + locale); + localeCacheBases.set( + locale, + calculateHash( + JSON.stringify({ + locale, + translation: translationIntegrity || translation, + missingTranslation, + shouldOptimize, + localizeVersion, + }), + ), + ); + } + } - const hasher = createContentHash(); - hasher.update(file.hash); - hasher.update(filename); - hasher.update(fileCacheKeyBase); - cacheKey = hasher.digest(); + const cacheChecks: CacheCheckItem[] = []; - cachedResultPromise = this.#cache.get(cacheKey).catch(() => null); - } + for (const filename of filenames) { + const file = this.#localizeFiles.get(filename); + assert(file !== undefined, 'Localize file must exist: ' + filename); - cacheChecks.push({ - filename, - locale, - cacheKey, - cachedResult: cachedResultPromise, - }); - } - } + for (const { locale } of windowLocales) { + let cacheKey: string | undefined; + let cachedResultPromise: Promise = Promise.resolve(null); - // Await all cache checks - const resolvedChecks = await Promise.all( - cacheChecks.map(async (item) => ({ - ...item, - result: await item.cachedResult, - })), - ); + if (this.#cache) { + const fileCacheKeyBase = localeCacheBases.get(locale); + assert(fileCacheKeyBase !== undefined, 'Cache base must exist for locale: ' + locale); - // Group uncached items by filename - const uncachedByFile = new Map< - string, - Array<{ locale: string; cacheKey?: string; translation?: Blob }> - >(); + const hasher = createContentHash(); + hasher.update(file.hash); + hasher.update(filename); + hasher.update(fileCacheKeyBase); + cacheKey = hasher.digest(); - 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); + cachedResultPromise = this.#cache.get(cacheKey).catch(() => null); + } + + cacheChecks.push({ + filename, + locale, + cacheKey, + cachedResult: cachedResultPromise, + }); } - fileEntries.push({ - locale: item.locale, - cacheKey: item.cacheKey, - translation: localeBlobs.get(item.locale), - }); } - } - // Adaptive 2D Sharding for uncached tasks - if (uncachedByFile.size > 0) { - await this.#processUncachedBatches(uncachedByFile, localeList.length, fileResultsByLocale); + // 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< + string, + Array<{ locale: string; cacheKey?: string; translation?: Blob }> + >(); + + 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); + } + fileEntries.push({ + locale: item.locale, + cacheKey: item.cacheKey, + translation: localeBlobs.get(item.locale), + }); + } + } + + // Adaptive 2D Sharding for uncached tasks in this window + if (uncachedByFile.size > 0) { + await this.#processUncachedBatches( + uncachedByFile, + windowLocales.length, + fileResultsByLocale, + activeLocales, + isLastWindow, + ); + } } // Assemble final results in deterministic order per locale @@ -366,6 +385,8 @@ export class I18nInliner { uncachedByFile: Map>, localeCount: number, fileResultsByLocale: Map>, + activeLocales?: string[], + isLastWindow = true, ): Promise { const workerCount = this.#workerPool.maxThreads || 1; const targetTaskCount = Math.max(uncachedByFile.size, workerCount * 2); @@ -377,7 +398,7 @@ export class I18nInliner { const workerTasks: Promise[] = []; for (const [filename, entries] of uncachedByFile) { - const ephemeral = entries.length <= localesPerBatch; + const ephemeral = isLastWindow && entries.length <= localesPerBatch; for (let i = 0; i < entries.length; i += localesPerBatch) { const batchEntries = entries.slice(i, i + localesPerBatch); const task = (async () => { @@ -389,6 +410,7 @@ export class I18nInliner { translation: e.translation, })), ephemeral, + activeLocales, }, { 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 e8fefeb3a14f..90b9a9779a6d 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts @@ -516,4 +516,28 @@ describe('I18nInliner', () => { await fs.rm(cacheDir, { recursive: true, force: true }); } }); + + it('inlines across sliding windows when locale count exceeds window size', async () => { + const locales = Array.from({ length: 20 }, (_, i) => ({ + locale: `locale-${i}`, + translation: { greeting: translationFor(`Hello ${i}`) }, + })); + + const inliner = new I18nInliner( + { + missingTranslation: 'warning', + outputFiles: [browserFile('main.js', GREETING_SOURCE)], + }, + 2, + ); + + const results = await inliner.inlineAll(locales); + + expect(results.size).toBe(20); + for (let i = 0; i < 20; i++) { + const localeResult = results.get(`locale-${i}`); + expect(localeResult?.errors).toEqual([]); + expect(findFile(localeResult?.outputFiles ?? [], 'main.js').text).toContain(`"Hello ${i}"`); + } + }); }); From f3df78b4f73d1b4a702be169cfed1d614be62bbb Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:36:34 -0400 Subject: [PATCH 3/3] refactor(@angular/build): use namespaced Cache abstraction in i18n inliner Updates I18nInliner to instantiate and use a namespaced Cache instance (cacheStore.createCache('transforms')) instead of directly invoking lower-level CacheStore methods (get/set). Using the Cache abstraction ensures standard namespace partitioning within the angular-i18n persistent cache store, automatic in-flight promise management, and proper get/put promise semantics. --- .../build/src/tools/esbuild/i18n-inliner.ts | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index 61ff460579c1..b8ad6cdedcab 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -12,7 +12,7 @@ import { serialize } from 'node:v8'; import { calculateHash, createContentHash, initializeHash } from '../../utils/hash'; import { WorkerPool } from '../../utils/worker-pool'; import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files'; -import { type PersistentCacheStore, createPersistentCacheStore } from './cache'; +import { type Cache, type PersistentCacheStore, createPersistentCacheStore } from './cache'; /** * A keyword used to indicate if a JavaScript file may require inlining of translations. @@ -125,7 +125,8 @@ interface CacheCheckItem { export class I18nInliner { #cacheInitFailed = false; #workerPool: WorkerPool; - #cache: PersistentCacheStore | undefined; + #cacheStore: PersistentCacheStore | undefined; + #cache: Cache | undefined; readonly #localizeFiles: ReadonlyMap; readonly #unmodifiedFiles: Array; @@ -241,7 +242,7 @@ export class I18nInliner { for (const { locale, translation, translationIntegrity } of windowLocales) { localeBlobs.set(locale, serializeTranslation(translation)); - if (this.#cache) { + if (this.#cacheStore) { localeCacheBases.set( locale, calculateHash( @@ -277,7 +278,10 @@ export class I18nInliner { hasher.update(fileCacheKeyBase); cacheKey = hasher.digest(); - cachedResultPromise = this.#cache.get(cacheKey).catch(() => null); + cachedResultPromise = this.#cache + .get(cacheKey) + .then((val) => val ?? null) + .catch(() => null); } cacheChecks.push({ @@ -424,18 +428,13 @@ export class I18nInliner { const cacheKey = matchingEntry?.cacheKey; if (this.#cache && cacheKey) { - // `CacheStore.set` may return `this` synchronously or a `Promise`. - // `Promise.resolve` normalizes both return values into a Promise so `Promise.allSettled` - // can safely handle any synchronous or asynchronous cache store errors. cachePromises.push( - Promise.resolve( - this.#cache.set(cacheKey, { - file: filename, - code: res.code, - map: res.map, - messages: res.messages, - }), - ), + this.#cache.put(cacheKey, { + file: filename, + code: res.code, + map: res.map, + messages: res.messages, + }), ); } @@ -520,7 +519,7 @@ export class I18nInliner { * @returns A void promise that resolves when closing is complete. */ async close(): Promise { - await Promise.allSettled([this.#cache?.close(), this.#workerPool.destroy()]); + await Promise.allSettled([this.#cacheStore?.close(), this.#workerPool.destroy()]); } /** @@ -530,7 +529,7 @@ export class I18nInliner { * @returns A promise that resolves once the cache initialization process is complete. */ private async initCache(): Promise { - if (this.#cache || this.#cacheInitFailed) { + if (this.#cacheStore || this.#cacheInitFailed) { return; } @@ -542,11 +541,12 @@ export class I18nInliner { // Initialize a persistent cache for i18n transformations. try { - const [, cache] = await Promise.all([ + const [, cacheStore] = await Promise.all([ initializeHash(), createPersistentCacheStore(join(persistentCachePath, 'angular-i18n')), ]); - this.#cache = cache; + this.#cacheStore = cacheStore; + this.#cache = cacheStore.createCache('transforms'); } catch { this.#cacheInitFailed = true;