perf: disable React dev-build owner-stack capture in dev mode

Merged
AL
20d ago
+109-4

Why dev mode feels slow to click around

Profiling real navigation clicks in a large Reflex app (the reflex.dev docs site, 1,023 pages) under reflex run vs --env prod, interleaved click-by-click on the same machine:

busy CPU per navigation click
reflex run (dev)345.7 ms
--env prod62.6 ms

71% of the dev CPU (246.7 ms) is a single function: React's development-build createElement. React 19's dev runtime constructs an Error("react-stack-top-frame") plus a console.createTask() for every element created, to capture "owner stacks" for DevTools. Two things make this brutal in practice:

  1. The cost scales with render depth. Error() walks the live JS stack: measured 2.1 µs/element at stack depth 2, but 12–17 µs at the depth React actually renders at. A docs page creates ~10,300 elements per client-side navigation → ~247 ms.
  2. Humans pay full price on every click. React budgets the capture to 10k elements, but react-dom resets the counter 1 s after the last render. Tight automated loops exhaust the budget once and look fast; a person clicking at a normal pace gets a fresh budget — and the full cost — every single click. This is exactly the "dev feels sluggish but benchmarks look fine" signature.

(The "outdated JSX transform" warning is a red herring here — the modern jsx/jsxs runtime executes the identical per-element capture.)

The fix

Emit a small dev-only snippet in the generated context.js that pins React's budget counter past its cap, so element creation takes the runtime's own cheap branch (unknownOwnerDebugStack). A pinned accessor is required — a plain write would be undone by react-dom's once-per-second reset (this is also why naive attempts to disable it appear to do nothing).

Everything else about the dev build is unchanged: dev warnings, component stacks in errors, StrictMode behavior, HMR/Fast Refresh (all re-verified against a live app, including in-place HMR). The trade-off: elements carry React's shared unknown-owner placeholder, so the public React.captureOwnerStack() returns no owner frames in dev — affecting React DevTools' owner-stack view and anything built on that API, such as custom error overlays and the onCaughtError/onUncaughtError root handlers (verified against the pinned React 19.2.8). REFLEX_REACT_OWNER_STACKS=1 restores full owner stacks; the guard is browser-only, so SSR diagnostics are untouched. The snippet is defensive (existence-checked, try/catch) so a future React that drops the internal is a no-op, and it is not emitted in prod builds at all.

Measured result

Interleaved A/B on one server boot (docs app, human-paced navigation clicks, CDP CPU profiles, median of n=10 per arm):

busy CPU / navcreateElement self timesettle time
unpinned (today's dev)353.1 ms250.3 ms348.4 ms
pinned (this PR)82.8 ms1.0 ms81.8 ms

−270 ms per click (−77%), taking dev from 5.6× prod to ~1.3× prod. Verified no console errors, navigation, state events, and hot reload working on both a minimal app and the docs app; full unit suite passes (7,460).