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..2804e0ef8a9e 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()) { @@ -321,6 +342,7 @@ interface LocalizeCallSite { end: number; messageParts: TemplateStringsArray; expressions: { start: number; end: number }[]; + expressionIndexes: number[]; } /** @@ -381,12 +403,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, }); } } @@ -396,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. * @@ -448,7 +483,7 @@ async function inlineLocalize( diagnostics, translation || {}, callSite.messageParts, - callSite.expressions.map((_, index) => index), + callSite.expressionIndexes, translation === undefined ? 'ignore' : missingTranslation, ); @@ -459,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.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index e8f8e67462c2..7c4e19cb0268 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. */ @@ -177,6 +152,7 @@ export class I18nInliner { #cacheStore: PersistentCacheStore | undefined; #transformedFileCache: Cache | undefined; #translationCache: Cache | undefined; + #generation = 0; readonly #localizeFiles: ReadonlyMap; readonly #unmodifiedFiles: Array; @@ -254,6 +230,7 @@ export class I18nInliner { ): Promise> { await this.initCache(); + const generation = ++this.#generation; const { missingTranslation, localizeVersion } = this.options; const localeList = Array.from(locales); @@ -306,68 +283,64 @@ export class I18nInliner { }), ); - const cacheChecks: CacheCheckItem[] = []; - - for (const filename of filenames) { - const file = this.#localizeFiles.get(filename); - assert(file !== undefined, 'Localize file must exist: ' + filename); - - for (const { locale } of windowLocales) { - let cacheKey: string | undefined; - let cachedResultPromise: Promise = Promise.resolve(null); - - if (this.#transformedFileCache) { - const fileCacheKeyBase = localeCacheBases.get(locale); - assert(fileCacheKeyBase !== undefined, 'Cache base must exist for locale: ' + locale); + const uncachedByFile = new Map(); - const hasher = createContentHash(); - hasher.update(file.hash); - hasher.update(filename); - hasher.update(fileCacheKeyBase); - cacheKey = hasher.digest(); + if (this.#transformedFileCache) { + const cache = this.#transformedFileCache; + const cacheChecks: Promise[] = []; - cachedResultPromise = this.#transformedFileCache - .get(cacheKey) - .then((val) => val ?? null) - .catch(() => null); - } + for (const filename of filenames) { + const file = this.#localizeFiles.get(filename); + assert(file !== undefined, 'Localize file must exist: ' + filename); + + 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), + }; + }, + ); - cacheChecks.push({ - filename, - locale, - cacheKey, - cachedResult: cachedResultPromise, - }); + 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 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(); - - 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), - }); + await Promise.all(cacheChecks); + } else { + for (const filename of filenames) { + uncachedByFile.set( + filename, + windowLocales.map(({ locale }) => ({ + locale, + translation: localeBlobs.get(locale), + })), + ); } } @@ -379,6 +352,7 @@ export class I18nInliner { fileResultsByLocale, activeLocales, isLastWindow, + generation, ); } } @@ -445,6 +419,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); @@ -459,6 +434,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,11 +444,12 @@ 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, + 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..ff86793cef9c 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,51 @@ 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(); + } + }); + + 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!`'); + }); });