Skip to content

perf: elide unchanged connection-scoped router fields from deltas - #6906

Closed
Alek99 wants to merge 9 commits into
mainfrom
alek/router-delta-slim
Closed

Alek99 wants to merge 9 commits into
mainfrom
alek/router-delta-slim

Conversation

@Alek99

@Alek99 Alek99 commented Aug 18, 2026

Copy link
Copy Markdown
Member

What

Every client-side navigation fires on_load_internal, which reassigns state.router and marks the whole var dirty — so every navigation delta re-ships the complete RouterData, including the session block and every request header (twice, via raw_headers). Measured on a minimal app with no cookies:

  • navigation delta: 1,870 bytes, of which 1,323 bytes (71%) are session + headers that cannot change for the life of the websocket
  • apps with real auth cookies re-ship those on every page change too

This lands on the client on every nav (and every event that carries a changed router_data), gets JSON-parsed, and replaces the router value in the root state context.

How

Ship session/headers only when they actually changed:

  • BaseStateEventProcessor compares the previous router when reassigning; if session and headers are unchanged it arms a transient _router_static_unchanged flag on the root state (never pickled meaningfully — recomputed on every reassignment).
  • get_delta then serializes only the per-navigation fields (page, url, route_id) for the router var via serialize_partial_router_data (extracted from the existing serializer, so the two can't drift).
  • applyDelta on the frontend merges partial router payloads over the previously received value, so session/headers carry forward on the client.
  • Fail-safe: any direct router write clears the flag in __setattr__ — covering the connect handler's sid update and the linked-token client_token rewrite — falling back to the full payload.

Reconnects and cross-worker moves are safe by construction: a new socket has a new session_id, so the comparison fails and the full router is sent; a state restored on another worker has empty router_data (excluded from pickle), which also fails the comparison.

Compatibility

Rolling deployments / cached frontends. A pre-upgrade frontend replaces the router value instead of merging, so it must never receive a partial payload. The frontend already advertises the exact version it was compiled by as the websocket subprotocol, and the backend already compares it; this PR turns that existing handshake into the capability gate. on_connect records subprotocol == backend version on the root state (_partial_router_capable), and get_delta sends the partial payload only when it is set. Older bundles, proxies that strip the subprotocol, and polling transports all fall back to the full router in every delta. The capability survives pickling and worker moves, and reconnects re-evaluate it — a tab that reconnects with a stale cached bundle after a redeploy is downgraded to full payloads. Verified live by forcing a mismatched subprotocol from a real browser client: every navigation delta carried the full session/headers.

Internal fields are reserved. _router_static_unchanged (transient, never pickled) and _partial_router_capable are declared is_var=False fields on BaseState, added to RESERVED_BACKEND_VAR_NAMES, and __init_subclass__ raises ReservedStateFieldError if a user state declares either name — a collision surfaces as an error instead of silently steering delta serialization.

Measured result

  • Navigation delta drops 1,870 → 516 bytes (−72%) on the minimal app.
  • Rendered router vars (State.router.session.client_token, State.router.headers.user_agent) verified intact on the client after slim deltas, across navigations and events, on a fresh boot.
  • New unit test test_partial_router_delta covers full-on-first-send, partial-when-unchanged, and full-again-after-direct-write. test_dynamic_route_var_route_change_completed_on_load updated: its router_data carries no sid/headers, so the partial payload correctly applies from the first on_load. Full unit suite: 7,460 passed.

Review in cubic

@Alek99
Alek99 requested a review from a team as a code owner August 18, 2026 00:13
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@codspeed

codspeed Bot commented Aug 18, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 27 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing alek/router-delta-slim (7f506e8) with main (d28be54)

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.

@greptile-apps

greptile-apps Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR reduces navigation delta sizes by omitting unchanged connection-scoped router data while preserving compatibility with clients that cannot merge partial payloads.

  • Compares session and header data before selecting partial serialization.
  • Merges partial router updates into existing frontend state.
  • Gates partial payloads on the websocket version handshake and reserves internal bookkeeping fields.
  • Adds coverage for capability gating, changed connection data, direct writes, and reserved-field collisions.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/reflex-base/src/reflex_base/.templates/web/utils/state.js Merges partial router payloads over the router data already held by the frontend.
packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py Detects whether connection-scoped router fields remained unchanged during event processing.
reflex/app.py Records partial-router capability from the websocket subprotocol version handshake.
reflex/istate/data.py Separates full RouterData serialization from its per-navigation partial representation.
reflex/state.py Tracks capability and invalidation state and conditionally emits partial router deltas.
tests/units/test_app.py Exercises event-processor behavior for legacy clients and changed session or header data.
tests/units/test_state.py Covers partial serialization gates, direct-write invalidation, and reserved internal fields.

Reviews (9): Last reviewed commit: "clarify that the headers-fallback leg re..." | Re-trigger Greptile

Comment thread packages/reflex-base/src/reflex_base/.templates/web/utils/state.js Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py Outdated
Comment thread packages/reflex-base/src/reflex_base/.templates/web/utils/state.js Outdated
Comment thread tests/units/test_state.py Outdated
Comment thread packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py Outdated
@Alek99
Alek99 marked this pull request as draft August 18, 2026 00:50
@Alek99
Alek99 marked this pull request as ready for review August 18, 2026 01:31
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 11 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread tests/units/test_state.py Outdated
Comment thread reflex/state.py
Comment thread tests/units/test_app.py

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread tests/units/test_app.py Outdated
@Alek99

Alek99 commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

On the CodSpeed regression (test_var_access[non_mutable_scalar], −4.72%)

Investigated rather than acknowledged blindly; summary:

  • The diff contains no read-path change. state.scalar reads go through BaseState.__getattribute__, which this PR does not touch — the only __setattr__ addition is on the write path, and get_delta is not in the benchmark loop.
  • Walltime cannot reproduce it. Interleaved A/B of the benchmark's exact loop (10k scalar reads, 200 samples/round, 4 alternating rounds, PR head vs 7e57be74) shows the PR equal-or-faster in every round — e.g. per-round minimums 2.86–2.98 ms (PR) vs 2.94–3.09 ms (base).
  • Most plausible mechanism for the instruction-count delta: the two new declared fields (_router_static_unchanged, _partial_router_capable) add two entries to every state instance's __dict__, which can shift dict sizing/probing, and added code in state.py shifts layout — the kind of effect simulation mode registers but wall clock does not. The same cost profile applies to any new BaseState field (e.g. _was_touched).

The fields are the review-required design (declared + reserved so user states cannot silently shadow them), so I'd rather not contort the storage to appease a micro-benchmark that walltime can't confirm. Suggest acknowledging on CodSpeed; happy to investigate further if the report shows the extra instructions somewhere other than dict handling.

@masenf masenf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think a better approach here would be to break the router data up into separate base vars so we're not working against reflex's delta system.

each type of router data is already defined independently, so we could keep the top level RouterData object as a switchboard that gives you the per-field base var value instead of RouterData being the root of a dataclass that gets entirely serialized.

i'm not a fan of the special case here

Alek99 added 9 commits August 19, 2026 11:05
Every client-side navigation fires on_load_internal, which reassigns
state.router and marks the whole var dirty, so each navigation delta
re-ships the complete RouterData - including the session block and
every request header (twice, via raw_headers). Measured on a minimal
app: 1,323 of 1,870 delta bytes (71%) are connection-scoped fields
that cannot change for the life of the websocket; apps with cookies
ship those on every page change too.

Ship session and headers only when they actually changed:

- The event processor compares the previous router value when
  reassigning; when session and headers are unchanged it arms a
  transient flag on the root state.
- get_delta then serializes only the per-navigation fields (page,
  url, route_id) for the router var.
- The frontend merges partial router payloads over the previously
  received value, so session/headers carry forward on the client.
- Any direct router write elsewhere (connect handler sid updates,
  linked-token client_token rewrites) clears the flag via
  __setattr__, falling back to the full payload.

Reconnects and cross-worker moves are safe by construction: a new
socket has a new session id, so the comparison fails and the full
router is sent; a state restored on another worker has empty
router_data, which also fails the comparison.

Measured on the example app: navigation delta drops from 1,870 to
516 bytes, with rendered router vars (client_token, user_agent)
verified intact on the client after slim deltas.
Greptile flagged the router protocol keys being hardcoded independently
on the backend and frontend. Rather than only naming the literals, make
the invariant structural: CONNECTION_SCOPED_ROUTER_FIELDS declares which
RouterData fields are fixed for a connection, the full serializer builds
its connection-scoped half from that tuple, and the event processor
compares via router_connection_scope() over the same tuple. A field can
therefore no longer be elided from navigation deltas without also being
compared. The frontend merge is field-agnostic, so it needs no matching
list; its one shared identifier is now the named ROUTER_FIELD constant.

Adds a regression test asserting that every field omitted from the
partial payload is one whose change is visible to the comparison
(verified to fail if the serializer is re-hardcoded away from the
tuple).
… the processor

- Exclude _router_static_unchanged from __getstate__ alongside _was_touched.
  It is request-scoped and recomputed on every router reassignment;
  persisting it could arm a partial router delta for a client that never
  received the connection-scoped fields.
- Add a processor-driven test that drives the real comparison instead of
  setting the internal flag by hand, covering full-on-first-event,
  partial-when-unchanged, and full-again-when-the-session-id-changes.
  Verified to fail if the comparison is short-circuited to always arm.
… fields

Addresses the two review blockers:

Rolling deployments (P1): a cached pre-upgrade frontend uses the old
replacing applyDelta, so a partial router payload would delete its
session/headers. The frontend already advertises the exact version it
was compiled by as the websocket subprotocol, and the backend already
compares it (previously warn-only). Use that existing handshake as the
capability signal: on_connect records subprotocol == backend version
as `_partial_router_capable` on the root state, and get_delta sends
the partial payload only when it is set. Anything else - older
bundles, proxies that strip the subprotocol, polling transports -
falls back to the full router in every delta. The capability is
connection-scoped and survives pickling/worker moves; reconnects
re-evaluate it, so a tab that reconnects with a stale cached bundle
after a redeploy is downgraded to full payloads.

Reserved fields (P2): single-underscore names are valid user backend
vars, so the internal flags are now declared fields on BaseState
(is_var=False, like _was_touched), added to RESERVED_BACKEND_VAR_NAMES,
and __init_subclass__ raises ReservedStateFieldError if a user state
declares either name - collisions surface as errors instead of
silently steering delta serialization.

Tests: the processor-driven test now covers the pre-capability phase
(full deltas even with an unchanged session), the capability flip, and
the changed-session fallback; removing the capability gate makes it
fail. New test asserts redefining either reserved field raises.
- The reserved-field check ran after the mixin early-return, so a
  mixin=True base could smuggle either internal router field into
  concrete states unchecked (reproduced before fixing). Run the check
  before the mixin return so mixins are validated at definition, and
  cover both the annotated and value-only mixin cases in the test.
- The processor test now also covers changed headers with an unchanged
  session id, the other half of the connection scope; verified it fails
  if headers are dropped from CONNECTION_SCOPED_ROUTER_FIELDS.
- test_partial_router_delta no longer re-implements the processor's
  comparison by hand; it treats the flag and the client capability as
  explicit givens and pins get_delta's serialization for each
  combination, with the arming logic itself covered end to end by the
  processor-driven test.
The sid in that payload equals the one the state already holds from the
previous event, so the session compares equal and only the headers
differ; name the sid and say so, since the literal reads like a second
session change.
@Alek99
Alek99 force-pushed the alek/router-delta-slim branch 2 times, most recently from 36cdaf1 to 7f506e8 Compare August 19, 2026 18:09
@Alek99

Alek99 commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

The integration-app-harness (memory, 3.14, 1) failure is the known pre-existing react-helmet race (test_call_script → "scripts not loaded"), root-caused and fixed in #6905 (analysis): react-helmet's rAF-deferred head flush is intermittently lost around hydration, dropping rx.script tags — ~1-in-5 locally on main's code path, independent of this PR. This branch doesn't carry the fix to avoid cross-PR conflicts; once #6905 merges, a rebase inherits it and this shard stops flaking. Rerun triggered.

@masenf

masenf commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Closing in favor of #7068, which supersedes this.

Per @masenf's review, the new PR takes the "don't work against the delta system" approach instead: rather than eliding unchanged RouterData fields during delta serialization, the router data is split into separate base vars on the root state (router_session, router_headers, router_page, router_url, router_route_id), with the top-level RouterData kept as a switchboard over them. Dirty tracking then scopes deltas on its own — no _router_static_unchanged flag, no partial payload format, no applyDelta merge on the frontend, and no _partial_router_capable version gate, since there is no new wire format to negotiate.

Same win, measured on a comparable connection: navigation router delta 1898 → 619 bytes (−67%). #7068 also moves the connection-static router data gathering (headers, client IP, sid) out of on_event into on_connect, which makes per-event router_data preparation ~4.4x faster.


Generated by Claude Code

@masenf masenf closed this Sep 8, 2026
masenf added a commit that referenced this pull request Sep 18, 2026
… router data at connect (#7068)

* Split router into per-field base vars; gather static router data at connect

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

* Add news fragments; stop router dep tests leaking class-level registrations

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

* Add reflex-base news fragment for the router var split

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

* Rebuild the session var from router_data when linking a token to a sid

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

* Assign a router var only when its rebuilt value differs

_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

* Keep the new on_event tests from leaking tokens into a shared redis

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

* Address review findings on the router var split

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

* Record the connecting token in router_data when linking a sid

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

* Carry omitted router_data keys forward when rebuilding the router vars

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

* Give the serialized router keys a single source of truth

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

* Do not store merged router_data when it changes nothing

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

* List `router` as a var so substates inherit the switchboard

`_RouterDescriptor` subclasses `property`, which `_is_user_descriptor`
excludes, so after the split `router` disappeared from `State.vars` (and
from every substate's `inherited_vars`) even though it is still usable as
a Var everywhere one is accepted. Add it explicitly.

Nothing that serializes vars is affected: `get_delta` and `dict` iterate
`base_vars`/`computed_vars`, so a switchboard with no backing field can
never reach the wire. `test_dict` asserted the wire keys by deriving them
from `vars`; it now derives them from the two dicts that actually back
fields, which says the same thing without assuming every var has one.

Since `inherited_vars` aliases the parent's `vars`, substates now resolve
`self.router` through one parent delegation in `__getattribute__` instead
of five (one per field), measured at 22.5us -> 16.2us at depth 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh

* Correct the stale get_skip_vars comment for `router`

`router` is listed in `vars` now, so "Not a var" no longer describes why
it is skipped. Say what the entry actually guards: substates are already
covered by `set(cls.inherited_vars)`, and this catches a root state class
whose inherited_vars has no `router` to skip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh

* Track every field a composite var reads in VarData.field_names

`VarData.merge` kept only the first non-empty `field_name`, so a var built
from several state fields could only ever report one of them. Any
`deps=[composite_var]` therefore tracked a single field and went stale when
the others changed. `RouterDataVar` worked around this with a bespoke
`_dependency_field_names` override that reached into its five sub-vars.

Fix it where the information is lost instead: `VarData` now stores
`field_names` and merge collects them in order, deduped. `field_name`
becomes a property returning the first, so every existing reader is
unaffected. Names carrying a different `state` are dropped, because callers
pair these names with the single merged `state` and registering a field
against a state that does not have it fails dependency validation.

The `RouterDataVar` override is gone; the generic path now produces the
same five names for `deps=[State.router]`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh

* Prefix the per-field router vars with rx_

`router_session` and friends are plausible names for an app's own field,
and a collision is silent: the framework var wins and the app's field is
dropped without taking effect (see #7074). Prefixing them makes that
collision unlikely rather than merely unlucky. `router` itself keeps its
name -- it is the public switchboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh

* Fix router name collisions and composed-var dependencies

* Point the router deps deprecation at the Var form

The message steered users to `deps=["rx_router_url"]`, a string naming an
internal-prefixed backing var -- which makes the `rx_` names user-facing
API exactly where the PR is trying to keep them internal. Recommend the
Var form this PR already supports instead: `deps=[State.router.url]` for
one field (verified: tracks only `rx_router_url`), or `deps=[State.router]`
for all five, which is what the legacy string meant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh

* Hoist the reserved router name check out of __init_subclass__

`__init_subclass__` was already well past the complexity guideline on main
(19); the reserved-name loop pushed it to 21. Move the loop into a
`_check_reserved_router_names` classmethod alongside the other `_check_*`
/ `_validate_*` helpers, restoring the function to 19 and leaving the
behaviour (including the check running for mixins, before
`super().__init_subclass__`) unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh

* Share the empty router defaults across states

The split gave the root state four extra base vars, and three of their
`default_factory` defaults were rebuilt for every state instance even
though `SessionData`, `HeaderData` and `URLData` are frozen dataclasses
whose members are themselves immutable (`URLData.query_parameters` is a
`_FrozenDictStrStr`). One instance can back every state's field.

`field()` cannot express this: it shares a `default` only when its type
is in `IMMUTABLE_TYPES`, and deep-copies anything else per instance, so
these three use `Field(default=...)` directly. `PageData` keeps
`default_factory` -- its `params` is a plain mutable dict.

Root state instantiation, measured on 3.14 (min of 7 x 2000 runs):

    main    12.78us
    before  15.02us
    after   10.92us

so this removes the instantiation regression the split introduced and
lands 15% below main. Payload size is unchanged; the remaining CodSpeed
delta is serialization of the extra keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh

* Make ReflexURL reject attribute assignment

`URLData.href` defaults to a class-level `ReflexURL("")`, so the empty
URL object is shared by every state that has not navigated yet -- that
sharing comes from the dataclass default, not from how the field is
declared, and it behaves the same whether `rx_router_url` uses
`default_factory` or a shared default. `ReflexURL` had no assignment
guard, so `state.router.url.path = "/x"` rewrote that shared object and
every other such state saw `/x`.

Reject `__setattr__`/`__delattr__`; `__new__` keeps filling the parsed
components through `object.__setattr__`. `__slots__` is not available
here because `str` is variable-length.

Pickle, `copy` and `deepcopy` still round-trip every component, since
they restore instance state through `__dict__` rather than `setattr`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh

* Group VarData field dependencies by owning state

`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

* Address review of the VarData field dependency grouping

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

* Drop the field_names accessor from VarData

`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

* Name the legacy router pickle key and cover the drop

`__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

* Persist the router URL as the URL, not as its parsed pieces

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

* Resolve get_var_value(State.router) and name the deprecated var

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

* Build the merged field dependencies in one pass

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Farhan <www.mfarvirus@gmail.com>
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.

2 participants