Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -130,6 +136,11 @@ const fileDataCache = new Map<string, Promise<CachedFileData>>();
*/
const deserializedTranslations = new Map<string, Promise<Record<string, ɵParsedTranslation>>>();

/**
* 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.
Expand All @@ -144,6 +155,10 @@ const deserializedTranslations = new Map<string, Promise<Record<string, ɵParsed
function loadFileData(filename: string, codeBlob: Blob, cache = true): Promise<CachedFileData> {
const existing = fileDataCache.get(filename);
if (existing) {
if (!cache) {
fileDataCache.delete(filename);
}

return existing;
}

Expand Down Expand Up @@ -207,6 +222,12 @@ function loadTranslation(
export async function inlineFileBatch(
request: InlineFileBatchRequest,
): Promise<InlineFileBatchResult> {
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()) {
Expand Down Expand Up @@ -321,6 +342,7 @@ interface LocalizeCallSite {
end: number;
messageParts: TemplateStringsArray;
expressions: { start: number; end: number }[];
expressionIndexes: number[];
}

/**
Expand Down Expand Up @@ -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,
});
}
}
Expand All @@ -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.
*
Expand Down Expand Up @@ -448,7 +483,7 @@ async function inlineLocalize(
diagnostics,
translation || {},
callSite.messageParts,
callSite.expressions.map((_, index) => index),
callSite.expressionIndexes,
translation === undefined ? 'ignore' : missingTranslation,
);

Expand All @@ -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];
Expand Down
146 changes: 62 additions & 84 deletions packages/angular/build/src/tools/esbuild/i18n-inliner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TransformedFileResult | null>;
}

/**
* An uncached transformation request entry for a file within a specific locale.
*/
Expand All @@ -177,6 +152,7 @@ export class I18nInliner {
#cacheStore: PersistentCacheStore | undefined;
#transformedFileCache: Cache<TransformedFileResult> | undefined;
#translationCache: Cache<Uint8Array> | undefined;
#generation = 0;
readonly #localizeFiles: ReadonlyMap<string, BuildOutputFile>;
readonly #unmodifiedFiles: Array<BuildOutputFile>;

Expand Down Expand Up @@ -254,6 +230,7 @@ export class I18nInliner {
): Promise<Map<string, LocaleInlineResult>> {
await this.initCache();

const generation = ++this.#generation;
const { missingTranslation, localizeVersion } = this.options;
const localeList = Array.from(locales);

Expand Down Expand Up @@ -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<TransformedFileResult | null> = 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<string, UncachedLocaleEntry[]>();

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<void>[] = [];

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<UncachedLocaleEntry | undefined> => {
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<string, UncachedLocaleEntry[]>();

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),
})),
);
}
}

Expand All @@ -379,6 +352,7 @@ export class I18nInliner {
fileResultsByLocale,
activeLocales,
isLastWindow,
generation,
);
}
}
Expand Down Expand Up @@ -445,6 +419,7 @@ export class I18nInliner {
fileResultsByLocale: Map<string, Map<string, TransformedFileResult>>,
activeLocales?: string[],
isLastWindow = true,
generation?: number,
): Promise<void> {
const workerCount = this.#workerPool.maxThreads || 1;
const targetTaskCount = Math.max(uncachedByFile.size, workerCount * 2);
Expand All @@ -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) {
Expand All @@ -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
Expand Down
47 changes: 47 additions & 0 deletions packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!`');
});
});