Skip to content

fix: client_state readers that mount after the value changed - #7407

Merged
masenf merged 7 commits into
reflex-dev:mainfrom
benedikt-bartscher:client-state-var-seed-from-shared-value
Oct 9, 2026
Merged

masenf merged 7 commits into
reflex-dev:mainfrom
benedikt-bartscher:client-state-var-seed-from-shared-value

Conversation

@benedikt-bartscher

@benedikt-bartscher benedikt-bartscher commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Two fixes for a global rx._x.client_state whose readers mount after the value changed, such as one behind an rx.cond or page content that only renders once the page is hydrated.

1. Late readers keep a stale value

The value lives in refs['_client_state_<name>'] and every reader renders that shared value, but each reader's own useState started from the default. A reader that mounts after the value was set shows the shared value while its local state holds the default. Setting the value back to the default then calls that reader's setter with a value equal to its local state, React skips the update, and the reader keeps showing the stale value.

We hit this with a loading flag: push(True) from on_load, push(False) once a background query settles. Our layout only renders the page content once is_hydrated is true, so after a websocket reconnect the content re-mounts between the two pushes and the spinner never goes away.

Fix: for global client state, useState starts from the shared value, useState('_client_state_<name>' in refs ? refs['_client_state_<name>'] : (<default>)), or just refs['_client_state_<name>'] without a default. Only a missing slot falls back to the default, so a set or pushed null is kept, and the parentheses keep defaults like a || b or ternaries intact. Local client state is unchanged.

2. A push before any reader mounts fails

push compiled to refs['_client_state_set<name>'](value), and only a mounted component defines that setter. A backend push that arrives before one mounts, e.g. the on_load push above on a fresh load while the page waits for hydration, raised TypeError: refs._client_state_set... is not a function. applyEvent catches it and reports it through window.onerror (so it shows up in error tracking), and the value was lost, so the reader mounted with the default.

Fix: without a setter, the push stores the value in the shared slot, (refs['_client_state_set<name>'] ?? ((pushed) => { refs['_client_state_<name>'] = pushed; }))(value). With fix 1 the first reader to mount starts from it. This relies on fix 1: without it, the late reader would show the pushed value but keep the default as its own state.

#6936 replaces this implementation with useSyncExternalStore, which has neither problem, but keeps the current one available as rx._x.client_state.

Tests

  • Playwright test_late_reader_follows_set_back_to_default: set the value, reveal a reader behind rx.cond, set the default again. Before fix 1 the reader stays at clicked.
  • Playwright test_push_before_reader_mounts: push from the backend on a page where nothing uses the value yet, then reveal a reader. Before fix 2 the push raises and the reader shows initial.
  • Playwright test_push_none_before_reader_mounts: the same with push(None), which has to survive instead of falling back to the default.
  • Unit tests for the generated useState hook (global with and without a default, local) and the generated push script.
  • Updated the expected hook in test_client_state_setter_only_sibling_memo_initializes_state.

@benedikt-bartscher
benedikt-bartscher marked this pull request as ready for review October 3, 2026 23:42
@benedikt-bartscher
benedikt-bartscher requested a review from a team as a code owner October 3, 2026 23:42

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

No issues found across 5 files

Re-trigger cubic

@greptile-apps

greptile-apps Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Medium impact] The PR appears safe to merge based on the reviewed changes.

Summary

The PR initializes late-mounted global client-state readers from the shared value and preserves backend pushes made before a reader mounts. It adds unit and Playwright coverage for both cases, including a pushed None value.

Reviews (7) · Last reviewed commit: "Merge branch 'main' into client-state-va..." · Reviewed by Greptile

@codspeed

codspeed Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

⚠️ 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

⚡ 2 improved benchmarks
❌ 1 regressed benchmark
✅ 147 untouched benchmarks
⏩ 18 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ test_var_access[mutable_dataclass_list] 138 ms 144.3 ms -4.39%
⚡ test_hydration_metadata[20] 220.5 µs 207.8 µs +6.14%
⚡ test_get_state[disk] 955.8 µs 918.3 µs +4.08%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing benedikt-bartscher:client-state-var-seed-from-shared-value (3a0ebb4) with main (deea1c3)

Open in CodSpeed

Footnotes

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

@benedikt-bartscher benedikt-bartscher changed the title fix: start a global client_state's useState from the shared value fix: client_state readers that mount after the value changed Oct 4, 2026

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

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

Re-trigger cubic

Comment thread reflex/experimental/client_state.py
@masenf masenf added the on deck PRs lined up to review / merge next label Oct 5, 2026
Comment thread reflex/experimental/client_state.py Outdated

@FarhanAliRaza FarhanAliRaza 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.

I tested this PR in a small app that uses rx._x.client_state the way a downstream user does. I ran the same app on the PR and on its main merge base.

On main, each fault in the description occurs. A late reader behind rx.cond stays at clicked after a reset to the default. A push before any reader mounts raises refs._client_state_set... is not a function, and the reader shows the default. The on_load push with a reader behind is_hydrated shows the same error.

On the PR, all of these are correct, with no console errors. I also tested readers that unmount and mount again, client navigation away and back, a pushed None, a var with no default, a list default, retrieve after an early push, and pushes while a reader is mounted. A production export also succeeds.

One small change request is inline.

@masenf
masenf merged commit a528c2d into reflex-dev:main Oct 9, 2026
156 of 159 checks passed
@benedikt-bartscher
benedikt-bartscher deleted the client-state-var-seed-from-shared-value branch October 11, 2026 12:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

on deck PRs lined up to review / merge next

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants