perf: disable React dev-build owner-stack capture in dev mode - #6905
perf: disable React dev-build owner-stack capture in dev mode#6905Alek99 wants to merge 7 commits into
Conversation
React 19's development runtime constructs an Error() plus a
console.createTask() for every element it creates to capture "owner
stacks". The Error constructor walks the live JS stack, so the cost
grows with render depth: measured 2.1us per element at stack depth 2
but 12-17us at the depth React actually renders at. On a large page
(the reflex.dev docs app, ~10,300 elements per client-side navigation)
this is 247ms of main-thread CPU per navigation - 71% of all dev-mode
render CPU - and is the main reason dev mode feels slow to click
around compared to prod.
React budgets the capture to 10k elements, but react-dom resets the
counter one second after the last render, so a human clicking at a
normal pace pays the full cost on every click while tight automated
loops exhaust the budget once and look fast.
Pin the budget counter past its cap from the generated context.js (dev
mode only) so element creation takes the runtime's own cheap branch.
A pinned accessor is required - a plain write would be undone by
react-dom's once-per-second reset. All other dev-build behaviour
(warnings, component stacks in errors, Fast Refresh) is unchanged;
only the owner-stack detail in React DevTools is lost. Set
REFLEX_REACT_OWNER_STACKS=1 to restore it.
Interleaved A/B on the docs app, human-paced navigation clicks,
median of 10 per arm on one server boot:
unpinned: 353.1ms busy CPU/nav (createElement 250.3ms)
pinned: 82.8ms busy CPU/nav (createElement 1.0ms)
which closes most of the gap to the production build (62.6ms) that
was previously a 5.6x difference.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Merging this PR will not alter performance
Comparing Footnotes
|
Greptile SummaryThe PR reduces development-mode rendering overhead by disabling React owner-stack capture by default while preserving an environment-variable escape hatch. It also makes
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| packages/reflex-base/src/reflex_base/compiler/templates.py | Generates the guarded owner-stack counter pin; the previously requested key extraction is correctly applied to both executable accesses. |
| reflex/compiler/compiler.py | Enables the generated pin only in development when the owner-stack escape hatch is disabled. |
| packages/reflex-base/src/reflex_base/environment.py | Defines the typed environment toggle that restores React owner-stack capture. |
| packages/reflex-components-core/src/reflex_components_core/base/script.py | Disables Helmet's deferred update scheduling for script components. |
| packages/reflex-components-core/src/reflex_components_core/core/helmet.py | Exposes Helmet's defer property through the component model. |
| tests/units/compiler/test_compiler.py | Verifies conditional emission, browser guarding, imports, and documentation of the owner-stack pin. |
| tests/units/components/base/test_script.py | Verifies that script components render Helmet with synchronous flushing enabled. |
Reviews (5): Last reviewed commit: "fix: flush rx.script head updates synchr..." | Re-trigger Greptile
Review feedback on two points, both correct: - The snippet mutated shared React internals unconditionally, but this module is also evaluated by the server renderer, so SSR owner-stack diagnostics were disabled in the same process. Guard it on a browser check so it only runs client-side. - The trade-off is broader than "React DevTools". Pinning the counter makes elements carry React's shared unknown-owner placeholder, so the public React.captureOwnerStack() returns no owner frames -- which also affects custom error overlays and the onCaughtError / onUncaughtError root handlers. Verified against the pinned React 19.2.8. Say so in the generated code and the news entry instead of understating it. Adds a compiler test covering both directions: the pin is absent when owner stacks are requested or in prod, and when present it is wrapped by the browser guard and carries the escape hatch and trade-off in the emitted comment.
Side-by-side: navigating a content-heavy app in dev modeRecorded a click-through of the same app with and without this change. Left pane is dev mode as it ships today, right pane is with the pin. Same app, same four navigation clicks, same machine; the header bar in each pane shows that click's render time, measured in-page from the click to the last DOM mutation.
Method notes, since a side-by-side is easy to make lie:
|
masenf
left a comment
There was a problem hiding this comment.
i wonder if it makes more sense to switch the flexgen sandbox to use the new --env preview mode that basically makes an unoptimized prod build. would probably need to benchmark it to see if the compile time is acceptable.
this feels pretty hacky, but it does make a noticable difference and has a chicken switch, so it's okay with me.
Per review: the generated context.js does not need the full essay; keep the one-line what/cost/escape-hatch summary and link PR #6905 for the rest. Re-verified the pin still engages at runtime after the trim.
Root-causes the flaky "scripts not loaded" failures in tests/integration/test_call_script.py (also seen on main at 7e57be7). rx.script wraps its tag in react-helmet, whose client-side flush is deferred and batched via requestAnimationFrame by default. That flush can be lost around hydration, leaving the page's script tags out of the document entirely: in the failing state the DOM contains no helmet- attributed tags at all and both the inline and external script globals are undefined, while the app is otherwise hydrated and interactive. The race predates this PR - it reproduces locally on the base commit's code path (pin disabled: 1/5 runs) - but the owner-stack pin makes dev renders 3-4x faster, which shifts the timing and roughly triples the incidence (pin enabled: 3/5 runs). Passing defer={false} on the Helmet wrapper makes the head update part of the synchronous commit: deferred helmet + pin: 3/5 runs failed deferred helmet, no pin: 1/5 runs failed defer=false + pin: 5/5 runs passed Scope is rx.script's Helmet wrapper only; other head usage keeps the default batching. Adds a unit test pinning the rendered defer prop.
The
|
| configuration | failures |
|---|---|
| deferred helmet, pin disabled (= main's code path) | 1/5 |
| deferred helmet, pin enabled | 3/5 |
defer={false} helmet, pin enabled |
0/5 |
So the race predates this PR — it also failed on main at 7e57be74 in CI — but this PR's faster dev renders shift the timing and make it fire noticeably more often. That's an interaction I didn't catch in the earlier regression pass, and it's why I'm folding the fix in here rather than leaving the amplification behind.
Fix: pass defer={false} on rx.script's Helmet wrapper only, so the head update is part of the synchronous commit. Other Helmet usage keeps the default batching. Unit test pins the rendered prop; news entries added for the bugfix.
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 runvs--env prod, interleaved click-by-click on the same machine:reflex run(dev)--env prod71% of the dev CPU (246.7 ms) is a single function: React's development-build
createElement. React 19's dev runtime constructs anError("react-stack-top-frame")plus aconsole.createTask()for every element created, to capture "owner stacks" for DevTools. Two things make this brutal in practice: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.react-domresets 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/jsxsruntime executes the identical per-element capture.)The fix
Emit a small dev-only snippet in the generated
context.jsthat 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 theonCaughtError/onUncaughtErrorroot handlers (verified against the pinned React 19.2.8).REFLEX_REACT_OWNER_STACKS=1restores 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):
createElementself time−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).