Repository navigation
Conversation
ClientStateVar expanded into eight lines of generated hook code per var and
kept its state in four `refs` keys, alongside DOM refs, upload controllers and
the toaster. Those writes happened during render rather than in an effect, the
per-instance setter dicts were never cleaned up on unmount, and every write
fanned out to every registered setter, so writing one var re-rendered
components reading a different one.
Replace it with a single `useClientState` hook over a store of independently
subscribable slots, delivered by a React context provider injected through the
existing `VarData.app_wraps` pipeline. The store keeps one debuggable
`refs["__client_state"]` entry, and per-slot subscriptions via
`useSyncExternalStore` mean a write only re-renders that var's subscribers.
Also:
- Promote the API out of `experimental`: it lives in reflex-base and is
exposed as `rx.client_state`; `reflex.experimental.client_state` re-exports
it, so existing imports keep working.
- Collapse `.set` and `.set_value` into `.set`, which is now callable.
`.set` attaches bare to a trigger, `.set(value)` binds a value, and
`.set(lambda v: ...)` traces a functional updater against a placeholder typed
from the var, so ordinary var operations work inside it. `.set_value` remains
as a deprecated alias.
- Add `.global_value` / `.global_set`, a supported escape hatch for driving a
client state var from JS outside the React tree.
- Replace the eval'd `run_script` used by `push`/`retrieve` with first-class
`_client_state_set` / `_client_state_get` events, and reuse one extracted
callback helper across the `applyEvent` result-callback sites.
- Suffix the emitted JS identifier with a marker so a name can never collide
with a reserved word (`rx.client_state("class")` was a syntax error), and fix
`.set`'s arg-name recovery to key on Reflex's marker convention instead of a
`_` prefix -- so any valid identifier is a legal name, and event args are
recovered from compound expressions too.
- Generate omitted names from a dedicated counter, so a name no longer shifts
when unrelated code draws from the process-wide name generator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
Three issues found reviewing the previous commit: - A named var with no default emitted `useClientState(, "name")`, a syntax error that breaks the page build, because the store name is passed as a second argument and the empty default rendered as nothing. Emit an explicit `undefined`. - `push` sent its value as a JSON payload, so a `Var` -- a client-side expression -- arrived as its own source text instead of being evaluated. Route a Var through the evaluated path via `refs["__client_state"]`, keeping the JSON payload for concrete values. - `getClientStore` memoized a module-level store on the server too, so if the provider were ever absent the SSR fallback could carry a value between requests. Return a fresh store when there is no `document`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
…, exports - `_client_state_get` returned early when no provider was mounted, leaving a handler awaiting `retrieve` blocked on a result that would never arrive. Call back with undefined instead: it may fail, but it fails visibly. - Several providers can share one store (an embedded app rendered alongside a main app), so the first to unmount deleted the `refs` entry out from under the others. Reference-count mounted providers and only drop it on the last. - Export `ClientStateSetter` so the type `.set` returns can be named in an annotation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
The python suites can only see this code through compiled output, and an integration test cannot reach behavior that needs no running app -- provider teardown, the SSR branch, subscription bookkeeping. Two fixes in this branch landed untested for exactly that reason. Adds `tests/js/`, deliberately outside `.templates/web` since everything in there is copied verbatim into generated apps. `$/...` specifiers resolve to the template tree via a vitest alias; `$/utils/state` is stubbed, because the real module pulls in socket.io, react-router and the per-app generated `context.js`. Scoped to `client_state.js` for now -- `state.js` needs those stubs before it is unit-testable, and the integration tests already cover its interaction end to end. Nineteen tests covering slot semantics (per-var listener isolation, updaters, equal-value bail, create-on-write, unsubscribe), `getClientStore` client singleton vs. per-call on the server, provider refcounting across several mounted providers and StrictMode's double mount, `useClientState` sharing and isolation, and the non-React escape hatch. Each of the three behaviors these were written for was confirmed to fail the intended test when the fix is reverted. Runs as a `js-unit-tests` job in the existing unit-tests workflow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
- `client_state.js` no longer imports `refs` from `$/utils/state`. The provider takes the object to publish its store on as a `registry` prop, which the python side supplies as the `refs` Var carrying its own import. The module is now independent of where that lives, so the python side can move it without touching this javascript. Its unit tests pass their own object, so the `$/utils/state` stub is gone too. - `__hash__` now includes `_state_name` and `_global_ref`. Two vars differing only in those compared equal, despite carrying materially different VarData. - The `create` docstring described scoping incorrectly. A named var is readable and writable from any component and from the backend; an anonymous one is private to the component its hook is emitted in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
Client state was two tiers selected by `global_ref`, and the anonymous tier did
not survive Reflex's own compiler: because touching a client state var is itself
a memoization trigger, every consumer compiles to its own React component, so an
anonymous var read in one place and written in another became two disconnected
slots. An ordinary stateful sibling was enough to trigger it. The page compiled
and simply did not work.
Names now resolve down a scope chain. A scope owns some names and delegates the
rest to its parent; the first component in a tree to use a name claims it for its
descendants. Separate instances of a boundary get separate state, everything
under one boundary shares, and optimizer-generated boundaries stay invisible --
so a subtree split across memo modules keeps resolving the same slot and no memo
code has to be refactored.
Which tier you get follows from whether you name the var, so `global_ref` is
gone: a named var resolves at the root scope and stays reachable from the backend
via `push` / `retrieve` / `global_value` / `global_set`; an unnamed one is owned
by the tree that first uses it. Where you *construct* the var decides who shares
it, mirroring React's lifted state -- and because construction happens once per
call at compile time, a plain helper function called N times yields N independent
states with no memo, no keys and no configuration.
The boundary is emitted as an HOC on the memo definition's existing `wrapper`
extension point, not as a provider inside its returned JSX: a component's hooks
run before its own output mounts, so an inner provider would leave the memo's own
`useClientState` resolving against the enclosing scope and sharing across
instances. A new `is_instance_boundary` flag on `MemoComponentDefinition`, set
only by `@rx.memo`, keeps auto-memo wrappers transparent, and the wrap is gated
on the subtree actually using client state so pages don't pay per memo.
Also:
- The new API is `rx.client_state(default, *, name=None, prefix="cs")` -- a
single positional default, reading like `useState`. Putting `default` first
matters now that the first argument decides global vs scoped:
`rx.client_state("default")` used to look like a value while naming the var.
`prefix` customizes generated names to keep compiled output readable.
- `rx._x.client_state` keeps the original signature and carries every deprecation
notice, so the new API has none. Its `global_ref=False` drops the name, which
reproduces the old anonymous behavior exactly under the new rules.
- 27 vitest tests for the scope chain and 3 compiler tests for the emission
gating; each behavior was confirmed to fail its intended test when the
corresponding piece is reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
The wrapping-react page still described the retired `global_ref` model. Explain what actually decides sharing now: naming a var makes it global, an unnamed one is scoped to the tree that uses it, and *where you construct it* picks the owner -- including the consequence that a page-level read collapses per-instance state below it. Covers the plain-helper case and `prefix=`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
`rx.foreach` rendered its item and index as the `.map` callback's parameters, which went out of scope the moment anything referencing them compiled into its own function -- an `on_submit` lifted into a `useCallback`, or a subtree lifted into its own memo module. The page threw `ReferenceError: index is not defined` (#3210), and the documented workaround was a hidden form input. Each rendered item is now wrapped in a `ScopedValues` provider that publishes the item and index by name, and a loop var carries a `useScopedValue` read for them. The hook declares the same identifier the callback binds, so inside the loop body the parameter shadows it (where the parameter is the real value) and anywhere else the context read wins. For that to reach the consumers, `Foreach` stops being a snapshot *boundary* and becomes only a structural snapshot child: its subtree is user content, so it keeps memoizing and each consumer lands in its own module below the per-item provider. The subtree is walked with a memoize-only hook chain, so no page-level collector sees it -- its hooks, imports, refs and custom code still belong to the memo body that renders it, and the page stays free of the loop scope. The provider is the element the map yields, so it is what React reconciles the list by and therefore what carries the key: an explicit key on the item is lifted onto it, otherwise the index keys by position as before. An auto-memo wrapper now also inherits the key of the component it replaces, which a keyed item root would otherwise lose. `ScopedValues` opens a client state scope too, since one rendered item is one component instance. An unnamed `rx.client_state` var in a `foreach` body is therefore per item, the way `useState` would be in a React list, which closes the inline-foreach gap in client state scoping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
`rx.client_state(initial_value)` where the default is a Var -- the obvious way to seed per-item state from a loop index -- emitted `useClientState(ix_rx_state_, "cs3")` in every consumer module with nothing declaring `ix_rx_state_` and no `useScopedValue` import, so each item seeded from `undefined`. `ClientStateVar.create` read `default_var._var_data`, the var's own field. A derived or cast default keeps its hooks and imports on the var it wraps, reachable only through `_get_all_var_data()` -- a loop var is `scoped_loop_var(...).guess_type()`, whose cast wrapper has no var data of its own. Not loop-specific: a state var default lost its `useContext(StateContexts…)` the same way. Ordering holds by construction -- `VarData.merge` builds hooks in argument order and the pair travels inside one `VarData`, so the declaration cannot land after the line that reads it. The default is a seed, read once when the scope claims the name, so it does not track the var afterwards. Documented, along with reading an enclosing loop's item from a nested body, which works as long as the inner loop does not reuse the name -- the rule Python already imposes by shadowing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
Merging this PR will not alter performance1 warning
Comparing |
[Medium impact] The PR adds an opt-in browser state feature that reaches app components and backend event paths. The PR still needs the backend-only client-state path fixed before merging. Findings
SummaryPromotes client-only state to
Reviews (37) · Last reviewed commit: "Find trigger args through nested lambdas..." · Reviewed by Greptile |
…-context-refactor-jv3pig
…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
On the CodSpeed compile regressionThe regression is real and caused by this PR. I don't think it should block, and here's the accounting. Every regressed benchmark is Mechanism. That extra memoization is the fix, not overhead next to it. It's what puts a loop-var consumer in its own component so a hoisted It's compile-time only — build and hot reload, not the served app. At runtime the change goes the other way: loop items are now individually memoized, so a list update re-renders the items that changed rather than rebuilding every item's subtree. Follow-up worth doing separatelyProfiling I've deliberately left it out of this PR: it touches the memo tag-stability contract, which decides generated module filenames, and it deserves its own change with its own tests rather than riding along here. Happy to open an issue or pick it up next. Since the cost is inherent to the fix, this wants an acknowledgement on CodSpeed rather than a code change — I don't have access to do that. Generated by Claude Code |
|
The analysis itself completed — every query was interpreted and the SARIF was written: It died in the upload step on a network timeout to I've re-run the failed jobs in that workflow once (run 32881954542). If it fails the same way again I'll treat it as real and dig further rather than re-running a second time. For the record, the only other red check on this commit is CodSpeed, which I addressed separately above. Everything else that has finished is green, including Generated by Claude Code |
`create_passthrough_component_memo` analyzes the passthrough's params to
evaluate a preview body for the tag, then `_create_component_definition`
analyzed the same function's params again. Analyzing resolves type hints, and
the compiler builds one of these per auto-memo wrapper, so the second pass was
pure waste.
`_create_component_definition` now takes the already-analyzed params, and the
one caller that has them passes them through.
Measured on the `_stateful_page` compile benchmark, which this branch regressed
by widening auto-memoization into `rx.foreach` subtrees. Judged by cProfile
call counts, which are exactly reproducible -- wall clock on the dev box is
useless at this delta (interleaved A/B mins overlap completely):
before 46,195 calls/compile get_type_hints x26
after 43,348 calls/compile get_type_hints x13 (-6.2%)
`_create_component_definition` drops from 12.8% to 4.8% of compile cumtime.
The generated output is byte-identical -- page plus all 13 memo modules diffed
across the change.
This is a partial offset, not a fix for the whole regression. Profiling puts
the remaining cost in the memo tag hash: `_compute_memo_tag` is ~35% of compile
cumtime, dominated by `_update_deterministic_hash` at ~1,140 recursive calls
and ~3,000 hashlib updates per compile. Reusing the already-evaluated preview
as the definition body was also measured (a further -3.1%) and deliberately
not taken: it makes the definition body the same object `_compute_memo_tag`
rendered, and `render` can mutate, so the coupling is subtle rather than absent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
|
Correcting my earlier comment: I said the double-evaluation in I judged this by cProfile call counts rather than wall clock, because wall clock on a dev box is meaningless at this delta (interleaved A/B/A/B/A/B mins overlapped completely: 7.21–8.33 ms against 6.54–8.42 ms). Call counts are exactly reproducible. Per compile of the
What I did not take, and why: reusing the already-evaluated preview as the definition's body is worth a further −3.1%, but it makes the definition body the same object Where the cost actually is. Not in either double-evaluation. So the regression is reduced, not eliminated. The rest still wants either an acknowledgement or a follow-up on the tag hash. Generated by Claude Code |
|
CodSpeed on
The −6.2% I measured in interpreter call counts landed as ~8–9 points of wall-clock recovery on the compile benchmarks, so the deterministic metric tracked the real thing well. The split in that table is worth reading: only the compile benchmarks moved, because they are the ones that build memo definitions. The three that didn't — page evaluation and import collection — never touch So the remaining 5.53% is roughly: ~6% on compile (the extra memo definitions, of which the tag hash is the dominant cost — see above) and ~5% on evaluation (loop-var Generated by Claude Code |
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
A row's loop values and a row's client state behave differently when the list contents are replaced, and the difference is easy to mistake for a bug, so both are now asserted against a running app. The loop item and index update. They resolve through the scope the loop provides on every render rather than being captured at mount, so replacing ["a","b","c"] with ["d","e","f"] moves them even though the default positional key means React re-renders the existing rows instead of mounting new ones. Verified by mutation: freezing the provided values fails this test. A row's client state does not. The default seeds the slot when the row first claims it and is not re-read, and a positional key means the row never unmounts, so nothing re-claims -- `useState(props.item)` semantics. This holds for a state the row was given and for one seeded from the item, and the test asserts it deliberately: re-seeding whenever the default changed would silently throw away whatever the user had typed into the row. `key=` on the item is the way to tie state to the item instead. Keyed by identity the old rows unmount, releasing their scopes, and the new rows seed from the new items. Covered by the third test. Also considered and rejected: resetting a row's scope when its provided values change, instead of relying on keys. For a list of dicts every unrelated state update yields fresh object references, so scopes would reset continuously and all per-item state would evaporate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
`rx.foreach` keys rows by position, and that stays the default: a list of
interchangeable slots wants "row 3 is expanded" to persist as data flows
through, and keying by identity needs a unique key expression, which Reflex
cannot guarantee for an arbitrary list.
The cost of that default was under-documented. A client state default is a seed,
read once when the row claims its slot, so a row seeded from its item keeps the
*old* seed when the list's contents change -- the row never unmounts, so nothing
re-claims. The item itself follows the data; only the seeded state lags, which
makes the two easy to confuse:
State.items: ["a", "b", "c"] -> ["d", "e", "f"]
rx.text(item) renders d, e, f
rx.client_state(item).value renders a, b, c
Both docs now show that side by side and point at `key=` for tying state to the
item, or an explicit `on_mount` set for tracking a var while staying editable in
between. Also says when each keying choice is the right one, and that identity
keys need unique keys.
Behavior is unchanged: seeding once is what keeps a re-render from discarding
what the user typed into a row, and both directions are already covered by
integration tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
…-context-refactor-jv3pig One conflict, in `reflex/experimental/client_state.py`: main reworded a property docstring there (#6893, the ruff/pyright/typer upgrade) while this branch had replaced the whole file with a deprecation shim over `reflex_base.client_state`. Resolved in favor of the shim -- the implementation that docstring belonged to no longer exists here. That upgrade also brought the rule behind the reword, and this branch's own `ClientStateVar.value` tripped it. Fixed the same way main fixed theirs. Verified against the merged base with the upgraded tooling (ruff 0.16.3, pyright 1.1.411): pre-commit 7/7, 7762 unit tests, 40 vitest, 24 Playwright. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
…-context-refactor-jv3pig Conflicts resolved as unions: - ``components/memo.py``: ``MemoComponentDefinition`` keeps ``is_instance_boundary`` alongside main's ``auto_memo_wrapper`` and ``display_name``; ``create_passthrough_component_memo`` keeps the pre-analyzed ``params`` pass-through together with main's replacements. - ``tests_playwright/test_memo.py``: main's ``framed`` memo page section plus the scoped/nested/mutable/keyed sections from this branch. - ``pyi_hashes.json``: regenerated. ``test_user_memo_inside_foreach_is_not_independently_memoized`` asserted a premise main's auto-memoization of stateful user memos supersedes: a user ``@rx.memo`` with a loop-var prop now gets its own wrapper module rather than being inlined into the foreach body. Retargeted to the behavior that still matters — the wrapper resolves the loop var through ``useScopedValue`` and lands beside the foreach snapshot, never on the page.
…-context-refactor-jv3pig Restores the second parent that the previous commit dropped, and picks up #6941 (release automation moved into the ``reflex-release`` package). The previous merge of ``origin/main`` resolved correctly but was recorded with a single parent, so every commit on main's side of that merge read as belonging to this branch — 120 files against a true diff of a fraction of that. The four files that conflict here (``components/memo.py``, ``pyi_hashes.json``, ``tests_playwright/test_memo.py``, ``compiler/test_memoize_plugin.py``) are untouched by main since, so their resolutions carry over unchanged.
…-context-refactor-jv3pig Picks up #6605 (memo ``RestProp`` CSS routing) and #6924 (``reflex deploy`` moved to the Cloud CLI). ``_create_component_definition`` takes the union: this branch's optional pre-analyzed ``params`` guard plus the ``rest_target_fields`` out-parameter #6605 threads into ``_evaluate_component_body``.
…-context-refactor-jv3pig Picks up #6964 (reflex-release 0.1.0), confined to packages/reflex-release. Clean auto-merge.
Brings in 147 commits. Conflict resolutions and follow-ups: - memo.py: take main's create_passthrough_component_memo (#7123 evaluates the passthrough body once via _PASSTHROUGH_PARAMS), which supersedes this branch's `params=` pass-through on _create_component_definition; that kwarg is dropped as it no longer has a caller. - experimental/client_state.py: keep the shim. Main's #7286 fixed the old module (setter carries its hooks; default read via _get_all_var_data) -- the new implementation already has both, so port its coverage instead: setter hooks for named and scoped vars, state-var defaults reaching every accessor, and the two runtime scenarios (setter fires before any reader mounts; setter-only sibling with a backend default). A legacy ClientStateVar subclass in the shim keeps the original positional `create(var_name, default=...)` working with a deprecation notice. - Main's node harnesses for state.js (state_js.test.mjs, client_event_queue_runtime.mjs) link against a fixed stub map; add the `$/utils/client_state` import this branch introduces. - unit_tests.yml: gate `js-unit-tests` on the changes filter and add it to unit-tests-gate.needs, per the workflow gate rule main added. - Memo-wrapper transparency (#6850) routes props through mergeSlotProps; retarget the one assertion that spelled out the old literal shape. - deprecation_version 0.9.12 -> 0.10.0 (latest tag is v0.10.0a2). - Pin that a client state var used only inside an @rx.memo body surfaces its provider through main's whole-body app-wrap collection (#7176). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
…tate check-min-deps type-checks each package against its declared minimums, and the internal form blocks now call `rx.client_state`, which no published `reflex` has. Floor at the dev release of the next version, per the sibling dependency floor rule; releasing the dependent lifts it to the first published version that satisfies it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
`@dataclasses.dataclass(slots=True)` rebuilds the class, so the zero-arg `super()` in the legacy `create` stayed bound to the discarded pre-slots class and raised `TypeError: super(type, obj)` on Python 3.11. The shim adds no fields, so it needs no dataclass processing; `__slots__ = ()` keeps the slots semantics without rebuilding the class. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
Brings in 139 commits (2026-10-06..22). Conflict resolutions and follow-ups: - state.js: both sides added a helper at the same spot (applyResultCallback here, syncLocalStorageEvent from #7545); keep both. - experimental/client_state.py and its unit tests: keep the shim. Main's #7407 patched the old module so a late reader starts from the shared value, a push before any consumer mounts is kept, and a pushed null is a value; the store here already does all three (store.set claims the slot, own() keys on slot existence), so port the behaviors as Playwright scenarios on the late-mount app instead of the useState-level assertions. - test_memo.py: both sides extended the same app; union with the shared decorator, closer and docstring seams repaired. test_memoize_plugin.py keeps the useClientState assertion over main's refs/useState one. - reflex-components-internal: take main's reflex >= 0.10.1.dev0 floor; the form blocks keep rx.client_state and gain main's event_sequence import. - integration_gallery.py: main's redesigned filter button (#7549) with the promoted .set() in place of set_value(). - pyi_hashes.json regenerated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
… name Main renders a state's context hook as `useStateContext(...)` now, so the `useContext(StateContexts` literal went stale. Assert the contract instead: the identifier a preceding hook declares is the one the `useClientState` default reads the field from. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
The shim mapped a legacy call without a name to a scoped var whenever global_ref was omitted, so existing rx._x.client_state(default=...) callers lost backend push/retrieve access. Generate a name unless the legacy flag is explicitly false, matching the original default. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
Per-item client state only works when the item's hooks run below the ScopedValues provider the loop renders around it. The page walk split out stateful consumers, but a loop inside an explicit @rx.memo body compiled straight from the definition, so useScopedValue and useClientState hoisted above the .map and every row shared one slot, at any nesting depth below the memo. The memoize plugin now wraps each loop item in a snapshot memo component of its own, after the sealed walk over the item so its subtree keeps auto-memoizing. Explicit memo bodies are walked in a loop-items-only mode before compiling: only loops are rewritten, since any other wrapper would strand references to the memo's parameters. Parameters an item references are declared on the item component and threaded through as props, with the memo's children passed as the item's children, and the item components registered mid-compile are compiled in the same pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
Walking every @rx.memo body to isolate its loop items cost a compile pass per memo and had to thread the memo's own parameters into each item component, which is more surface than the case warrants. An explicit memo body is compiled as one component again and never walked, so a loop inside it renders its item inline. Per-item client state in that position is reached by making the item function an @rx.memo component, whose instances own their state; the foreach docs say so, and the Playwright scenario now exercises that pattern. Page-level loops keep rendering each item through a component of its own, so their hooks run below the item's scope. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
A loop item renders as a snapshot of a component whose own strategy is passthrough, so the body bakes the children in while the tag still hashed the root alone. Two loops whose items rendered identical JSX with different descendant hooks or other artifacts the JSX never shows collapsed into one module, and both ran whatever registered last. memo_tag takes the snapshot decision from the caller, so the tag covers descendants' artifacts whenever the body does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
…-context-refactor-jv3pig
reflex and reflex-components-core need the client state module and memo changes that only exist in-tree, and reflex-components-internal needs rx.client_state, so their floors move to 0.10.2.dev0 now that 0.10.1 is published without them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
Describe what the code does rather than what it replaced, and keep the changelog fragments to the change and its effect on users. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
Keep this change to the rx.client_state interface. The scoped loop vars, the per-item provider around foreach items, item isolation in the memoize plugin and their tests and docs move to a separate pull request. Per-item client state inside a loop is written with an @rx.memo item function, which the docs and the Playwright scenario now show. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
A prefix may not contain $, and the generated name is the prefix, $, then the shared counter, so names built from different prefixes can never coincide. The legacy shim draws its generated names from the same helper. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
…-context-refactor-jv3pig
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
| if (event.name == "_client_state_set") { | ||
| const store = refs[CLIENT_STATE_REF]; | ||
| if (store === undefined) { | ||
| console.error( | ||
| `Cannot set client state "${event.payload.var_name}": no ClientStateProvider is mounted.`, | ||
| ); | ||
| } else { | ||
| store.set(event.payload.var_name, event.payload.value); |
There was a problem hiding this comment.
When a named var is used only by backend handlers, push and retrieve never request ClientStateProvider. This branch then drops every write, and reads return undefined. An app that pushes a value and later retrieves it therefore loses that value.
Use the shared store directly here, or ensure backend-only use also installs the provider. PR #7690 leaves this path unchanged.
parse_args_spec hands an args spec EventArgVar placeholders that carry their position and the trigger's full signature. A client state setter takes its bound value apart with get_leaf_vars, finds the placeholders it reads, and declares every trigger argument up to the last one read, in order, so a value built from an argument through an operation, a later argument, or an updater lambda closing over one all bind to the argument the chain passes rather than to the enclosing closure. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
…gnature A placeholder's position is its index in the trigger signature it carries, so the separate field goes. The setter now walks the bound value itself and always searches a function through its body, so a placeholder closed over by a nested lambda is declared even when another leaf of that body reads the lambda's own parameter, which is where get_leaf_vars keeps the function whole. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
Type of change
Description
Promote client-only state to
rx.client_statewith a store-backed React runtime.What changes for users
rx.client_state(default, name=...)is the public API. A named var is global: any component can read and write it, and the backend canpushandretrieveit. An unnamed var is scoped to the component tree that first uses it, so each@rx.memoinstance owns its own value, the way React'suseStatedoes..settakes a value, aFunctionVar, or a lambda traced at compile time (count.set(lambda v: v + 1)), and replaces.set_value.rx._x.client_stateandClientStateVar.create(var_name, default, global_ref)keep working with a deprecation warning. An unnamed legacy var stays global unlessglobal_ref=Falsewas passed.Implementation
reflex_base.client_state:ClientStateVar,ClientStateSetter, theclient_state()factory..templates/web/utils/client_state.js: slots with independent subscriptions, a scope chain that decides which slot a name resolves to, andgetClientState/setClientStatefor code outside the React tree.ClientStateProviderapp wrap publishes the store;@rx.memobodies that use client state are wrapped in aClientStateScopeso each instance is a scope boundary. Auto-memoize wrappers stay invisible to scoping.tests/js/vitest harness for the shipped frontend JavaScript, wired into the unit-tests workflow.Not in this PR
Per-item scoping of
rx.foreachloop vars (providing the item and index to handlers and memoized descendants, and isolating each item in its own component) moves to a follow-up PR. Until it lands, per-item client state inside a loop is written with an@rx.memoitem function, which the docs show.Checklist
uv run ruff check .anduv run ruff format .cleanuv run pyright reflex testspassespyi_hashes.jsonupdatedhttps://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9
Generated by Claude Code