Skip to content
Merged
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
9 changes: 5 additions & 4 deletions packages/angular/build/src/builders/application/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
37 changes: 23 additions & 14 deletions packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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<string, Blob>;
// Extract common options used for inline requests from the Worker context
const { missingTranslation } = (workerData || {}) as {
missingTranslation: 'error' | 'warning' | 'ignore';
};

Expand Down Expand Up @@ -128,20 +136,18 @@ const deserializedTranslations = new Map<string, Promise<Record<string, ɵParsed
* to be garbage-collected once the batch request finishes.
*
* @param filename The name of the file to load.
* @param codeBlob The source code file as a Blob.
* @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 loadFileData(filename: string, cache = true): Promise<CachedFileData> {
function loadFileData(filename: string, codeBlob: Blob, cache = true): Promise<CachedFileData> {
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 };
Expand Down Expand Up @@ -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) {
Expand All @@ -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]) => {
Expand Down
112 changes: 78 additions & 34 deletions packages/angular/build/src/tools/esbuild/i18n-inliner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ɵParsedTranslation> | undefined,
): SharedArrayBuffer | Blob | undefined {
translationIntegrity?: string,
translationCache?: Cache<Uint8Array>,
): Promise<SharedArrayBuffer | Blob | undefined> {
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);
}

Expand Down Expand Up @@ -145,7 +175,8 @@ export class I18nInliner {
#cacheInitFailed = false;
#workerPool: WorkerPool;
#cacheStore: PersistentCacheStore | undefined;
#cache: Cache<TransformedFileResult> | undefined;
#transformedFileCache: Cache<TransformedFileResult> | undefined;
#translationCache: Cache<Uint8Array> | undefined;
readonly #localizeFiles: ReadonlyMap<string, BuildOutputFile>;
readonly #unmodifiedFiles: Array<BuildOutputFile>;

Expand Down Expand Up @@ -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<string, Blob>(
Array.from(files, ([name, file]) => [name, new Blob([file.contents])]),
),
},
});
}
Expand Down Expand Up @@ -239,6 +263,7 @@ export class I18nInliner {

const fileResultsByLocale = new Map<string, Map<string, TransformedFileResult>>();
for (const { locale } of localeList) {
assert(!fileResultsByLocale.has(locale), 'Duplicate locale provided to inliner: ' + locale);
fileResultsByLocale.set(locale, new Map());
}

Expand All @@ -256,23 +281,30 @@ export class I18nInliner {
const localeCacheBases = new Map<string, string>();
const localeBlobs = new Map<string, Blob | SharedArrayBuffer | undefined>();

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

Expand All @@ -284,7 +316,7 @@ export class I18nInliner {
let cacheKey: string | undefined;
let cachedResultPromise: Promise<TransformedFileResult | null> = Promise.resolve(null);

if (this.#cache) {
if (this.#transformedFileCache) {
const fileCacheKeyBase = localeCacheBases.get(locale);
assert(fileCacheKeyBase !== undefined, 'Cache base must exist for locale: ' + locale);

Expand All @@ -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);
Expand Down Expand Up @@ -424,13 +456,19 @@ export class I18nInliner {
const workerTasks: Promise<void>[] = [];

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);
const task = (async () => {
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,
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -519,6 +557,7 @@ export class I18nInliner {
translation: Record<string, ɵParsedTranslation> | undefined,
templateCode: string,
templateId: string,
translationIntegrity?: string,
): Promise<{ code: string; errors: string[]; warnings: string[] }> {
const hasLocalize = templateCode.includes(LOCALIZE_KEYWORD);

Expand All @@ -535,7 +574,11 @@ export class I18nInliner {
code: templateCode,
filename: templateId,
locale,
translation: serializeTranslation(translation),
translation: await serializeTranslation(
translation,
translationIntegrity,
this.#translationCache,
),
},
{ name: 'inlineCode' },
);
Expand Down Expand Up @@ -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;

Expand Down
Loading