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
41 changes: 34 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 @@ -77,18 +77,25 @@ interface InlineFileBatchRequest {
*/
interface InlineLocaleResult {
locale: string;
code: string;
code?: string;
map?: string;
messages: { type: 'error' | 'warning'; message: string }[];
}

/**
* The response returned from a batch file request.
*/
interface InlineFileBatchResult {
file: string;
results: InlineLocaleResult[];
}
type InlineFileBatchResult =
| {
file: string;
unmodified: true;
messages: { type: 'error' | 'warning'; message: string }[];
}
| {
file: string;
unmodified?: false;
results: InlineLocaleResult[];
};

// Extract the application files and common options used for inline requests from the Worker context
const { files, missingTranslation } = (workerData || {}) as {
Expand Down Expand Up @@ -204,6 +211,18 @@ export async function inlineFileBatch(

const { code, metadata } = await loadFileData(request.filename, !request.ephemeral);

// Fast path: file has no $localize call sites or locale insert sites
if (metadata.callSites.length === 0 && metadata.localeInsertSites.length === 0) {
return {
file: request.filename,
unmodified: true,
messages: (metadata.diagnostics ?? []).map((message) => ({
type: 'error' as const,
message,
})),
};
}

// Parse the sourcemap once for the entire batch.
// It will naturally be garbage-collected after this batch action returns.
const rawMap = await files.get(request.filename + '.map')?.text();
Expand Down Expand Up @@ -254,7 +273,7 @@ export async function inlineCode(request: InlineCodeRequest) {
);

return {
output: result.code,
output: result.code ?? request.code,
messages: result.diagnostics.messages,
};
}
Expand Down Expand Up @@ -499,9 +518,17 @@ async function inlineLocalize(
magicString.overwrite(callSite.start, callSite.end, replacement);
}

if (!magicString.hasChanged()) {
return {
code: undefined,
map: undefined,
diagnostics,
};
}

const outputCode = magicString.toString();
let outputMap;
if (map && magicString.hasChanged()) {
if (map) {
// A decoded map is generated here rather than an encoded one because remapping decodes its
// inputs. Encoding the mappings only for remapping to immediately decode them again doubles
// the peak memory of the largest structure involved in inlining a file.
Expand Down
87 changes: 60 additions & 27 deletions packages/angular/build/src/tools/esbuild/i18n-inliner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export interface LocaleInlineResult {
*/
interface TransformedFileResult {
file: string;
code: string;
code?: string;
map?: string;
messages: { type: 'error' | 'warning'; message: string }[];
}
Expand Down Expand Up @@ -362,17 +362,26 @@ export class I18nInliner {

if (fileResults) {
for (const filename of filenames) {
const originalFile = this.#localizeFiles.get(filename);
assert(originalFile !== undefined, 'Localize file must exist: ' + filename);

const fileResult = fileResults.get(filename);
if (!fileResult) {
continue;
}

const type = this.#localizeFiles.get(filename)?.type;
assert(type !== undefined, 'localized file should always have a type: ' + filename);
const type = originalFile.type;
if (fileResult.code != undefined) {
outputFiles.push(createOutputFile(filename, fileResult.code, type));
} else {
outputFiles.push(originalFile.clone());
}

outputFiles.push(createOutputFile(filename, fileResult.code, type));
if (fileResult.map) {
const originalMap = this.#localizeFiles.get(filename + '.map');
if (fileResult.map !== undefined) {
outputFiles.push(createOutputFile(filename + '.map', fileResult.map, type));
} else if (originalMap !== undefined) {
outputFiles.push(originalMap.clone());
}

for (const message of fileResult.messages) {
Expand Down Expand Up @@ -427,30 +436,54 @@ export class I18nInliner {
activeLocales,
},
{ name: 'inlineFileBatch' },
)) as {
file: string;
results: Array<TransformedFileResult & { locale: string }>;
};

const cachePromises: Promise<unknown>[] = [];
for (const res of batchResult.results) {
const matchingEntry = batchEntries.find((e) => e.locale === res.locale);
const cacheKey = matchingEntry?.cacheKey;

if (this.#cache && cacheKey) {
cachePromises.push(
this.#cache.put(cacheKey, {
file: filename,
code: res.code,
map: res.map,
messages: res.messages,
}),
);
)) as
| {
file: string;
unmodified: true;
messages: { type: 'error' | 'warning'; message: string }[];
}
| {
file: string;
unmodified?: false;
results: Array<TransformedFileResult & { locale: string }>;
};

if (batchResult.unmodified) {
const unmodifiedResult: TransformedFileResult = {
file: filename,
messages: batchResult.messages,
};

const cachePromises: Promise<unknown>[] = [];
for (const { locale, cacheKey } of batchEntries) {
fileResultsByLocale.get(locale)?.set(filename, unmodifiedResult);

if (this.#cache && cacheKey) {
cachePromises.push(this.#cache.put(cacheKey, unmodifiedResult));
}
}

fileResultsByLocale.get(res.locale)?.set(filename, res);
await Promise.allSettled(cachePromises);
} else {
const cachePromises: Promise<unknown>[] = [];
for (const res of batchResult.results) {
const matchingEntry = batchEntries.find((e) => e.locale === res.locale);
const cacheKey = matchingEntry?.cacheKey;

if (this.#cache && cacheKey) {
cachePromises.push(
this.#cache.put(cacheKey, {
file: filename,
code: res.code,
map: res.map,
messages: res.messages,
}),
);
}

fileResultsByLocale.get(res.locale)?.set(filename, res);
}
await Promise.allSettled(cachePromises);
}
await Promise.allSettled(cachePromises);
})();

workerTasks.push(task);
Expand Down
79 changes: 79 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 @@ -644,4 +644,83 @@ describe('I18nInliner', () => {
const iifeMatches = frPolyfills.match(/function\s*\(global/g);
expect(iifeMatches?.length).toBe(1);
});

it('preserves code and sourcemaps when a file contains the $localize keyword only in comments', async () => {
const source = '// $localize keyword in comment\nexport const message = "hello world";\n';
const { code, map } = await transform(source, {
sourcefile: 'comment-only.ts',
loader: 'ts',
sourcemap: 'external',
});

const localeInliner = createInliner([
browserFile('main.js', code),
browserFile('main.js.map', map),
]);

const results = await localeInliner.inlineAll([
{ locale: 'fr', translation: { greeting: translationFor('Bonjour') } },
{ locale: 'de', translation: { greeting: translationFor('Hallo') } },
]);

expect(results.size).toBe(2);

for (const locale of ['fr', 'de'] as const) {
const localeResult = results.get(locale);
expect(localeResult).toBeDefined();
expect(localeResult?.errors).toEqual([]);
expect(localeResult?.warnings).toEqual([]);

const mainJs = findFile(localeResult?.outputFiles ?? [], 'main.js');
expect(mainJs.text).toBe(code);

const mainMap = findFile(localeResult?.outputFiles ?? [], 'main.js.map');
expect(mainMap.text).toBe(map);
}
});

it('correctly handles persistent caching for unmodified files', async () => {
const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18n-unmodified-cache-test-'));
const source = '// $localize comment only\nexport const value = 123;\n';

try {
const inliner1 = new I18nInliner({
missingTranslation: 'error',
outputFiles: [browserFile('main.js', source)],
persistentCachePath: cacheDir,
});

const results1 = await inliner1.inlineAll([
{
locale: 'fr',
translation: { greeting: translationFor('Bonjour') },
translationIntegrity: 'fr-1',
},
]);

expect(results1.get('fr')?.errors).toEqual([]);
expect(findFile(results1.get('fr')?.outputFiles ?? [], 'main.js').text).toBe(source);
await inliner1.close();

const inliner2 = new I18nInliner({
missingTranslation: 'error',
outputFiles: [browserFile('main.js', source)],
persistentCachePath: cacheDir,
});

const results2 = await inliner2.inlineAll([
{
locale: 'fr',
translation: { greeting: translationFor('Bonjour') },
translationIntegrity: 'fr-1',
},
]);

expect(results2.get('fr')?.errors).toEqual([]);
expect(findFile(results2.get('fr')?.outputFiles ?? [], 'main.js').text).toBe(source);
await inliner2.close();
} finally {
await fs.rm(cacheDir, { recursive: true, force: true });
}
});
});