Skip to content

Commit f9c75b7

Browse files
committed
perf(@angular/build): traverse AST with iterative post-order walker in i18n inliner
Replace the oxc-parser Visitor class in the i18n inliner worker with a lightweight, non-recursive post-order AST walker based on visitorKeys. oxc-parser's Visitor class caches visitor callback objects in a module-global array across invocations, which causes all per-request MagicString instances, source code buffers, and diagnostics closures to be retained for the lifetime of the worker thread. In multi-locale builds, this leads to continuous heap accumulation and out-of-memory errors on memory-constrained CI runners. The custom walker uses an iterative two-pass array traversal on the V8 heap to guarantee bottom-up evaluation without recursion or stack overflow risks. This ensures nested $localize template expressions are transformed and written to MagicString before outer templates evaluate their expressions, while eliminating all module-global caching and memory retention across file transformations.
1 parent a56a691 commit f9c75b7

2 files changed

Lines changed: 72 additions & 8 deletions

File tree

packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
*/
88

99
import remapping, { type DecodedSourceMap, type SourceMapInput } from '@ampproject/remapping';
10+
import type { Node } from '@oxc-project/types';
1011
import { MagicString } from 'magic-string';
1112
import assert from 'node:assert';
1213
import { deserialize } from 'node:v8';
1314
import { workerData } from 'node:worker_threads';
14-
import { Visitor, parseSync } from 'oxc-parser';
15+
import { parseSync, visitorKeys } from 'oxc-parser';
1516

1617
/**
1718
* The options passed to the inliner for each file request
@@ -180,6 +181,51 @@ async function loadLocalizeTools(): Promise<LocalizeUtilityModule> {
180181
return localizeToolsModule;
181182
}
182183

184+
/**
185+
* Traverses ESTree AST nodes in post-order (bottom-up) without recursion.
186+
* Bottom-up traversal ensures that nested `$localize` expressions are transformed and
187+
* written to MagicString before outer containing templates are evaluated.
188+
*
189+
* @param root The root AST node to traverse.
190+
* @param onExit Callback invoked on each AST node in post-order.
191+
*/
192+
function walkAstPostOrder(root: Node, onExit: (node: Node) => void): void {
193+
const traverseStack: Node[] = [root];
194+
const postOrderNodes: Node[] = [];
195+
196+
while (traverseStack.length > 0) {
197+
const current = traverseStack.pop();
198+
if (!current) {
199+
continue;
200+
}
201+
202+
postOrderNodes.push(current);
203+
204+
const keys = visitorKeys[current.type];
205+
if (keys) {
206+
for (let i = 0; i < keys.length; i++) {
207+
const child = (current as unknown as Record<string, Node | Node[]>)[keys[i]];
208+
if (child && typeof child === 'object') {
209+
if (Array.isArray(child)) {
210+
for (let j = 0; j < child.length; j++) {
211+
const item = child[j];
212+
if (item && typeof item === 'object') {
213+
traverseStack.push(item);
214+
}
215+
}
216+
} else {
217+
traverseStack.push(child);
218+
}
219+
}
220+
}
221+
}
222+
}
223+
224+
for (let i = postOrderNodes.length - 1; i >= 0; i--) {
225+
onExit(postOrderNodes[i]);
226+
}
227+
}
228+
183229
/**
184230
* Transforms a JavaScript file using OXC and Magic-String to inline the request locale and translation.
185231
* @param code A string containing the JavaScript code to transform.
@@ -206,13 +252,12 @@ async function transformWithOxc(
206252
const { Diagnostics, translate } = await loadLocalizeTools();
207253
const diagnostics = new Diagnostics();
208254

209-
const visitor = new Visitor({
210-
Literal(node) {
255+
walkAstPostOrder(program, (node) => {
256+
if (node.type === 'Literal') {
211257
if (typeof node.value === 'string' && node.value === '___NG_LOCALE_INSERT___') {
212258
magicString.overwrite(node.start, node.end, JSON.stringify(options.locale));
213259
}
214-
},
215-
'TaggedTemplateExpression:exit'(node) {
260+
} else if (node.type === 'TaggedTemplateExpression') {
216261
if (node.tag.type === 'Identifier' && node.tag.name === '$localize') {
217262
const cooked = node.quasi.quasis.map((q) => q.value.cooked);
218263
const raw = node.quasi.quasis.map((q) => q.value.raw);
@@ -252,11 +297,9 @@ async function transformWithOxc(
252297

253298
magicString.overwrite(node.start, node.end, replacement);
254299
}
255-
},
300+
}
256301
});
257302

258-
visitor.visit(program);
259-
260303
const outputCode = magicString.toString();
261304
let outputMap;
262305
if (map && magicString.hasChanged()) {

packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,4 +274,25 @@ describe('I18nInliner', () => {
274274

275275
expect(findFile(outputFiles, 'other.js').text).toBe('export const answer = 42;\n');
276276
});
277+
278+
it('inlines nested $localize calls in post-order', async () => {
279+
const source =
280+
'export const msg = $localize`:@@outer:You selected ${$localize`:@@inner:Apple`} for delivery.`;\n';
281+
const { outputFiles, errors, warnings } = await createInliner([
282+
browserFile('main.js', source),
283+
]).inlineForLocale('fr', {
284+
inner: translationFor('Pomme'),
285+
outer: {
286+
messageParts: ['Vous avez sélectionné ', ' pour la livraison.'],
287+
placeholderNames: ['PH'],
288+
text: 'Vous avez sélectionné {$PH} pour la livraison.',
289+
},
290+
});
291+
292+
expect(errors).toEqual([]);
293+
expect(warnings).toEqual([]);
294+
expect(findFile(outputFiles, 'main.js').text).toBe(
295+
'export const msg = `Vous avez sélectionné ${"Pomme"} pour la livraison.`;\n',
296+
);
297+
});
277298
});

0 commit comments

Comments
 (0)