perf: split router into per-field base vars; gather connection-static router data at connect - #7068
Conversation
…onnect Reimagines #6906 without touching the delta machinery: instead of eliding unchanged RouterData fields during delta serialization, the router is no longer a single serialized base var. Each kind of router data lives in its own base var on the root state (router_session, router_headers, router_page, router_url, router_route_id), so the existing per-var delta machinery naturally re-sends only what changed: - a navigation dirties only page/url/route_id (measured: 1898 -> 619 bytes, -67%, on a realistic connection; session and headers ship once per connection instead of on every page change) - a reconnect dirties only the session - an event without a route change re-sends no router vars at all State.router remains as a switchboard. On instances it is a property that composes a RouterData view from the per-field vars (and decomposes on assignment), so all existing reads/writes keep working. On classes it returns RouterDataVar, whose attributes resolve directly to the per-field base vars, so State.router.session.client_token and friends compile to the new var names with no frontend changes needed. ComputedVar dependency tracking recurses into the property getter, so vars reading self.router depend on the per-field vars; an explicit legacy deps=["router"] is expanded to all of them with a deprecation warning. The URL is stored as URLData, a dataclass mirroring ReflexURL's parsed components: json.dumps serializes str subclasses natively (bypassing the serializer registry), so a bare ReflexURL field would reach the frontend as a string instead of the component dict. Also move the static per-connection router_data gathering (headers, client IP, session id) from on_event to on_connect: they cannot change without going through on_connect again, so decoding every header on every event was wasted work. on_event now merges a per-sid cached fragment (~4.4x faster router_data prep, benchmarked by test_on_event_router_data), falling back to the connection environ if the connect was not seen; the cache is dropped on disconnect. Old pickled states are discarded by the existing schema check (the root state's base vars changed); __setstate__ drops the legacy router entry so unpickling them does not crash before that check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
|
There was a problem hiding this comment.
All reported issues were addressed across 13 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…factor-7j1305 # Conflicts: # tests/units/test_app.py
…ations test_router_var_dep and test_router_var_dep_legacy_string define state classes locally, which register themselves in State's class-level _var_dependencies / _potentially_dirty_states and outlive the test. A later test that dirties a router var on a fresh State tree then resolves the stale entry and raises on the missing substate -- which already made test_chained_event_keeps_originating_router_data fail whenever it ran after test_state.py. Drop the registrations at the end of each test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
The changelog check requires a news fragment under every package whose source the PR touches; this change also edits reflex-base's route constants and event processor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
Merging this PR will improve performance by 6.31%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ⚡ | test_get_state_uncached[memory] |
70 ms | 64.5 ms | +8.62% |
| ⚡ | test_get_state_uncached[disk] |
143.6 ms | 138 ms | +4.05% |
| 🆕 | test_on_event_router_data |
N/A | 1.9 ms | N/A |
Tip
Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.
Comparing claude/router-vars-refactor-7j1305 (92ee46f) with main (8211edc)
Footnotes
-
17 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. ↩
dataclasses.replace() on the existing router_session value rejects anything that is not a dataclass instance, which broke the redis token manager tests: they drive on_connect with a mocked state whose router_session is a Mock. Rebuilding from router_data (as the pre-split code did, and as the event processor does) keeps the session var and router_data in step and works with the mocked state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
_update_router_vars gated on the router_data keys each var derives from, then assigned unconditionally. Different keys can still yield an equal value -- an absent key and an empty one both produce the default -- so a router_data update that changed nothing observable still dirtied the var, which marked the state touched and persisted it to redis. That showed up as an extra token in test_redis_token_manager_enumerate_tokens. The pre-split code compared the rebuilt RouterData before assigning; restore that, keeping the key check as the cheap gate that avoids rebuilding HeaderData on every navigation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
EventNamespace's token manager is redis-backed when a redis URL is configured, so linking a token in these tests left it visible to test_redis_token_manager_enumerate_tokens, which asserts an exact token count. Tear the namespace down the way the token manager tests' own factory does. Also stop expecting router_route_id in every on_load delta of test_dynamic_route_var_route_change_completed_on_load: those navigations all match the same route, so the route pattern only changes on the first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
|
CI caught three things on the first push, all genuinely this PR's. Fixed and pushed ( 1. Missing news fragment for 2. 3. A var could be dirtied when its value had not changed. That last one also sharpens the delta table in the description: a navigation now dirties These only reproduce with redis configured ( Generated by Claude Code |
There was a problem hiding this comment.
All reported issues were addressed across 7 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
Whole-router dependency (greptile P1, cubic P2): deps=[State.router] registered only router_url, because VarData.merge surfaces the first non-empty field name, so a cached var declaring the whole router went stale when any other router field changed. Vars now name the state fields a dependency must track via _dependency_field_names(); RouterDataVar names all five. Non-composite vars keep their existing behaviour. Cached headers aliasing (greptile P2, cubic P2): the cached headers dict was shared with every event and, through it, with the mutable state.router_data, so a handler mutating self.router_data["headers"] corrupted the connection cache. Copy it per event -- measured at 0.081us against the 1.347us decode it replaced, so the cache still pays for itself 17x over. My note on the PR claiming the copy was the cost being removed was simply wrong. Omitted static keys (cubic P2): a router_data carrying only the navigation keys says nothing about the session or headers, but _update_router_vars read the omission as a change and reset them to their defaults. A key absent from the new payload is now left alone. Non-origin headers (cubic P2): only the origin header feeds the page and URL, so a cookie change no longer rebuilds the navigation vars. Hardcoded identifiers (greptile P2): the router field names are now named constants, used at the lookup sites and in the dynamic route dependency. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
There was a problem hiding this comment.
All reported issues were addressed across 11 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
Both reviewers flagged this as P1. Rebuilding SessionData from router_data left router_session.client_token empty until the first event filled it in, and duplicate-token handling makes that worse: the state is loaded under a freshly issued token, so anything reading client_token in the meantime -- a background task, a shared-state link -- addresses the wrong state tree. Record the identity the state was actually loaded under. Also make the EventNamespace fixture async so its token-manager teardown awaits on the test's own event loop, rather than driving a redis client bound to that loop from a fresh one via asyncio.run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
|
Second review round addressed in Fixed
Fixture teardown ran on a fresh event loop (cubic P2). Now a Verified, no change
Not this PR's
CodSpeed "Performance Regression: -0.07%" — flagged on VerificationFull Generated by Claude Code |
There was a problem hiding this comment.
All reported issues were addressed across 11 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
The previous commit stopped an absent key from being read as a change, but the constructors still read the incoming dict directly, so a payload holding only some navigation keys rebuilt page and url without the origin header that gives them their host. Merge the new data over what the state last saw and build from that; the merged dict is also what the caller now stores, so a partial payload does not drop keys for the next comparison either. Merging additionally makes an absent key compare equal to what it replaced, so the explicit presence check is no longer needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
serialize_router_data and RouterDataVar independently spelled out the five keys of the serialized router shape, and the two have to agree: the literal a whole-router render produces is the object a component reads, and it must match what the delta carries. Name them once and use them in both places, with a test pinning the rendered keys to the serializer's and to what actually reaches the client. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
There was a problem hiding this comment.
All reported issues were addressed across 12 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
A partial payload is never equal to the full dict the state holds, so it always reaches the merge -- and when it merges to what is already there, assigning it still dirtied router_data, marked the state touched, and persisted it for an event that moved nothing. Same class of bug as the spurious var dirtying fixed earlier, reached through the router_data assignment instead. Assign only when the merge actually differs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
|
Follow-up on the Windows 3.14 unit-tests job, because my earlier note named a specific test and mechanism that do not explain this second occurrence — leaving that standing would misattribute it. What failed on The test monkeypatches Why I'm confident it isn't this PR's:
I did not determine the precise Windows-specific cause, and I'd rather say that than guess. What I can say is that both failures involve the per-machine Reflex user directory or a globally-patched module attribute — shared session state — which is where I'd look if it keeps recurring. Worth its own issue rather than riding on this PR. Re-run: not spent deliberately. The branch has advanced twice since ( Generated by Claude Code |
Correction: the Windows unit-test failure is a pre-existing suite-wide race, and I had the pattern wrongI said I'd report what Windows did on
That is falsified. On Three commits, three different tests in Root cause
Every test in Telemetry is not disabled for the unit suite — there is no Evidence1. Real telemetry work is queued during the unit run. Wrapping
Identical. The 23 come from 2. The mechanism reproduces. Running just that file with one extra thread calling Two different tests from the three CI hit — which is the point: the victim is whichever assertion the background write lands beside. 3. The diff is unrelated. It touches no part of I'm not claiming this is impossible to have been nudged by timing changes here — CI on Proposed patch (not pushed here)Keeping the unit suite off the shared worker fixes it at the source. In def _drop_telemetry_job(fn, /, *args, **kwargs) -> None:
"""Discard a telemetry job instead of queueing it on the worker thread.
Args:
fn: The callable the caller wanted to run in the worker.
args: Positional arguments the caller passed.
kwargs: Keyword arguments the caller passed.
"""
@pytest.fixture(autouse=True)
def no_background_telemetry(request) -> Generator[None, None, None]:
"""Keep the unit suite off the process-wide telemetry worker thread.
``telemetry.send`` hands the event to a single background worker that
resolves ``REFLEX_DIR`` (creating the installation id and its semantics
marker) and then POSTs to PostHog. Tests that ``monkeypatch.setenv`` that
directory mutate the process-global environment, so an in-flight telemetry
job can write into another test's ``tmp_path`` and race its assertions.
``test_telemetry`` exercises the worker on purpose and drains it itself.
Args:
request: The active pytest request, used to spot ``test_telemetry``.
Yields:
None, once the telemetry worker is neutralized for this test.
"""
if request.module.__name__.rpartition(".")[2] == "test_telemetry":
yield
return
original_submit = telemetry._submit
telemetry._submit = _drop_telemetry_job
try:
yield
finally:
telemetry._submit = original_submitVerified on a clean Two notes on the shape: it restores This belongs outside this PR rather than widening it. Happy to open it as its own PR if you want it — say the word and I'll do that instead of leaving it as a patch here. NextSpending the one re-run on Generated by Claude Code |
|
Re-run result, as promised: That is consistent with the race diagnosis above rather than proof of it; the reproduction and the identical 31-submission telemetry count on No merge conflict against Generated by Claude Code |
`field_names` was a flat tuple paired with a single `state`, so merging a var built from one state's field with a var built from another's dropped the second: `VarData.merge` kept only the fields whose state matched the first non-empty one. A computed var depending on such a composite var never tracked the other state's field and went stale when it changed. Make the canonical form a `Mapping[str, tuple[str, ...]]` of state to its field names, unioned across merges, and register dependencies against every state in it -- in `_add_static_dep`, in `add_dependency`, and in `DependencyTracker`, all three of which previously collapsed a composite var to one state. `state`, `field_names` and `field_name` stay as fallback accessors reporting the first state and its first field, which is what the remaining readers in `state.py`, `exceptions.py` and the memoize plugin want. The mapping is a plain dict rather than a `MappingProxyType`: VarData is pickled along with the states holding it, and mappingproxy cannot be pickled. It is built fresh per VarData and never mutated afterwards. Verified against a var spanning two states: the second state's field goes from untracked to tracked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
There was a problem hiding this comment.
All reported issues were addressed across 8 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
Three points from the automated review of c67a9bc: - `field_dependencies` was inserted before `imports` in `VarData.__init__`, so a caller passing four or more arguments positionally would have bound `imports` to the new parameter. No caller in the repo does, but VarData is public surface in reflex-base. Move it after the existing parameters. - The new composite-dependency test left its consumer registered in both source states' `_var_dependencies` and `_potentially_dirty_states`, which outlive the test; a later test dirtying either field would resolve the stale entry and raise on the missing substate. Tear them down, as the router dependency tests nearby already do. - The news fragment claimed existing readers are unaffected. That holds for a multi-state composite -- the old merge dropped other states' fields, so `field_names` reports the same thing it did before -- but not for a VarData carrying field names and no state, which used to be folded into the first state's list and now sits under its own "" key. Point readers at `field_dependencies` instead of claiming blanket compatibility. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
`field_names` was not something `main` exposes -- it was introduced by an earlier iteration of this PR, so keeping it as a "fallback accessor" added a net-new public name to `VarData` purely to stay compatible with an intermediate state of the same branch. Nothing downstream has ever seen it. Remove the property, the `field_names=` constructor argument and the branch in `_normalize_field_dependencies` that consumed it. `field_name` now reads the mapping directly. `state` and `field_name` stay, since those are the two names `main` actually has. Against `main`, `field_dependencies` is now the only public name this adds to `VarData`; every remaining `field_names` in the diff is a local loop variable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
`__setstate__` popped a bare `"router"` literal. Extract it, but not to `constants.ROUTER`: that constant names the public switchboard as it is today, while this is a historical name frozen into pickle payloads already on disk. Renaming the switchboard must not change what those are keyed by, so the two are independent despite sharing a value now. The drop also had no test, though the PR description claims it works. Add one, verified to fail without the pop -- restoring the legacy entry routes `router` through the descriptor's setter and raises SetUndefinedStateVarError, which is the crash the line prevents. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
#7136 landed, so the bespoke `_check_reserved_router_names` can go. It is not #7136's validator that covers these names -- that one pops state vars out of its reserved set, so the `rx_router_*` fields are not in it. What rejects them is the framework's general inherited-var shadow detection: a substate redeclaring `rx_router_session` shadows a `BaseState` var like any other field and raises BaseVarShadowsInheritedVarError. That is a better mechanism than a router-specific list, so the check is redundant. Verified before removing: an annotated field, an unannotated class attribute and a computed var are all rejected on a substate, and `router` itself is rejected by #7136's validator. Two cases are not covered, both general rather than router-specific, so neither is papered over here: - a mixin declaring an inherited var is not rejected, and neither is a state consuming it; the field is silently shadowed. This is not about the router -- a mixin can shadow any inherited base var, while a direct subclass doing the same is correctly rejected. - a direct `BaseState` subclass starts its own root, so there is no inherited var to shadow. `test_router_field_names_are_reserved` is narrowed to the substate path it actually guarantees, and the breaking-change fragment now names the error callers will really see. The only merge conflict was the `reflex.istate.data` import list in tests/units/test_state.py; resolved as the union of both sides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
Splitting the router into per-field vars moved the page URL into `URLData`,
a dataclass mirroring every parsed component of `ReflexURL`. Both of them
store the derived components eagerly, so a state write pickled the URL text
eight times over -- and `URLData` is the storage form of a router var, so
that happened on every write.
`ReflexURL.__reduce__` and `URLData.__reduce__` now persist only the URL
itself and re-split it on the way back in. Measured on the state-manager
benchmark tree (root + three substates), against main:
main before after
root state pickle 805 B 883 B 741 B
whole tree pickle 2117 B 2195 B 2053 B
tree pickle time 35.62us 38.95us 34.27us
So the write path is now cheaper than main rather than 9% dearer, and
loading is cheaper too (URLData unpickle 8.38us -> 5.46us), which is the
`test_get_state_uncached` direction. Profiling `set_state` against main
leaves one difference: 9 extra `dict.pop` calls per write, 0.1% of the
operation, inherent to skipping five router vars instead of one.
Fixing this surfaced a real divergence. `URLData`'s defaults were written
out by hand as all-empty, but `ReflexURL("").origin` is "://", so
`URLData()` and `URLData.from_url(ReflexURL(""))` disagreed on `origin`.
A fresh state therefore reported "" where main reports "://", contradicting
this PR's claim that the router API is unchanged -- and with `__reduce__`
in place the value would also have changed across a save/load cycle. The
defaults are now read off the empty URL, so the two construction paths
cannot drift again and the emitted payload matches main byte for byte. The
two test fixtures that had encoded the wrong origin are corrected.
`"://"` as the origin of an empty URL is odd, but it is main's behavior and
changing it belongs in its own change, not in a performance one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
FarhanAliRaza
left a comment
There was a problem hiding this comment.
I tested this in a real app in dev mode, with a headless Chromium that logged every received WebSocket frame.
The app has three routes: an index page, a page with on_load, and a dynamic route. It renders every State.router.* class-level var and the whole State.router object. It has computed vars with auto-deps, deps=["router"], deps=[State.router], deps=[State.router.url], deps=[State.router.headers], and a composite dep that spans two states.
The deltas match the table in the description. The first event carries all five router vars. A navigation to a different route carries page, url and route_id. A navigation inside the same dynamic route carries page and url. An event with no route change carries none. After a page reload the token stays and the session id changes.
All router-dependent computed vars refresh on navigation, and the headers-only one correctly does not. The dynamic arg, the query parameters, on_load, rx.redirect from an event, and a direct load of a URL with a query and a fragment are correct. Assignment to self.router.url.path is rejected. A mutation of router_data["headers"] does not leak into the next event. A pickle round trip of the root state keeps the router equal. A prod export succeeds. The browser console and the backend log are clean.
I also compared get_var_value on the router vars against main. That comparison found the regression below.
The requested changes are in the inline comments.
Review found four things on the current head.
`get_var_value(State.router)` raised UnretrievableVarValueError. The
switchboard renders as an object literal over the five per-field vars and
had no var data of its own, so there was no field to read. It now names the
`router` attribute it stands for, and the existing generic path resolves it
through the property to the composed RouterData.
Probing every router form against main shows the split traded one case for
three rather than simply losing one:
main before after
router ok FAIL ok
router.session FAIL ok ok
router.page FAIL ok ok
router.route_id FAIL ok ok
router.url FAIL FAIL FAIL
`router.url` fails identically on main -- an item operation through a cast
carries no state on its own var data -- so it is not this PR's and is left
alone.
Naming the field puts `router` in the declared dep set alongside the five,
which made the legacy-string expansion warn for `deps=[State.router]` too.
The warning is now gated on the string form, which is the only one that
arrives without the per-field names, and it identifies the computed var:
the dep scan is lazy, so the caller frame `console.deprecate` reports is
unrelated to the declaration and was pointing at `<frozen abc>:106`.
Registration still resolves to exactly the five fields; nothing is
registered against `router`, which has no backing field and would never be
dirtied.
`deprecation_version` stays 0.9.12. The review read the latest tag as
v0.9.11a2 with 0.9.11 unreleased, but `git tag --sort=-v:refname` sorts the
prerelease above the release: v0.9.11 was tagged 2026-09-11, after a2, and
v0.9.11.post1 on 2026-09-15 is the newest release tag.
Also drop "Deprecated fallback accessor" from the `VarData.state` and
`field_name` docstrings -- nothing deprecates them and the framework still
reads both -- and shorten the narrative comments in app.py,
base_state_processor.py, state.py and data.py to the present behavior.
tests/units: 9604 passed, 6 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
…factor-7j1305 # Conflicts: # tests/units/test_app.py
|
The latest automated review drops to 4/5 with "not yet safe to merge because a syntactically valid but structurally corrupt stateful-page marker can still crash backend startup". That code isn't this PR's — it arrived from The stateful-page marker lives in
This is the same failure mode as the changelog-fragment finding earlier in this PR: the review is reading the merge commit's contents as the PR's diff. The review summary gives it away — "changes added since the previous review also harden stateful-page marker writes" describes #7142, not anything written here. Worth saying that the underlying concern may well be valid against #7142 — if a marker file decodes as JSON but isn't the expected shape, a read could still fail. That belongs on that change rather than here, and I haven't filed it since it's not mine to judge. For what it is worth on the current head Generated by Claude Code |
CodSpeed flagged test_cond_operations at -3.7% on the current head. It is
this PR's: `rx.cond` merges the VarData of its three operands, and carrying
a per-state field mapping costs more than main's two short-circuiting scans
over a stored `state`/`field_name` pair.
Two passes were wasted. `merge` rebuilt a state's tuple once per
contributing var, via `dict.fromkeys` over the previous tuple spread with
the new names; then `VarData.__init__` normalized the result and rebuilt
every tuple again. It now accumulates ordered sets and hands those to the
constructor, which materializes each tuple exactly once. `__init__` already
accepted any iterable of names, so only the annotation widens.
Measured on the benchmark's own shapes, min of 3 runs of 9:
main base before after
cond x44 2.359 ms 2.55 ms 2.50 ms
VarData.merge x1000 13.34 ms 15.3 ms 14.3 ms
So merge goes from ~13% over the base to ~7%, and the benchmark from ~7% to
~5.5%. The remainder is structural: a `Mapping[str, tuple[str, ...]]` cannot
be merged as cheaply as picking the first non-empty of two strings, and that
mapping is what makes a composite var track fields across every state it
reaches. Left there rather than traded away.
Note the CodSpeed run compared against 8c06e79 rather than the real base
a1ba536, having found no successful run on the latter, so its percentage is
against a slightly different tree than this branch merges.
tests/units: 9663 passed, 6 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh
…factor-7j1305 # Conflicts: # tests/units/reflex_base/vars/test_base.py
#7068 replaced the root state's single `router` var with five `rx_router_*` vars, which changed the shape of `state.dict()` and of every delta. The existing 7068 fragments describe the new vars and the shadowing error, but nothing told downstream code that inspects a serialized state — such as reflex-enterprise's REST redaction, #7214 — which keys to read now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NeyxWsuC9hqSKq8YcEwZyY (cherry picked from commit 4290548fe328e0ca010e3cbb106c6ac263116826)
Supersedes #6906, reworked along the lines of #6906 (review): rather than special-casing the delta computation to elide unchanged
RouterDatafields, the router is no longer a single serialized base var. There is no partial-payload format, no frontend merge logic, and no version/capability gate — the existing per-var delta machinery does the work.What
RouterDatawas one base var, so every navigation reassigned it and re-shipped the whole object — session block and every request header (twice, viaraw_headers) — on every page change, even though none of that can change without a reconnect.Each kind of router data now lives in its own base var on the root state (
rx_router_session,rx_router_headers,rx_router_page,rx_router_url,rx_router_route_id), so dirty tracking is naturally scoped to what actually changed:page,url,route_idpage,urlsessionheadersMeasured on a realistic connection (a few cookies, normal UA): the navigation router delta drops 1898 → 619 bytes (−67%).
A var is assigned only when its rebuilt value actually differs, so a
router_dataupdate that changes nothing observable does not dirty anything, does not mark the state touched, and does not persist it.The
rx_prefix keeps these framework-owned fields from colliding with a field an app already defines;routeritself stays unprefixed as the public switchboard. A substate declaring one of the five reserved names raisesBaseVarShadowsInheritedVarError— the framework's general inherited-var shadow detection, not a router-specific list — rather than silently shadowing.State.routerstays a switchboardThe top-level
RouterDataremains the public API and keeps working unchanged in both positions:BaseStatecomposes aRouterDataview over the per-field vars, and assignment decomposes one back into them. Existingself.router.session.client_tokenreads andstate.router = ...writes are untouched.RouterDataVarwhose.session/.headers/.page/.url/.route_idresolve directly to the underlying per-field base var, soState.router.session.client_tokencompiles to…rx_router_session_rx_state_?.["client_token"]. RenderingState.routeritself still emits an object literal matching the pre-split shape, so passing the whole router to a component keeps working.routeris also listed inState.vars, so substates inherit the switchboard and resolveself.routerthrough a single parent delegation rather than one per field. It has no backing field, so it never reaches a delta.Dependency tracking covers the whole router in both forms.
self.routerin a computed var auto-deps through the property getter onto the per-field vars.deps=[State.router]covers all five too:VarDatanow records every state field a var is built from infield_dependencies, aMapping[str, tuple[str, ...]]of state name to field names, unioned per state as vars merge — otherwiseVarData.mergewould surface one field name and leave the computed var stale when any of the others changed.deps=[State.router.url]narrows to just that field. An explicit legacydeps=["router"]is expanded to all five with aconsole.deprecate()warning (deprecated 0.9.12, removal 1.0). A computed var namedrouternow raisesComputedVarShadowsBaseVarsErrorrather than silently shadowing the descriptor.One new type:
URLData, a dataclass mirroringReflexURL's parsed components. It exists becausejson.dumpsserializesstrsubclasses natively and never invokes thedefault=serializehook — a bareReflexURLbase var would reach the frontend as a plain href string instead of the component dict, breakingrouter.url.pathand friends on the client.self.router.urlstill hands back aReflexURL. It mirrors those components for the frontend payload only: in the state store both it andReflexURLpersist just the URL and re-split it on load, so a state write carries the URL once rather than eight times.on_event→on_connectToken, sid, headers, and client IP cannot change without going through
on_connectagain, so decoding every request header from the ASGI scope on every single event was wasted work. Those entries are now built once per connection inon_connectand cached per sid (dropped inon_disconnect);on_eventmerges the cached fragment and only computes the genuinely dynamicPATH/QUERY. If a socket was never seen byon_connect,on_eventfalls back to the connection environ and caches the result.The headers mapping is copied into each event, so a handler mutating
self.router_data["headers"]cannot corrupt the connection cache. That copy measures 0.081µs against the 1.347µs decode it replaced, so the cache still pays for itself ~17x over.Per-event
router_datapreparation: 2.85µs → 0.65µs (~4.4x faster). The processor side benefits too —_update_router_varsonly rebuilds the dataclasses whose backing keys changed, soHeaderData.from_router_datano longer runs on every navigation.State construction and serialization cost
Splitting one base var into five costs something on the state-construction and serialization paths, so two follow-ups pay it back:
SessionData,HeaderDataandURLDatadefaults are shared rather than rebuilt per state. All three are frozen dataclasses whose members are themselves immutable, so one instance can back every state's field.PageDatakeepsdefault_factory— itsparamsis a plain mutable dict.ReflexURLandURLDatapersist only the URL itself, re-splitting it on the way back in, instead of writing scheme, netloc, origin, path, query, query parameters and fragment alongside the href on every state write.ReflexURLalso rejects__setattr__/__delattr__:URLData.hrefdefaults to a class-levelReflexURL("")shared by every state that has not navigated, so without a guardstate.router.url.path = "/x"rewrote that shared object for all of them.Root-state instantiation, 3.14, min of 7 × 2000 runs:
main12.78µs, after the split 15.02µs, with shared defaults 10.92µs. On the state-manager benchmark tree, the root state's pickle goesmain805 B → 883 B after the split → 741 B once the URL stops being stored as its parsed pieces, and the whole tree pickles in 34.27µs againstmain's 35.62µs. CodSpeed reports +6.11% overall with no regressed benchmarks:test_get_state_uncached+8.66% (memory) and +3.63% (disk), and theset_state/modify_statepaths back at parity withmain.Compatibility
State.router.*/self.router.*API is unchanged.rx_router_*names are reserved; a substate declaring one raisesBaseVarShadowsInheritedVarErrorand must rename its field.__setstate__drops the legacyrouterentry so unpickling one doesn't crash before reaching that check.router_datapayload carrying only the navigation keys leaves the connection-scoped vars alone, rather than reading the omission as a reset to defaults (which is what the pre-split code did).All Submissions:
Type of change
deps=["router"]is deprecated, not removed.Changes To Core Features:
test_navigation_delta_elides_connection_scoped_router_vars— end-to-end through the processor, asserts exactly which router vars land in the delta for first event / navigation / no-op / reconnect. Verified it fails onmain.test_update_router_vars_granular_delta— per-field dirty tracking, including keys that differ but derive equal values.test_update_router_vars_ignores_omitted_static_keys,test_update_router_vars_non_origin_header_leaves_navigation_clean— payloads that must not disturb unrelated vars.test_router_var_dep_whole_router,test_router_var_dep_legacy_string_still_compiles— whole-router dependency coverage in both the Var and legacy string forms.test_router_var_resolves_to_per_field_base_vars,test_router_var_renders_composed_object,test_router_var_carries_state_var_data,test_url_data_serializes_like_reflex_url— the switchboard var and URL serialization.test_router_is_listed_as_a_var_and_inherited_by_substates,test_router_field_names_are_reserved— the switchboard's presence invarsand the shadow rejection on a substate.test_var_data_merge_collects_field_names,test_var_data_merge_keeps_field_names_of_every_state,test_composite_var_dep_tracks_fields_in_every_state— theVarData.field_dependenciesmachinery, including a var built from a field of each of two states.test_reflex_url_rejects_attribute_assignment,test_shared_empty_url_default_cannot_be_mutated_through_a_state— the shared-default immutability guard; each confirmed to fail without the fix.test_url_data_default_matches_the_parsed_empty_url,test_reflex_url_and_url_data_survive_pickling,test_pickling_a_url_does_not_store_its_derived_components— the URL storage form: thatURLData()equalsURLData.from_url(ReflexURL("")), that a round-trip keeps every component, and that the payload carries the URL once.test_on_event_uses_connect_time_router_data,test_on_event_falls_back_to_environ_without_connect,test_on_event_does_not_share_the_cached_headers— the connect-time cache, its fallback, and its isolation.test_on_event_router_data— new codspeed benchmark for the per-event path.tests/unitswith redis configured, matching the ubuntu CI jobs: 9601 passed, 6 skipped, 0 failed.tests/integration/tests_playwright/test_router_query.py: 6/6 pass in dev and prod — real browser, router-dependent computed vars reactive across navigation and redirects.uv run ruff check .,uv run ruff format --check .,uv run pyright reflex tests, and the fullpre-commitrun: all clean.News fragments added under
news/andpackages/reflex-base/news/.Reviewer notes
Judgment calls flagged here that review caught, now fixed rather than argued for: the whole-router
deps=[State.router]dependency covered only one field, andVarDatacollapsed a composite var to a single state; the cached headers dict was shared by reference (measuring showed the defensive copy costs ~6% of what the cache saves); the deprecation message steered users at the internalrx_-prefixed names instead of the Var form; and the shared emptyReflexURLdefault was mutable.One thing I deliberately did not change, for you to weigh in on:
_patch_statemarks all five router vars dirty on every linked-shared-state event (thread). Onmainthis isdirty_vars.add("router")— the single var holding all five fields — so the emitted payload is identical and this is not a regression. Reducing it means invalidating dependent computed vars without marking the base vars for emission, which needs a mechanism that doesn't exist yet; that deserves its own change with shared-state coverage.rx_router_pagestill ships as its own var even thoughpageis deprecated (removal 1.0), because dynamic route args readpage.params. Folding it intorx_router_urloncepageis gone remains available as a cleanup, but it is no longer buying anything: the serialization regressions it was meant to close turned out to beURLDatastoring the URL's parsed pieces, which is fixed here, so nothing about this PR waits on an API removal.🤖 Generated with Claude Code
https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh