From 502ee51ccc1ded0516901d9627a90619f8f3f092 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:03:48 -0400 Subject: [PATCH] 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. --- .../src/tools/esbuild/i18n-inliner-worker.ts | 61 ++++++++++++++++--- .../src/tools/esbuild/i18n-inliner_spec.ts | 21 +++++++ 2 files changed, 74 insertions(+), 8 deletions(-) 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 bfec753f6b2d..35223c4df4ac 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts @@ -7,11 +7,12 @@ */ import remapping, { type DecodedSourceMap, type SourceMapInput } from '@ampproject/remapping'; +import type { Node } from '@oxc-project/types'; import { MagicString } from 'magic-string'; import assert from 'node:assert'; import { deserialize } from 'node:v8'; import { workerData } from 'node:worker_threads'; -import { Visitor, parseSync } from 'oxc-parser'; +import { parseSync, visitorKeys } from 'oxc-parser'; /** * The options passed to the inliner for each file request @@ -180,6 +181,53 @@ async function loadLocalizeTools(): Promise { return localizeToolsModule; } +/** + * Traverses ESTree AST nodes in post-order (bottom-up) without recursion. + * Bottom-up traversal ensures that nested `$localize` expressions are transformed and + * written to MagicString before outer containing templates are evaluated. + * + * @param root The root AST node to traverse. + * @param onExit Callback invoked on each AST node in post-order. + */ +function walkAstPostOrder(root: Node, onExit: (node: Node) => void): void { + const traverseStack: Node[] = [root]; + const postOrderNodes: Node[] = []; + + while (traverseStack.length > 0) { + const current = traverseStack.pop(); + if (!current) { + continue; + } + + postOrderNodes.push(current); + + const keys = visitorKeys[current.type]; + if (!keys) { + continue; + } + + for (let i = 0; i < keys.length; i++) { + const child = (current as unknown as Record)[keys[i]]; + if (child) { + if (Array.isArray(child)) { + for (let j = 0; j < child.length; j++) { + const item = child[j]; + if (item) { + traverseStack.push(item); + } + } + } else { + traverseStack.push(child); + } + } + } + } + + for (let i = postOrderNodes.length - 1; i >= 0; i--) { + onExit(postOrderNodes[i]); + } +} + /** * Transforms a JavaScript file using OXC and Magic-String to inline the request locale and translation. * @param code A string containing the JavaScript code to transform. @@ -206,13 +254,12 @@ async function transformWithOxc( const { Diagnostics, translate } = await loadLocalizeTools(); const diagnostics = new Diagnostics(); - const visitor = new Visitor({ - Literal(node) { + walkAstPostOrder(program, (node) => { + if (node.type === 'Literal') { if (typeof node.value === 'string' && node.value === '___NG_LOCALE_INSERT___') { magicString.overwrite(node.start, node.end, JSON.stringify(options.locale)); } - }, - 'TaggedTemplateExpression:exit'(node) { + } else if (node.type === 'TaggedTemplateExpression') { if (node.tag.type === 'Identifier' && node.tag.name === '$localize') { const cooked = node.quasi.quasis.map((q) => q.value.cooked); const raw = node.quasi.quasis.map((q) => q.value.raw); @@ -252,11 +299,9 @@ async function transformWithOxc( magicString.overwrite(node.start, node.end, replacement); } - }, + } }); - visitor.visit(program); - const outputCode = magicString.toString(); let outputMap; if (map && magicString.hasChanged()) { 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 67a2fe5883a2..e89acc41c161 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts @@ -274,4 +274,25 @@ describe('I18nInliner', () => { expect(findFile(outputFiles, 'other.js').text).toBe('export const answer = 42;\n'); }); + + it('inlines nested $localize calls in post-order', async () => { + const source = + 'export const msg = $localize`:@@outer:You selected ${$localize`:@@inner:Apple`} for delivery.`;\n'; + const { outputFiles, errors, warnings } = await createInliner([ + browserFile('main.js', source), + ]).inlineForLocale('fr', { + inner: translationFor('Pomme'), + outer: { + messageParts: ['Vous avez sélectionné ', ' pour la livraison.'], + placeholderNames: ['PH'], + text: 'Vous avez sélectionné {$PH} pour la livraison.', + }, + }); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toBe( + 'export const msg = `Vous avez sélectionné ${"Pomme"} pour la livraison.`;\n', + ); + }); });