fix: avoid re-evaluating stateful pages twice in one process#6710
Conversation
A prod backend worker started via os.fork (the default when Python's multiprocessing start method is "fork", i.e. Python <=3.13) inherits the compiling process's populated dynamic-state registry. It then re-runs the stateful-pages marker path, re-evaluating each stateful page. Because ComponentState.create() increments a class-level counter and registers a new dynamic state class per call, this appends a second set of state classes (Counter_n3/_n4 on top of the inherited _n1/_n2). The frontend was compiled against only _n1/_n2, so the extra backend states break hydration/state deltas (TypeError: d is not a function on the client). Make page evaluation idempotent within a process: track evaluated routes in App._evaluated_pages and skip re-evaluation in _compile_page. A forked worker inherits the set from its parent and reuses the existing state classes; a freshly spawned worker (forkserver/spawn) starts empty and evaluates once as before.
Merging this PR will not alter performance
Comparing Footnotes
|
Greptile SummaryThis PR makes stateful-page evaluation idempotent within a single process, fixing a bug where forked production workers or same-process export+serve flows caused
Confidence Score: 5/5The change is narrowly scoped — a single new set field and a short-circuit guard in All runtime paths that trigger a second evaluation ( No files require special attention; the logic change in Important Files Changed
Reviews (2): Last reviewed commit: "honor save_page contract in _compile_pag..." | Re-trigger Greptile |
Problem
When a page uses
ComponentState.create()(or defines state inline), evaluating that page registers dynamic state classes (Counter_n1,Counter_n2, …) into a process-global registry via a monotonic counter that is never reset. If the same process evaluates a stateful page more than once, it appends a second set of classes (Counter_n3,Counter_n4, …).The frontend JS is compiled against only the first set, so when the backend's state tree contains the extra classes, hydration/state deltas reference states the client never defined, and the client throws:
Two real flows trigger a second in-process evaluation:
Forked prod workers.
reflex run --env prodcompiles in the CLI process (registering_n1/_n2), then serves. When the server (granian/gunicorn) starts a worker by forking that process, the worker inherits the populated registry and re-runs the stateful-pages marker path →_n3/_n4. Whether workers fork vs. spawn depends on the multiprocessing start method (forkon Python ≤3.13;forkserveron 3.14+), which is why it's environment-dependent.Same-process export + serve (version-independent). Harnesses like
reflex.testing.AppHarness(and flexgen'sAppHarnessProdOnePort) run a full compile (export,_n1/_n2) and then, in the same process withREFLEX_SKIP_COMPILEset, callapp()→ the skip/marker path re-evaluates the stateful pages →_n3/_n4. This happens regardless of Python version.Fix
Make page evaluation idempotent within a process.
Apptracks evaluated routes in_evaluated_pages;_compile_pageskips a route already evaluated and reuses the existing state classes. The full compile marks its routes so a subsequent skip/marker-path evaluation (forked worker or same-instance serve) reuses them instead of creating new ones. A freshly spawned/re-imported app starts with an empty set and evaluates once, as before.Verification
test_compile_page_is_idempotent_for_component_state(fails before, passes after).Appinstance, export compile + skip-compile serve):Counter_n1..n4without the fix,Counter_n1/_n2with it.reflex run --env prod(forcingfork): 4 states without the fix, 2 with it.Known limitation
Two full compiles in one process (neither going through the skip/marker path) still double, because the full-compile eval loop isn't guarded. This isn't an existing runtime flow (both prod serving and AppHarness use
REFLEX_SKIP_COMPILEfor the second evaluation), but it's worth noting.