dequelabs / dequelabs/cauldron

react test suite leaks ~5-6 MB/file → OOM in CI under --coverage

Open
#2,447 0 comments 0 reactions 0 assignees View on GitHub
bug claude-code-assisted medium tech debt
Dominant language
TypeScript
Stars
127
Forks
31
Avg merge
2d 12h
Merged PRs (30d)
8

Description

## Summary

The `@deque/cauldron-react` Jest suite (`jest --maxWorkers=1 --coverage`) leaks memory monotonically across test files and OOMs in CI at Node's default ~4 GB heap ceiling:

```
FATAL ERROR: Ineffective mark-compacts near heap limit
Allocation failed - JavaScript heap out of memory (exit 134 / SIGABRT)
```

Example failed run: [actions/runs/27957522773 → `react` job](https://github.com/dequelabs/cauldron/actions/runs/27957522773/job/82729766393).

This is **not** a test assertion failure — the worker process runs out of heap part-way through the suite. With `--maxWorkers=1`, every test file runs serially in a single worker, so anything that is retained between files accumulates for the entire run.

## Evidence

Running the full suite locally with `--logHeapUsage` (Jest forces a GC before each measurement, so the numbers below are **retained, non-collectable** memory — not transient garbage) over all 93 suites:

| Point in run | Post-GC heap floor |
|---|---|
| start (Combobox) | 325 MB |
| ~suite 20 | 400 MB |
| ~suite 35 | 478 MB |
| ~suite 53 | 551 MB |
| ~suite 74 | 647 MB |
| end (93 suites) | **857 MB** |

The heap sawtooths — it climbs, GC reclaims 200–300 MB, repeat — but **the floor after each GC rises monotonically and never returns to baseline**. That is ~530 MB retained across the run, roughly **5–6 MB per test file that is never freed**.

### Why it OOMs at 4 GB in CI but passes locally

The suite runs clean locally at `--max-old-space-size=8192`. The 857 MB retained floor is itself well under 4 GB, so the leak alone does not directly explain the crash — the mechanism matters:

- `--logHeapUsage` forces a full GC after every file, which artificially suppresses the peak during measurement.
- Normal CI (no forced GC) lets V8 grow the heap lazily and only collect under pressure, so the heap floats much higher between collections — on top of the ever-growing ~857 MB+ of genuinely un-freeable retained memory.
- Once the retained set plus in-flight transients can no longer be compacted below 4 GB, V8 burns cycles in GC that can't free anything → the `Ineffective mark-compacts` OOM.

In short: the leak sets a rising floor, CI's lazy GC needs headroom above it, and the suite has simply grown until floor + headroom > 4 GB. It will keep crossing that cliff as test files are added.

## Suspected root cause

Surfaced / worsened by the React 19 upgrade (#2405). The run logs a flood of `act(...)` warnings, all originating from raw DOM `.focus()` calls in tests that trigger `@react-stately/selection` state updates **after** the test has completed — e.g. `packages/react/src/components/TreeView/TreeView.test.tsx:137` and `:150`:

```js
const first = getByRole('row', { name: 'TreeView' });
first.focus(); // fires react-aria/react-stately setState outside act()
await userEvent.keyboard('{ArrowDown}');
expect(getByRole('row', { name: 'pizza' })).toHaveFocus();
```

State updates landing after the test means focus/selection managers and their fiber trees are not torn down with the rest of the render — retained references that GC cannot free. Raw `.focus();` currently appears in **14 test files**:

- `src/utils/useMnemonics.test.tsx` (13)
- `src/components/ActionMenu/ActionMenu.test.tsx` (5)
- `src/components/TreeView/TreeView.test.tsx` (4)
- `src/components/TextEllipsis/TextEllipsis.test.tsx` (2), `Drawer` (2), `AnchoredOverlay` (2)
- `useFocusTrap`, `Tabs`, `SideBar`, `RadioGroup`, `OptionsMenuList`, `Combobox`, `Checkbox`, `BottomSheet` (1 each)

`--coverage` adds monotonic coverage-map retention on top of this (it holds coverage data for every source file until the final report), so it is a real but separate contributor to the rising floor.

## Proposed work

1. **Unblock CI now (separate PR, not this issue):** add `workerIdleMemoryLimit: '1GB'` to `packages/react/jest.config.ts` so the worker recycles once the retained floor crosses 1 GB, plus a heap margin as backup. This is a crutch, not a fix.
2. **Reduce the leak (this issue):** wrap focus interactions in `act(...)` or drive focus via `userEvent` instead of raw `.focus()`, starting with the worst offenders above. Re-run with `--logHeapUsage` and confirm the retained floor flattens.
3. **Add a guardrail:** consider running `--logHeapUsage` in CI (or a lightweight leak check) so the floor cannot silently creep back toward the cliff.

## Acceptance criteria

- [ ] `act(...)` warnings eliminated from the `react` test run
- [ ] Retained heap floor stays roughly flat across the suite under `--logHeapUsage` (no monotonic climb)
- [ ] Suite passes at the default ~4 GB heap **without** `workerIdleMemoryLimit` as a crutch

## Caveats

- The `act(...)` warnings are a strong lead but not proven to account for 100% of the ~530 MB. Step 2 must re-measure to quantify the actual drop; coverage-map retention is a separate chunk that will not go away.
- The PR in step 1 will mask the leak in CI, which is why the third acceptance criterion ("passes at 4 GB without the crutch") exists — to keep the underlying fix honest.

---

Diagnosis assisted by Claude Code (`--logHeapUsage` run over all 93 suites).

Contributor guide

Open the contributing guide

Research direction

Start with the React Jest run using --logHeapUsage, then inspect the raw .focus() calls listed in the issue, beginning with packages/react/src/components/TreeView/TreeView.test.tsx and the other named test files. Compare retained heap floors and act(...) warnings before and after the focus interactions are addressed. Done means warnings are eliminated, heap usage stays roughly flat, and the suite passes at the default heap limit without workerIdleMemoryLimit.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, typescript
Domain
performance, testing
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.