Skip to content

Localized builds retain every transformed file in i18n worker memory since @angular/build 22.1 #33840

Description

@Rambuto

Command

build

Is this a regression?

  • Yes, this behavior used to work in the previous version

The previous version in which this bug was not present was

22.0.9

Description

After upgrading from Angular/Angular CLI 22.0.x to 22.1.x, a production build with all configured locales no longer completes on CI workers that previously had sufficient memory.

The i18n worker introduced in the 22.1 build path parses and transforms JavaScript with oxc-parser and MagicString. transformWithOxc() constructs a new OXC Visitor for each file/locale request. Its callbacks close over the per-request MagicString, source code, diagnostics, translation, and options.

OXC stores the callback-bearing { enter, exit } objects in a module-global cache, while its public Visitor wrapper does not reset that cache between instances. Completed file transforms therefore remain reachable for the lifetime of the Angular i18n worker. With full localization, retained heap grows approximately with output files × locales until Node terminates the worker at its heap limit.

This is not caused by Angular's persistent disk cache. Disabling cache reuse does not remove the per-process retention. Splitting locales across fresh processes hides the issue because process exit releases OXC's module-global cache; patching OXC so it does not retain visitor callbacks also allows the single-process full-localization build to complete.

The underlying OXC issue is tracked here: [OXC_ISSUE_URL]

Minimal Reproduction

  1. Create or use an Angular application with build-time localization and enough compiled JavaScript to make the retained heap visible.

  2. Configure multiple locales in angular.json. Our production application has 18 locales.

  3. Install Angular/CLI 22.1.x (@angular/build 22.1.3 reproduces the problem with oxc-parser 0.142.0).

  4. Run all locales in one process:

    npx ng build <project> --configuration production --localize=true
  5. Observe worker heap growth across file/locale requests and eventual termination.

  6. Downgrade @angular/build to 22.0.9 and repeat. The same build completes.

The OXC retention can also be demonstrated independently of the application size with this small script:

import { randomBytes } from 'node:crypto';
import { MagicString } from 'magic-string';
import { parseSync, Visitor } from 'oxc-parser';

if (!global.gc) {
  throw new Error('Run this script with node --expose-gc.');
}

global.gc();
const before = process.memoryUsage();

for (let index = 0; index < 40; index++) {
  const payload = randomBytes(768 * 1024).toString('base64');
  const code = `const payload = ${JSON.stringify(payload)}; tag\`message\`;`;
  const { program } = parseSync(`chunk-${index}.js`, code);
  const magicString = new MagicString(code);

  new Visitor({
    'TaggedTemplateExpression:exit'() {
      return magicString.toString().length;
    },
  }).visit(program);
}

global.gc();
const after = process.memoryUsage();
const toMiB = (bytes) => Math.round(bytes / 1024 / 1024);

console.log({
  heapDeltaMiB: toMiB(after.heapUsed - before.heapUsed),
  externalDeltaMiB: toMiB(after.external - before.external),
  rssDeltaMiB: toMiB(after.rss - before.rss),
});

Run with node --expose-gc repro.mjs. With oxc-parser 0.142.0, one observed result was:

{ heapDeltaMiB: 42, externalDeltaMiB: 2, rssDeltaMiB: 85 }

After changing OXC's getEnterExitObject() to return a fresh object instead of storing callback-bearing objects in the module-global cache, the same run produced:

{ heapDeltaMiB: 3, externalDeltaMiB: 2, rssDeltaMiB: 38 }

Exception or Error

The production build eventually fails with:


Error [ERR_WORKER_OUT_OF_MEMORY]: Worker terminated due to reaching memory limit: JS heap out of memory


During `ng serve`/watch mode, memory pressure can also leave the esbuild service in this downstream failure state:


fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
github.com/evanw/esbuild/internal/helpers.(*ThreadSafeWaitGroup).Wait(...)
        github.com/evanw/esbuild/internal/helpers/waitgroup.go:36
main.runService.func2()
        github.com/evanw/esbuild/cmd/esbuild/service.go:114 +0x8c


The worker out-of-memory error is the primary reproducible failure; the esbuild deadlock appears to be a secondary symptom after the Node side exits or stops servicing requests.

Your Environment

Angular CLI: 22.1.3
Angular: 22.1.1
@angular/build: 22.1.3
oxc-parser: 0.142.0
TypeScript: 6.0.3
Node: 22.22.3
npm: 10.9.8
OS: macOS arm64

Anything else relevant?

Angular source involved:

OXC source involved:

This is different from angular/angular-cli#33777, which concerns a Windows native-module unload crash and was resolved by a newer OXC release. This report is about JavaScript closure retention through the public Visitor cache and reproduces with OXC 0.142.0.

An OXC fix is the correct root fix. Angular can also protect its worker independently by creating one stateless Visitor per worker and routing callbacks through a short-lived active transform context that is assigned only around visitor.visit(program) and cleared in finally. That prevents OXC's long-lived cache from owning file-specific source state even when an affected OXC version is installed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions