Skip to content

Fix ClientStateVar late mount default sync - #6824

Closed
harsh21234i wants to merge 4 commits into
reflex-dev:mainfrom
harsh21234i:fix/client-state-late-mount-default-sync
Closed

Fix ClientStateVar late mount default sync#6824
harsh21234i wants to merge 4 commits into
reflex-dev:mainfrom
harsh21234i:fix/client-state-late-mount-default-sync

Conversation

@harsh21234i

@harsh21234i harsh21234i commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Fixes #6823

Summary

Fixes a global ClientStateVar rendering bug where a component that mounted while the shared value was non-default
could get stuck when the value was later pushed back to the default.

Changes

  • Initialize global ClientStateVar local state from the shared refs mirror when available.
  • Preserve the existing plain useState(default) behavior for non-global client state.
  • Add regression coverage for:
    • global client state reading from the shared ref
    • global client state without a default using undefined
    • local client state not reading from shared refs
  • Add a bugfix news fragment.

Testing

  • uv run pytest tests/units/test_client_state.py -q
  • uv run pytest tests/units/compiler/test_memoize_plugin.py -k client_state -q
  • uv run ruff check reflex/experimental/client_state.py tests/units/test_client_state.py
  • uv run ruff format --check reflex/experimental/client_state.py tests/units/test_client_state.py
  • uv run pyright reflex/experimental/client_state.py tests/units/test_client_state.py
  • uv run towncrier check --config pyproject.toml --dir . --compare-with origin/main

Review in cubic

@harsh21234i
harsh21234i requested a review from a team as a code owner July 31, 2026 15:17
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@harsh21234i

Copy link
Copy Markdown
Contributor Author

hey @adhami3310 can you go through this ?

@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR corrects late-mounted global ClientStateVar initialization while preserving local-state behavior.

  • Initializes global client state from the shared refs mirror whenever its value is defined.
  • Preserves explicit shared null values instead of replacing them with the configured default.
  • Adds unit and Playwright regression coverage for shared, defaultless, and local client state.
  • Adds a bugfix news fragment.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the explicit undefined check preserves a shared null during late mounting, resolving the previously reported issue.

Important Files Changed

Filename Overview
reflex/experimental/client_state.py Adds a lazy global-state initializer with an explicit undefined check, correctly preserving shared null values while leaving local initialization unchanged.
tests/units/test_client_state.py Verifies generated hooks for shared-ref initialization, explicit null preservation, undefined fallback, and local-state isolation.
tests/integration/tests_playwright/test_client_state.py Covers the late-mount synchronization flow and confirms both consumers update when shared state returns to its default.
news/6823.bugfix.md Accurately documents the corrected late-mounted global client-state behavior.

Reviews (4): Last reviewed commit: "Merge branch 'main' into fix/client-stat..." | Re-trigger Greptile

Comment thread reflex/experimental/client_state.py
@codspeed-hq

codspeed-hq Bot commented Jul 31, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing harsh21234i:fix/client-state-late-mount-default-sync (598c79e) with main (346177c)2

Open in CodSpeed

Footnotes

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

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

@adhami3310

adhami3310 commented Jul 31, 2026

Copy link
Copy Markdown
Member

can you add the repro as integration test

@harsh21234i

Copy link
Copy Markdown
Contributor Author

Added the repro as a Playwright integration test in 45c088e. It follows the issue scenario: push the global
ClientStateVar to a non-default value, mount a second consumer while it is non-default, then push back to the default
and assert both consumers update.

@masenf

masenf commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

i ended up refactoring client_state rather completely in #6936 which structurally addresses this and other problems we've seen with the current useState-based ClientStateVar.

i'll port over the regression test from this branch into mine to ensure the case remains covered under the new implementation.

@masenf masenf closed this Aug 25, 2026
masenf pushed a commit that referenced this pull request Aug 25, 2026
…uage

The `refs` key the provider publishes its store on was spelled out three
independent times in source: the `CLIENT_STATE_REF` constant in
`client_state.js`, two hardcoded literals in `state.js`, and the Python
expression `client_state.py` emits. A partial rename would have disconnected
the backend event handlers from the mounted provider.

There are now exactly two definitions, one per language, because neither can
import the other's: `CLIENT_STATE_REF` in `client_state.js` and
`CLIENT_STATE_REF` in `reflex_base.constants.state`. `state.js` imports the
frontend constant instead of respelling it -- the cycle its old comment warned
about no longer exists, since the provider takes the registry as a prop rather
than importing `refs`.

A vitest case asserts the two constants are equal by reading the Python source,
so a rename on either side fails loudly, and a second one keeps `state.js` from
regressing to a hardcoded copy. Both directions verified by mutation.

Also ports the late-mount regression test from #6824 (issue #6823): a consumer
mounting after a value has been pushed reads the live value rather than seeding
a copy from the default and then sitting stuck when the value returns to that
default. This holds by construction here -- there is one slot per name and a
late consumer binds to it -- and the test fails if slot claiming re-seeds. The
unit tests from that PR are not ported: they assert hook strings from the
per-component `useState` design this branch replaces.

Adds changelog fragments, and skips lockfiles in codespell so the new
`tests/js/package-lock.json` integrity hashes do not trip it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
masenf pushed a commit that referenced this pull request Aug 25, 2026
Audited the registry for retained values and added the guards. No leak found,
but the invariants that make that true were untested, and two of them are easy
to break.

What holds a slot: a scope's `owned` map. Scopes point *up* to their parent and
a parent keeps no list of children, so an unmounted `ClientStateScope` -- one
`rx.foreach` item, one `@rx.memo` instance -- takes its map and every slot in it
out of reach. Verified a loop adds nothing to the root scope as its list churns,
so the store cannot grow with the number of items ever rendered.

Root-scope slots are the deliberate exception: they outlive their consumers,
because a named var is app-wide and the backend can push to it with nothing
mounted -- releasing on last unmount would undo the late-mount behavior ported
from #6824. Their number is fixed at compile time by the named and page-level
`rx.client_state` call sites, so the retention is bounded, not unbounded.

That exception makes one thing load-bearing: a listener on a root slot that
outlived its component would pin that component's React internals for the life
of the page. Tested by wrapping the slot's subscribe and asserting the count
returns to zero on unmount.

The remaining behavior is React's, and it is worth stating: keys decide what a
row's state belongs to. Under positional keys -- what `rx.foreach` emits by
default -- changing the list re-renders rows in place rather than unmounting
them, so a row's client state stays with the position, not the item. Keyed by
identity the old rows unmount and their state is released. Both directions are
now tested, and the docs say so, since a loop item could not hold state before
this branch.

All three invariants fail under mutation: a no-op unsubscribe, a `ScopedValues`
that stops opening a scope, and an item scope that resolves to the root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
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.

ClientStateVar: a component that mounts while the value is non-default never re-renders on the push back to the default

3 participants