Skip to content

perf: split router into per-field base vars; gather connection-static router data at connect - #7068

Open
masenf wants to merge 12 commits into
mainfrom
claude/router-vars-refactor-7j1305
Open

perf: split router into per-field base vars; gather connection-static router data at connect#7068
masenf wants to merge 12 commits into
mainfrom
claude/router-vars-refactor-7j1305

Conversation

@masenf

@masenf masenf commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Supersedes #6906, reworked along the lines of #6906 (review): rather than special-casing the delta computation to elide unchanged RouterData fields, 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

RouterData was one base var, so every navigation reassigned it and re-shipped the whole object β€” session block and every request header (twice, via raw_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 (router_session, router_headers, router_page, router_url, router_route_id), so dirty tracking is naturally scoped to what actually changed:

event router vars in delta
first event on a connection all five
navigation to a different route page, url, route_id
navigation within the same route (a different dynamic arg) page, url
event with no route change none
reconnect (new sid) session
a non-origin header changing headers

Measured 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_data update that changes nothing observable does not dirty anything, does not mark the state touched, and does not persist it.

State.router stays a switchboard

The top-level RouterData remains the public API and keeps working unchanged in both positions:

  • Instance access β€” a property on BaseState composes a RouterData view over the per-field vars, and assignment decomposes one back into them. Existing self.router.session.client_token reads and state.router = ... writes are untouched.
  • Class access β€” returns a new RouterDataVar whose .session / .headers / .page / .url / .route_id resolve directly to the underlying per-field base var, so State.router.session.client_token compiles to state.router_session…?.["client_token"]. Rendering State.router itself still emits an object literal matching the pre-split shape, so passing the whole router to a component keeps working.

Dependency tracking covers the whole router in both forms. self.router in a computed var auto-deps through the property getter onto the per-field vars. deps=[State.router] covers all five too: a Var now names the state fields a dependency must track via _dependency_field_names() (defaulting to the single field from its VarData), which RouterDataVar overrides β€” otherwise VarData.merge would surface one field name and leave the computed var stale when any of the others changed. An explicit legacy deps=["router"] is expanded to all five with a console.deprecate() warning (deprecated 0.9.9, removal 1.0). A computed var named router now raises ComputedVarShadowsBaseVarsError rather than silently shadowing the descriptor.

One new type: URLData, a dataclass mirroring ReflexURL's parsed components. It exists because json.dumps serializes str subclasses natively and never invokes the default=serialize hook β€” a bare ReflexURL base var would reach the frontend as a plain href string instead of the component dict, breaking router.url.path and friends on the client. self.router.url still hands back a ReflexURL.

on_event β†’ on_connect

Token, sid, headers, and client IP cannot change without going through on_connect again, so decoding every request header from the ASGI scope on every single event was wasted work. Those entries are now built once per connection in on_connect and cached per sid (dropped in on_disconnect); on_event merges the cached fragment and only computes the genuinely dynamic PATH/QUERY. If a socket was never seen by on_connect, on_event falls 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_data preparation: 2.85Β΅s β†’ 0.65Β΅s (~4.4x faster). The processor side benefits too β€” _update_router_vars only rebuilds the dataclasses whose backing keys changed, so HeaderData.from_router_data no longer runs on every navigation.

Compatibility

  • User-facing State.router.* / self.router.* API is unchanged.
  • The frontend needs a recompile (the state keys are new names) β€” the same situation as any var rename, already covered by the existing version-mismatch handshake reporting. No partial-payload format means no capability gate is required.
  • Redis states pickled by an older version are discarded by the existing schema hash check (the root state's base vars changed); __setstate__ drops the legacy router entry so unpickling one doesn't crash before reaching that check.
  • A router_data payload 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

  • Breaking change (fix or feature that would cause existing functionality to not work as expected) β€” only at the wire/persistence layer: the compiled frontend must match the backend (already enforced), and old pickled states are discarded by the schema check. The Python API is source-compatible; deps=["router"] is deprecated, not removed.

Changes To Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
    • 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 on main.
    • 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 β€” 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_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.
  • Have you successfully ran tests with your changes locally?
    • Full tests/units in a clean checkout with redis configured, matching the ubuntu CI jobs: 8365 passed, 0 failed (main under the same setup: 8352 passed, 0 failed β€” the difference is this PR's new tests).
    • 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 full pre-commit run: all clean.

News fragments added under news/ and packages/reflex-base/news/.

Reviewer notes

Two of the judgment calls I originally flagged here were called out in review, and the reviewers were right β€” both are now fixed rather than argued for (c7a08e5): the whole-router deps=[State.router] dependency covered only one field, and the cached headers dict was shared by reference (I had claimed the defensive copy was the cost being removed; measuring showed it costs ~6% of what the cache saves).

Two things I deliberately did not change, for you to weigh in on:

  1. _patch_state marks all five router vars dirty on every linked-shared-state event (thread). On main this is dirty_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.
  2. router_page still ships as its own var even though page is deprecated (removal 1.0), because dynamic route args read page.params. It could fold into url once page is gone.

πŸ€– Generated with Claude Code

https://claude.ai/code/session_01Mwk1pagH3KZ884Xw55BNMh

…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
@greptile-apps

greptile-apps Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; the latest no-op update guard preserves required per-field invalidation while avoiding redundant state persistence.

Summary

  • Adds per-field router state variables for session, headers, page, URL, and route identity.
  • Composes those fields through a class- and instance-compatible router switchboard.
  • Extends dependency tracking so whole-router dependencies invalidate for every backing field.
  • Caches connection-scoped router data per socket while isolating mutable event headers.
  • Preserves partial router payloads and avoids touching or persisting state for no-op updates.
  • Adds regression, serialization, dependency, event-processing, and benchmark coverage.

Comment thread reflex/istate/data.py Outdated
Comment thread reflex/app.py
Comment thread reflex/state.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.

1 issue found across 13 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid β€” if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="reflex/istate/shared.py">

<violation number="1" location="reflex/istate/shared.py:109">
P2: When a client has a linked shared state, every `modify_state_with_links` event enters `_patch_state` and marks all five router base vars dirty. This resends connection-static session and headers (and unchanged navigation fields) on ordinary shared-state events, defeating the per-field delta optimization; invalidate dependent computed vars without marking unchanged router base vars for emission.</violation>
</file>

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 reflex/istate/shared.py
# Apply the updates into the existing state tree for rehydrate.
root_state = original_state._get_root_state()
root_state.dirty_vars.add("router")
root_state.dirty_vars.update(ROUTER_VARS)

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.

P2: When a client has a linked shared state, every modify_state_with_links event enters _patch_state and marks all five router base vars dirty. This resends connection-static session and headers (and unchanged navigation fields) on ordinary shared-state events, defeating the per-field delta optimization; invalidate dependent computed vars without marking unchanged router base vars for emission.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At reflex/istate/shared.py, line 109:

<comment>When a client has a linked shared state, every `modify_state_with_links` event enters `_patch_state` and marks all five router base vars dirty. This resends connection-static session and headers (and unchanged navigation fields) on ordinary shared-state events, defeating the per-field delta optimization; invalidate dependent computed vars without marking unchanged router base vars for emission.</comment>

<file context>
@@ -106,7 +106,7 @@ async def _patch_state(
         # Apply the updates into the existing state tree for rehydrate.
         root_state = original_state._get_root_state()
-        root_state.dirty_vars.add("router")
+        root_state.dirty_vars.update(ROUTER_VARS)
         root_state.dirty_vars.add(ROUTER_DATA)
         root_state._mark_dirty()
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not changing this one in this PR β€” it's a faithful translation, not a regression, and I'd rather not alter shared-state rehydrate semantics speculatively. Flagging it for @masenf to decide.

On main this line is root_state.dirty_vars.add("router"), and router was the single var holding session, headers, page, url and route_id β€” so main already re-sends all of that on the same events. dirty_vars.update(ROUTER_VARS) produces an identical payload; the per-field split neither improves nor worsens this path.

You're right that it leaves value on the table: a client with a linked shared state doesn't get the delta reduction on ordinary events. But the reason those vars are marked dirty is to invalidate computed vars that depend on the router for the patched tree β€” _mark_dirty_computed_vars drives that off dirty_vars, so decoupling "invalidate dependents" from "emit the var" needs a mechanism that doesn't exist yet. Doing that safely deserves its own change with shared-state test coverage rather than riding along here.

Happy to do it as a follow-up if you'd like it in scope.


Generated by Claude Code

Comment thread reflex/state.py
Comment thread reflex/app.py
Comment thread reflex/istate/data.py
…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
@codspeed-hq

codspeed-hq Bot commented Sep 8, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 3.17%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚑ 1 improved benchmark
βœ… 39 untouched benchmarks
πŸ†• 1 new benchmark
⏩ 8 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚑ test_var_access[mutable_dict] 47 ms 45.6 ms +3.17%
πŸ†• 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 (9c270ca) with main (5d9724e)

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. ↩

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
@masenf

masenf commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI caught three things on the first push, all genuinely this PR's. Fixed and pushed (d5c82f4); the last one is a real behavioral correction worth flagging rather than burying in a commit message.

1. Missing news fragment for reflex-base (changelog check). The PR touches packages/reflex-base/src/**, which needs its own fragment. Added packages/reflex-base/news/7068.performance.md.

2. dataclasses.replace() on a mocked state (5 redis token-manager tests). link_token_to_sid used dataclasses.replace(state.router_session, session_id=sid), but those tests drive on_connect with a Mock state, and replace() rejects non-dataclasses β€” the pre-split code rebuilt from router_data and so tolerated it. Now rebuilds via SessionData.from_router_data(state.router_data), which also keeps the session var and router_data in step the way the event processor does.

3. A var could be dirtied when its value had not changed. _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 an update that changed nothing observable still dirtied the var, which marks the state touched and persists it to redis. The pre-split code compared the rebuilt RouterData before assigning; I've restored that comparison, keeping the key check as the cheap gate that avoids rebuilding HeaderData on every navigation. Regression test added for the differing-keys/equal-values case.

That last one also sharpens the delta table in the description: a navigation now dirties route_id only when the matched route pattern actually changes, so navigating between two dynamic values of the same route ships page and url alone.

These only reproduce with redis configured (REFLEX_REDIS_URL), which is why the ubuntu unit jobs failed while every Windows and integration job passed β€” my earlier local runs had no redis. I've since run the full suite against a local redis, matching the CI configuration: 8361 passed, 0 failed (main under the same setup: 8352 passed, 0 failed β€” the difference is this PR's new tests). ruff check, ruff format --check, pyright, and the full pre-commit run are clean, and the three changelog job steps pass locally.


Generated by Claude Code

Comment thread reflex/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 7 files (changes from recent commits).

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

Re-trigger cubic

Comment thread reflex/app.py
Comment thread tests/units/test_app.py Outdated
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

@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 (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
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
@masenf

masenf commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Second review round addressed in c7e8c18, plus two CI signals I've concluded are not this PR's β€” recording the reasoning rather than quietly re-running.

Fixed

router_session.client_token left empty at connect (greptile P1 + cubic P1). link_token_to_sid now records the identity the state was loaded under. The window was pre-existing β€” main rebuilt the whole RouterData from router_data at the same point and also left the token empty β€” but the duplicate-token path makes it sharper, since the state is loaded under a freshly issued token nothing else knows yet. Regression test asserts both branches and fails without the fix.

Fixture teardown ran on a fresh event loop (cubic P2). Now a pytest_asyncio.fixture awaiting disconnect_all() on the test's own loop.

Verified, no change

disconnect_all() wiping other tests' mappings (cubic P3). It reads the manager instance's own token_to_socket/sid_to_token dicts (token_manager.py:130), which are plain instance attributes, not redis-backed views β€” so it only disconnects tokens that instance linked. Details on that thread.

Not this PR's

unit-tests (windows-latest, 3.14) on d5c82f4 β€” test_check_latest_package_version_refreshes_expired_check failed with version called twice: [call('reflex'), call('reflex-enterprise')]. That test mocks importlib.metadata.version globally, and reflex/utils/telemetry.py:227 calls version("reflex-enterprise"); the extra call is the telemetry path landing on the shared mock. This PR touches neither reflex/utils/prerequisites.py, tests/units/test_prerequisites.py, nor telemetry.py, only 1 of the 5 Windows jobs failed on that commit, and the file passes 100/100 locally. I have not spent the re-run on it: the branch has advanced twice since, so CI has re-run it naturally on newer commits.

CodSpeed "Performance Regression: -0.07%" β€” flagged on test_from_event_type[lambda_event] (50.3Β΅s β†’ 52Β΅s). This PR touches neither tests/benchmarks/test_event_creation.py nor the module from_event_type lives in, and that function doesn't reach any code changed here. CodSpeed's own report carries a "Different runtime environments detected" warning for the compared benchmarks, and it compared against base 5d9724e rather than this branch's merge base 77cfe61. The βˆ’3.21% is also paired with a +3.17% "improvement" on an unrelated benchmark β€” symmetric noise summing to βˆ’0.07%. Acknowledging it needs the CodSpeed UI, which I don't have; flagging for @masenf if it stays red.

Verification

Full tests/units in a clean checkout with redis configured, matching the ubuntu jobs: 8366 passed, 0 failed (main under the same setup: 8352). ruff check, ruff format --check, pyright reflex tests clean.


Generated by Claude Code

@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 (changes from recent commits).

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

Re-trigger cubic

Comment thread reflex/state.py Outdated
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
Comment thread reflex/istate/data.py Outdated
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

@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 12 files (changes from recent commits).

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

Re-trigger cubic

Comment thread reflex/state.py
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
@masenf

masenf commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

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 05372dd: a different test in the same file.

FAILED tests/units/test_prerequisites.py::test_ensure_installation_id_keeps_legacy_install_unmarked
assert 86400766029830831095088780414540710001 == 12345

The test monkeypatches REFLEX_DIR to a tmp dir, writes installation_id containing 12345, and expects it read back; instead ensure_reflex_installation_id() took the regenerate branch. On d5c82f4 the same job failed on test_check_latest_package_version_refreshes_expired_check instead β€” the telemetry/importlib.metadata.version mock race I described. Two different tests, two different mechanisms, same file, same job.

Why I'm confident it isn't this PR's:

  • The diff touches none of reflex/utils/prerequisites.py, tests/units/test_prerequisites.py, reflex/utils/telemetry.py, reflex/utils/path_ops.py, or environment.py.
  • A bug introduced by a change fails the same test repeatably. A different test each run is the signature of an isolation or environment problem.
  • Only windows-latest 3.14 fails. 3.10 / 3.11 / 3.12 / 3.13 on Windows and every ubuntu job pass on the same commits.
  • The full suite passes locally on Linux with redis (8368 passed, 0 failed), these tests included.
  • I checked the obvious candidate mechanism and ruled it out: EnvVar.get() reads os.environ on every call and caches nothing, so monkeypatch.setenv("REFLEX_DIR", ...) is sound. Whatever is happening is specific to that runner, not to how the test patches the environment.

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 (14d7662, 9c270ca) and CI is running these jobs again on the current head; a re-run of a superseded commit would tell us less than that does. I'll report what the Windows 3.14 job does on 9c270ca either way β€” including if it turns out I'm wrong and it reproduces a third time.


Generated by Claude Code

@masenf

masenf commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Correction: the Windows unit-test failure is a pre-existing suite-wide race, and I had the pattern wrong

I said I'd report what Windows did on 9c270ca either way, including if I turned out to be wrong. I was wrong, so first the retraction:

Only windows-latest 3.14 fails. 3.10 / 3.11 / 3.12 / 3.13 on Windows and every ubuntu job pass on the same commits.

That is falsified. On 9c270ca windows-latest 3.14 passed and windows-latest 3.13 failed β€” a third test, in the same file:

tests/units/test_prerequisites.py::test_ensure_installation_id_marks_new_install
    assert prerequisites.has_uuid_distinct_id_semantics() is True
E   assert False is True
1 failed, 7601 passed, 39 skipped in 328.72s

Three commits, three different tests in tests/units/test_prerequisites.py, and now two different Python versions. Not a version-specific defect β€” a race. I've since root-caused it.

Root cause

telemetry.send() hands the event to a process-wide single-worker ThreadPoolExecutor (reflex/utils/telemetry.py, _get_telemetry_executor/_submit). That worker calls ensure_reflex_installation_id() (via _get_event_defaults, and again in _maybe_alias_legacy_distinct_id), which writes installation_id and installation_id_semantics into REFLEX_DIR, and then does a real httpx.post to PostHog.

Every test in test_prerequisites.py that touches those helpers does monkeypatch.setenv("REFLEX_DIR", str(tmp_path)) β€” which mutates the process-global os.environ. So a telemetry job still draining on the worker thread resolves REFLEX_DIR to that test's tmp_path and writes into it, mid-assertion. Whichever assertion the write lands next to is the one that fails, which is exactly the rotating-test signature.

Telemetry is not disabled for the unit suite β€” there is no telemetry_enabled gate in unit_tests.yml, tests/units/conftest.py, or pyproject.toml.

Evidence

1. Real telemetry work is queued during the unit run. Wrapping telemetry._submit and tagging each call with the test that made it:

submissions from
this branch (9c270ca) 31 23 Γ— test_app.py (compile), 8 Γ— test_telemetry.py
origin/main (5d9724e) 31 23 Γ— test_app.py (compile), 8 Γ— test_telemetry.py

Identical. The 23 come from record_compile β†’ telemetry.send("compile", …) and are never flushed; test_app.py collects before test_prerequisites.py, and each queued job blocks on a real PostHog POST, so the queue drains long after the test that filled it. This diff neither adds nor removes a single submission.

2. The mechanism reproduces. Running just that file with one extra thread calling ensure_reflex_installation_id():

$ pytest tests/units/test_prerequisites.py -p conftest_race
FAILED test_mark_uuid_distinct_id_semantics_writes_marker  - assert '' == '0.9.5'
FAILED test_ensure_installation_id_keeps_legacy_install_unmarked
                                   - assert 180308797788070662466186902606752178733 == 12345
2 failed, 98 passed

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 reflex/utils/prerequisites.py, reflex/utils/telemetry.py, reflex/utils/telemetry_accounting.py, or tests/units/test_prerequisites.py.

I'm not claiming this is impossible to have been nudged by timing changes here β€” CI on main is green β€” but the exposure is byte-for-byte identical on both branches, and the failing code is untouched by this PR.

Proposed patch (not pushed here)

Keeping the unit suite off the shared worker fixes it at the source. In tests/units/conftest.py:

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_submit

Verified on a clean origin/main worktree: submissions drop 31 β†’ 8 (all 8 inside test_telemetry.py, which drains its own queue), and the suite is 7773 passed, 0 failed, 0 errors. It also stops the unit suite making real network calls to PostHog.

Two notes on the shape: it restores _submit by hand rather than via monkeypatch, because requesting monkeypatch from a conftest-level autouse fixture reorders it outside module-level autouse fixtures and breaks test_lighthouse_utils.py's cache-clearing teardown. And gating on config (REFLEX_TELEMETRY_ENABLED=false) does not work β€” the env override beats the explicit rx.Config(telemetry_enabled=True) that four test_app.py telemetry tests construct.

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.

Next

Spending the one re-run on unit-tests (windows-latest, 3.13) now that the failure is characterised; I'll report the result.


Generated by Claude Code

@masenf

masenf commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Re-run result, as promised: unit-tests (windows-latest, 3.13) passed on the second attempt, same commit 9c270ca6, no code change (job). CI is now fully green on this head β€” all 11 unit-test jobs, both integration suites, docs, examples and the per-package checks.

That is consistent with the race diagnosis above rather than proof of it; the reproduction and the identical 31-submission telemetry count on main are the actual evidence. The tests/units/conftest.py patch in my previous comment is still worth landing on its own β€” it's the thing that stops this recurring on unrelated PRs β€” but it stays out of this one unless you want it.

No merge conflict against main; the PR is green and waiting on review.


Generated by Claude Code

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