From 6ca18679850fcc2d08e8fe978af1f0cae8cb3f17 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:10:25 -0400 Subject: [PATCH 1/2] refactor(@angular/build): cache persistent binary translation buffers in i18n inliner Persist binary SharedArrayBuffer translation layouts on disk in .angular/cache keyed by translation file integrity hash. The I18nInliner creates a dedicated #translationCache instance under namespace 'translations' alongside #transformedFileCache under namespace 'transforms'. During locale serialization, translationCache.getOrCreate deduplicates in-flight encoding and restores pre-built binary buffers directly from disk, skipping translation dictionary binary encoding on warm builds. --- .../build/src/builders/application/i18n.ts | 9 +- .../build/src/tools/esbuild/i18n-inliner.ts | 99 ++++++++++++----- .../src/tools/esbuild/i18n-inliner_spec.ts | 100 ++++++++++++++++++ 3 files changed, 177 insertions(+), 31 deletions(-) diff --git a/packages/angular/build/src/builders/application/i18n.ts b/packages/angular/build/src/builders/application/i18n.ts index 058a2fa3d6a4..5d7efd7548f9 100644 --- a/packages/angular/build/src/builders/application/i18n.ts +++ b/packages/angular/build/src/builders/application/i18n.ts @@ -161,19 +161,20 @@ export async function inlineI18n( if (executionResult.templateUpdates?.size) { // The development server only allows a single locale but issue a warning if used programmatically (experimental) // with multiple locales and template HMR. - if (i18nOptions.inlineLocales.size > 1) { + if (localesToInline.length > 1) { inlineResult.warnings.push( `Component HMR updates can only be inlined with a single locale. The first locale will be used.`, ); } - const firstLocale = [...i18nOptions.inlineLocales][0]; + const targetLocale = localesToInline[0]; for (const [id, content] of executionResult.templateUpdates) { const templateUpdateResult = await inliner.inlineTemplateUpdate( - firstLocale, - i18nOptions.locales[firstLocale].translation, + targetLocale.locale, + targetLocale.translation, content, id, + targetLocale.translationIntegrity, ); executionResult.templateUpdates.set(id, templateUpdateResult.code); inlineResult.errors.push(...templateUpdateResult.errors); diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index 5cca10c534c9..b09229737bf6 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -36,16 +36,46 @@ const DEFAULT_LOCALE_WINDOW_SIZE = 8; * SharedArrayBuffer is unavailable. * * @param translation The translation messages for a locale, if the locale has any. + * @param translationIntegrity Optional content hash of the translation file for cache lookup. + * @param translationCache Optional Cache instance for binary translation buffers. * @returns A SharedArrayBuffer or Blob containing the serialized messages, or undefined if none. */ -function serializeTranslation( +async function serializeTranslation( translation: Record | undefined, -): SharedArrayBuffer | Blob | undefined { + translationIntegrity?: string, + translationCache?: Cache, +): Promise { if (!translation) { return undefined; } if (typeof SharedArrayBuffer !== 'undefined') { + if (translationIntegrity && translationCache) { + // Look up or generate binary translation data in the persistent cache. + // A Uint8Array view is stored in the cache store to allow binary persistence. + const binaryData = await translationCache.getOrCreate(translationIntegrity, () => { + return new Uint8Array(encodeTranslationToBuffer(translation)); + }); + + // On a cache miss, getOrCreate returns the newly created Uint8Array backed by the + // original SharedArrayBuffer. Return it directly to avoid an unnecessary allocation and copy. + if ( + binaryData.buffer instanceof SharedArrayBuffer && + binaryData.byteOffset === 0 && + binaryData.byteLength === binaryData.buffer.byteLength + ) { + return binaryData.buffer; + } + + // On a warm cache hit, the restored data is backed by a standard ArrayBuffer from disk. + // Copy it into a SharedArrayBuffer so worker threads can access it via zero-copy shared memory. + const buffer = new SharedArrayBuffer(binaryData.byteLength); + new Uint8Array(buffer).set(binaryData); + + return buffer; + } + + // When persistent caching is not configured, encode directly into a SharedArrayBuffer. return encodeTranslationToBuffer(translation); } @@ -145,7 +175,8 @@ export class I18nInliner { #cacheInitFailed = false; #workerPool: WorkerPool; #cacheStore: PersistentCacheStore | undefined; - #cache: Cache | undefined; + #transformedFileCache: Cache | undefined; + #translationCache: Cache | undefined; readonly #localizeFiles: ReadonlyMap; readonly #unmodifiedFiles: Array; @@ -239,6 +270,7 @@ export class I18nInliner { const fileResultsByLocale = new Map>(); for (const { locale } of localeList) { + assert(!fileResultsByLocale.has(locale), 'Duplicate locale provided to inliner: ' + locale); fileResultsByLocale.set(locale, new Map()); } @@ -256,23 +288,30 @@ export class I18nInliner { const localeCacheBases = new Map(); const localeBlobs = new Map(); - for (const { locale, translation, translationIntegrity } of windowLocales) { - localeBlobs.set(locale, serializeTranslation(translation)); - - if (this.#cacheStore) { - localeCacheBases.set( - locale, - calculateHash( - JSON.stringify({ - locale, - translation: translationIntegrity || translation, - missingTranslation, - localizeVersion, - }), - ), + await Promise.all( + windowLocales.map(async ({ locale, translation, translationIntegrity }) => { + const serialized = await serializeTranslation( + translation, + translationIntegrity, + this.#translationCache, ); - } - } + localeBlobs.set(locale, serialized); + + if (this.#cacheStore) { + localeCacheBases.set( + locale, + calculateHash( + JSON.stringify({ + locale, + translation: translationIntegrity || translation, + missingTranslation, + localizeVersion, + }), + ), + ); + } + }), + ); const cacheChecks: CacheCheckItem[] = []; @@ -284,7 +323,7 @@ export class I18nInliner { let cacheKey: string | undefined; let cachedResultPromise: Promise = Promise.resolve(null); - if (this.#cache) { + if (this.#transformedFileCache) { const fileCacheKeyBase = localeCacheBases.get(locale); assert(fileCacheKeyBase !== undefined, 'Cache base must exist for locale: ' + locale); @@ -294,7 +333,7 @@ export class I18nInliner { hasher.update(fileCacheKeyBase); cacheKey = hasher.digest(); - cachedResultPromise = this.#cache + cachedResultPromise = this.#transformedFileCache .get(cacheKey) .then((val) => val ?? null) .catch(() => null); @@ -458,8 +497,8 @@ export class I18nInliner { for (const { locale, cacheKey } of batchEntries) { fileResultsByLocale.get(locale)?.set(filename, unmodifiedResult); - if (this.#cache && cacheKey) { - cachePromises.push(this.#cache.put(cacheKey, unmodifiedResult)); + if (this.#transformedFileCache && cacheKey) { + cachePromises.push(this.#transformedFileCache.put(cacheKey, unmodifiedResult)); } } await Promise.allSettled(cachePromises); @@ -469,9 +508,9 @@ export class I18nInliner { const matchingEntry = batchEntries.find((e) => e.locale === res.locale); const cacheKey = matchingEntry?.cacheKey; - if (this.#cache && cacheKey) { + if (this.#transformedFileCache && cacheKey) { cachePromises.push( - this.#cache.put(cacheKey, { + this.#transformedFileCache.put(cacheKey, { file: filename, code: res.code, map: res.map, @@ -519,6 +558,7 @@ export class I18nInliner { translation: Record | undefined, templateCode: string, templateId: string, + translationIntegrity?: string, ): Promise<{ code: string; errors: string[]; warnings: string[] }> { const hasLocalize = templateCode.includes(LOCALIZE_KEYWORD); @@ -535,7 +575,11 @@ export class I18nInliner { code: templateCode, filename: templateId, locale, - translation: serializeTranslation(translation), + translation: await serializeTranslation( + translation, + translationIntegrity, + this.#translationCache, + ), }, { name: 'inlineCode' }, ); @@ -589,7 +633,8 @@ export class I18nInliner { createPersistentCacheStore(join(persistentCachePath, 'angular-i18n')), ]); this.#cacheStore = cacheStore; - this.#cache = cacheStore.createCache('transforms'); + this.#transformedFileCache = cacheStore.createCache('transforms'); + this.#translationCache = cacheStore.createCache('translations'); } 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 d2f5559f5bb2..00d5260fc591 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts @@ -13,6 +13,7 @@ import os from 'node:os'; import path from 'node:path'; import { initializeHash } from '../../utils/hash'; import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files'; +import { createPersistentCacheStore } from './cache'; import { I18nInliner } from './i18n-inliner'; /** @@ -213,6 +214,34 @@ describe('I18nInliner', () => { expect(code).toBe(source); }); + + it('inlines translations with translationIntegrity and persistent caching', async () => { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18n-template-cache-test-')); + + try { + const inliner = new I18nInliner({ + missingTranslation: 'error', + outputFiles: [], + persistentCachePath: cacheDir, + }); + + const translationIntegrity = 'hash-template-integrity-123'; + const { code, errors, warnings } = await inliner.inlineTemplateUpdate( + 'fr', + { greeting: translationFor('Bonjour') }, + GREETING_SOURCE, + 'template-id', + translationIntegrity, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(code).toContain('"Bonjour"'); + await inliner.close(); + } finally { + await fs.rm(cacheDir, { recursive: true, force: true }); + } + }); }); it('safely inlines translations containing special characters, quotes, and newlines', async () => { @@ -723,4 +752,75 @@ describe('I18nInliner', () => { await fs.rm(cacheDir, { recursive: true, force: true }); } }); + + it('caches binary translation buffer in persistent cache store when translationIntegrity is provided', async () => { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18n-inliner-trans-cache-test-')); + + try { + const inliner1 = new I18nInliner({ + missingTranslation: 'error', + outputFiles: [browserFile('main.js', GREETING_SOURCE)], + persistentCachePath: cacheDir, + }); + + const translationIntegrity = 'hash-test-integrity-12345'; + const results1 = await inliner1.inlineAll([ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + translationIntegrity, + }, + ]); + + expect(results1.get('fr')?.errors).toEqual([]); + expect(findFile(results1.get('fr')?.outputFiles ?? [], 'main.js').text).toContain( + '"Bonjour"', + ); + await inliner1.close(); + + // Verify directly that the binary translation buffer was persisted on disk + const store = await createPersistentCacheStore(path.join(cacheDir, 'angular-i18n')); + try { + const translationCache = store.createCache('translations'); + const cachedData = await translationCache.get(translationIntegrity); + expect(cachedData).toBeInstanceOf(Uint8Array); + expect(cachedData?.byteLength).toBeGreaterThan(0); + } finally { + await store.close(); + } + + const inliner2 = new I18nInliner({ + missingTranslation: 'error', + outputFiles: [browserFile('main.js', GREETING_SOURCE)], + persistentCachePath: cacheDir, + }); + + const results2 = await inliner2.inlineAll([ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + translationIntegrity, + }, + ]); + + expect(results2.get('fr')?.errors).toEqual([]); + expect(findFile(results2.get('fr')?.outputFiles ?? [], 'main.js').text).toContain( + '"Bonjour"', + ); + await inliner2.close(); + } finally { + await fs.rm(cacheDir, { recursive: true, force: true }); + } + }); + + it('throws an error when duplicate locales are provided to inlineAll', async () => { + const localeInliner = createInliner([browserFile('main.js', GREETING_SOURCE)]); + + await expectAsync( + localeInliner.inlineAll([ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + { locale: 'fr', translation: { greeting: translationFor('Salut') } }, + ]), + ).toBeRejectedWithError(/Duplicate locale provided to inliner: fr/); + }); }); From 590beb9bdbfc03b4d67f0c7186816375351d97ff Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:36:40 -0400 Subject: [PATCH 2/2] refactor(@angular/build): pass code and sourcemap Blobs on demand per batch request in i18n inliner Streamline WorkerPool initialization by removing global files Map from workerData. Instead of storing all file Blobs in workerData for the worker pool lifetime, pass code and sourcemap Blobs on demand per inlineFileBatch request. On the worker thread, loadFileData consumes the passed code Blob on initial cache misses and continues to cache pre-parsed OXC AST metadata in worker thread memory, preserving 100% of worker AST parsing speed while improving V8 Young Generation garbage collection. --- .../src/tools/esbuild/i18n-inliner-worker.ts | 37 ++++++++++++------- .../build/src/tools/esbuild/i18n-inliner.ts | 13 +++---- 2 files changed, 29 insertions(+), 21 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 e7b11cc53225..430a174326e6 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts @@ -49,11 +49,20 @@ interface InlineCodeRequest { */ interface InlineFileBatchRequest { /** - * The filename that should be processed. The data for the file is provided to the Worker - * during Worker initialization. + * The filename that should be processed. */ filename: string; + /** + * The file content as a Blob. + */ + code: Blob; + + /** + * Optional sourcemap content as a Blob. + */ + map?: Blob; + /** * The locale specifiers and optional translations to use during the inlining process of the file. */ @@ -97,9 +106,8 @@ type InlineFileBatchResult = results: InlineLocaleResult[]; }; -// Extract the application files and common options used for inline requests from the Worker context -const { files, missingTranslation } = (workerData || {}) as { - files: ReadonlyMap; +// Extract common options used for inline requests from the Worker context +const { missingTranslation } = (workerData || {}) as { missingTranslation: 'error' | 'warning' | 'ignore'; }; @@ -128,20 +136,18 @@ const deserializedTranslations = new Map { +function loadFileData(filename: string, codeBlob: Blob, 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 code = await codeBlob.text(); const metadata = extractLocalizeMetadata(filename, code); return { code, metadata }; @@ -209,7 +215,7 @@ export async function inlineFileBatch( } } - const { code, metadata } = await loadFileData(request.filename, !request.ephemeral); + const { code, metadata } = await loadFileData(request.filename, request.code, !request.ephemeral); // Fast path: file has no $localize call sites or locale insert sites if (metadata.callSites.length === 0 && metadata.localeInsertSites.length === 0) { @@ -223,10 +229,13 @@ export async function inlineFileBatch( }; } - // Parse the sourcemap once for the entire batch. + // Parse the sourcemap once for the entire batch if provided. // It will naturally be garbage-collected after this batch action returns. - const rawMap = await files.get(request.filename + '.map')?.text(); - const map = rawMap ? (JSON.parse(rawMap) as SourceMapInput) : undefined; + let map: SourceMapInput | undefined; + if (request.map) { + const rawMap = await request.map.text(); + map = rawMap ? (JSON.parse(rawMap) as SourceMapInput) : undefined; + } const results = await Promise.all( Array.from(request.locales, async ([locale, translation]) => { diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index b09229737bf6..e8f8e67462c2 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -236,13 +236,6 @@ export class I18nInliner { // Extract options to ensure only the named options are serialized and sent to the worker workerData: { missingTranslation, - // 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 - // unneeded repeat copying of potentially large JavaScript files. - files: new Map( - Array.from(files, ([name, file]) => [name, new Blob([file.contents])]), - ), }, }); } @@ -463,6 +456,10 @@ export class I18nInliner { const workerTasks: Promise[] = []; for (const [filename, entries] of uncachedByFile) { + const codeFile = this.#localizeFiles.get(filename); + assert(codeFile !== undefined, 'Localize file must exist: ' + filename); + const mapFile = this.#localizeFiles.get(filename + '.map'); + const ephemeral = isLastWindow && entries.length <= localesPerBatch; for (let i = 0; i < entries.length; i += localesPerBatch) { const batchEntries = entries.slice(i, i + localesPerBatch); @@ -470,6 +467,8 @@ export class I18nInliner { const batchResult = (await this.#workerPool.run( { filename, + code: new Blob([codeFile.contents]), + map: mapFile ? new Blob([mapFile.contents]) : undefined, locales: new Map(batchEntries.map((e) => [e.locale, e.translation])), ephemeral, activeLocales,