Tighten the rx.client_state contract before 0.11.0

Merged
MA
3d ago
+613-97

Fixes FINDING-034 and FINDING-035 from the reflex 0.11.0a1 pre-release testing (artifacts on branch claude/vibrant-pasteur-3921g3, prerelease-testing/runs/2026-11-11-v0.11.0a1/client_vars/ and verify_cv/).

All Submissions:

  • Have you followed the guidelines stated in CONTRIBUTING.md?
  • Have you checked to ensure there aren't any other open Pull Requests for the desired changed?

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • Breaking change (relative to 0.11.0a1 only: $ in names is rejected; one name with two defaults fails the compile)
  • This change requires a documentation update (included)

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?
  • Have you successfully ran tests with your changes locally?

Defects

rx.client_state is new public API in 0.11 (#6936); this makes its contract strict before 0.11.0 ships.

  • FINDING-034: the "give it a name" hint and the ClientStateVar.create docstring showed rx.client_state("my_name"), which creates an unnamed var whose default is "my_name" (following the hint raised the same error).
  • FINDING-035: generated JS identifiers collided. name="item" and name="Item" both declared setItem (Identifier setItem has already been declared: the memo module 500s in dev, the prod build fails); one name declared with two different defaults was silently resolved by whichever mounted first, or with auto_memoize=False 500'd every route (Identifier openRxClientState has already been declared). Nothing was checked in Python; name="cs$0" was accepted and clashed with the first unnamed var.

Fix

  • Hint and docstring now show rx.client_state(default, name="my_name"). When an unnamed var's default looks like a name (the deprecated API's name-first order), the error raised by push / retrieve / global_value / global_set says so. Signature unchanged (name was already keyword-only).
  • Collision-free bindings: getter <name>RxClientState, setter <name>RxClientStateSet, updater placeholder prev<N>RxClientStateArg — injective in the name and mutually distinct (the old placeholder could also shadow a var named prev0).
  • Name rule: [A-Za-z_][A-Za-z0-9_]* (ASCII identifier without $, which is reserved for generated names); prefix uses the same validator; the error names the bad value. Every name in the docs passes. The deprecated rx._x.client_state / ClientStateVar.create apply the same rule and keep their argument order and deprecation warnings.
  • Conflicting declarations: one name with two different defaults raises ClientStateConflictError (a new ReflexError / ValueError subclass in reflex_base.utils.exceptions) at compile time, naming both declarations with file:line. The check runs as hooks are rendered (templates._render_hooks), across one App._compile (a fresh record per compile), so creating a var never raises and a dev hot reload with an edited default works. Identical re-declarations stay allowed (the documented "any component can read and write it" pattern).
  • Docs: one paragraph in docs/wrapping-react/overview.md on the name rule and the same-default rule.

Before / after:

  • item + Item: setItem declared twice → itemRxClientState, itemRxClientStateSet and ItemRxClientState, ItemRxClientStateSet.
  • rx.client_state("x", name="a-b") → name 'a-b' is not a valid client state name: it must be an ASCII identifier, a letter or underscore followed by letters, digits or underscores. It must not contain '$', which is reserved for generated names.
  • Two modules declaring name="dup" with different defaults → Client state var 'dup' is declared with two different defaults: "dup-default" (declared at …/shared.py:15) and "OTHER-default" (declared at …/other_mod.py:6). …

Test plan

  • Regression tests in tests/units/reflex_base/test_client_state.py, tests/units/experimental/test_client_state.py, tests/units/test_app.py: 17 fail on main (hint ×4 accessors, docstring, name-as-default hint, case-only identifiers, no duplicate const in the compiled memo module, placeholder shadowing, name rejection ×4, non-string message, same-body conflict, legacy name rule, app-level two-page conflict naming both sites), e.g. assert 3 == 4 … {'ItemRxClientState','itemRxClientState','setItem'}, DID NOT RAISE ValueError; allow-path tests (identical declarations, documented names, legacy positional name, conflict record not outliving a compile) pass before and after. Existing setter/placeholder spellings updated in ~20 assertions (test_client_state.py, test_memoize_plugin.py).
  • Full unit suite: 15663 passed, 66 skipped, 81.44% (the Playwright browser-driver module excluded locally for lack of a Chromium binary); touched modules rerun after the ClientStateConflictError change. ruff, pyright, pre-commit: clean.
  • End to end (dev and prod): the QA casing pages render (/casing2 used to 500), also with auto_memoize=False; hot reload with an edited default recompiles; the duplicate-default app fails at startup with the error above, the same-default variant renders all 5 routes; reflex-examples overkey (deprecated ClientStateVar.create, .set_value) 6/6 with its deprecation warning.

Notes

  • Reported, not changed: .set(value) / push(value) are not type-checked against the var's type; prefix is ignored when name is given (documented); defaults that render differently (0 vs 0.0) count as a conflict; types are not compared.

News fragments: packages/reflex-base/news/+client-state-contract.bugfix.md, news/+client-state-contract.bugfix.md.


Generated by Claude Code