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-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 5cca10c534c9..e8f8e67462c2 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; @@ -205,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])]), - ), }, }); } @@ -239,6 +263,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 +281,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 +316,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 +326,7 @@ export class I18nInliner { hasher.update(fileCacheKeyBase); cacheKey = hasher.digest(); - cachedResultPromise = this.#cache + cachedResultPromise = this.#transformedFileCache .get(cacheKey) .then((val) => val ?? null) .catch(() => null); @@ -424,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); @@ -431,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, @@ -458,8 +496,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 +507,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 +557,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 +574,11 @@ export class I18nInliner { code: templateCode, filename: templateId, locale, - translation: serializeTranslation(translation), + translation: await serializeTranslation( + translation, + translationIntegrity, + this.#translationCache, + ), }, { name: 'inlineCode' }, ); @@ -589,7 +632,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/); + }); });