Skip to content

fix: avoid re-evaluating stateful pages twice in one process#6710

Merged
masenf merged 2 commits into
mainfrom
fix/component-state-double-eval-prod
Jul 6, 2026
Merged

fix: avoid re-evaluating stateful pages twice in one process#6710
masenf merged 2 commits into
mainfrom
fix/component-state-double-eval-prod

Conversation

@adhami3310

Copy link
Copy Markdown
Member

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:

[Reflex Frontend Exception] TypeError: d is not a function

Two real flows trigger a second in-process evaluation:

  1. Forked prod workers. reflex run --env prod compiles 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 (fork on Python ≤3.13; forkserver on 3.14+), which is why it's environment-dependent.

  2. Same-process export + serve (version-independent). Harnesses like reflex.testing.AppHarness (and flexgen's AppHarnessProdOnePort) run a full compile (export, _n1/_n2) and then, in the same process with REFLEX_SKIP_COMPILE set, call app() → 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. App tracks evaluated routes in _evaluated_pages; _compile_page skips 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

  • New regression test test_compile_page_is_idempotent_for_component_state (fails before, passes after).
  • Reproduced flexgen's exact pattern (shared App instance, export compile + skip-compile serve): Counter_n1..n4 without the fix, Counter_n1/_n2 with it.
  • Reproduced the forked-worker prod path via reflex run --env prod (forcing fork): 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_COMPILE for the second evaluation), but it's worth noting.

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.
@adhami3310 adhami3310 requested a review from a team as a code owner July 6, 2026 19:47
@codspeed-hq

codspeed-hq Bot commented Jul 6, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing fix/component-state-double-eval-prod (78d4b7a) with main (3280121)2

Open in CodSpeed

Footnotes

  1. 8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (b3fe805) during the generation of this report, so 3280121 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes stateful-page evaluation idempotent within a single process, fixing a bug where forked production workers or same-process export+serve flows caused ComponentState.create() to register duplicate dynamic state classes, breaking frontend hydration with TypeError: d is not a function.

  • Adds a _evaluated_pages: set[str] field to App and an early-return guard in _compile_page that skips re-evaluation when a route has already been compiled — while still honouring a save_page=True request if the component is not yet in _pages.
  • After a full compile, compile_app bulk-marks all compiled routes into _evaluated_pages so that the subsequent marker/skip-path calls (forked worker or AppHarness same-process serve) are no-ops and cannot create a second generation of state classes.

Confidence Score: 5/5

The change is narrowly scoped — a single new set field and a short-circuit guard in _compile_page — with no mutations to the actual compilation pipeline and a dedicated regression test that would have caught the original bug.

All runtime paths that trigger a second evaluation (REFLEX_SKIP_COMPILE marker path and backend-only evaluate path) pass save_page=False, so the guard condition is always satisfied and re-evaluation is reliably skipped. The save_page=True edge case is correctly handled by also checking route in self._pages, and is covered by the new test. The acknowledged limitation (two full compiles in one process) is not a live runtime flow.

No files require special attention; the logic change in reflex/app.py is small, well-commented, and the guard condition handles all existing call sites correctly.

Important Files Changed

Filename Overview
reflex/app.py Adds _evaluated_pages: set[str] field and an early-return guard in _compile_page that skips re-evaluation when a route was already compiled in this process, while still honouring save_page=True if the component is missing from _pages.
reflex/compiler/compiler.py After a full compile, marks all compiled routes in app._evaluated_pages so that subsequent skip-/marker-path calls to _compile_page are no-ops; one-line addition with no other logic changes.
tests/units/test_app.py New regression test test_compile_page_is_idempotent_for_component_state validates that the forked-worker and same-process-serve scenarios no longer duplicate ComponentState subclasses, and covers the save_page=True contract.
news/6710.bugfix.md Changelog entry describing the bug and fix; no code impact.

Reviews (2): Last reviewed commit: "honor save_page contract in _compile_pag..." | Re-trigger Greptile

Comment thread reflex/app.py Outdated
@masenf masenf merged commit 2d446c3 into main Jul 6, 2026
107 checks passed
@masenf masenf deleted the fix/component-state-double-eval-prod branch July 6, 2026 23:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants