From e6fe6fad2881c7460a3ef9111626a2e02b76dfeb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 01:02:12 +0000 Subject: [PATCH 1/9] feat(client_state): promote to rx.client_state on a React context 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 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- .../pages/integrations/integration_gallery.py | 5 +- .../reflex_docs/templates/docpage/docpage.py | 7 +- docs/library/data-display/icon.md | 5 +- docs/wrapping-react/overview.md | 40 +- .../.templates/web/utils/client_state.js | 197 +++++++ .../reflex_base/.templates/web/utils/state.js | 106 +++- .../src/reflex_base/client_state.py | 485 ++++++++++++++++++ .../components/client_state_context.py | 39 ++ .../src/reflex_base/constants/base.py | 2 + .../src/reflex_base/constants/state.py | 3 + .../blocks/demo_form.py | 13 +- .../blocks/intro_form.py | 13 +- pyi_hashes.json | 2 +- reflex/__init__.py | 1 + reflex/experimental/client_state.py | 303 +---------- .../tests_playwright/test_client_state.py | 231 +++++++++ tests/units/compiler/test_memoize_plugin.py | 64 +-- tests/units/experimental/__init__.py | 0 tests/units/experimental/test_client_state.py | 21 + tests/units/reflex_base/test_client_state.py | 460 +++++++++++++++++ 20 files changed, 1621 insertions(+), 376 deletions(-) create mode 100644 packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js create mode 100644 packages/reflex-base/src/reflex_base/client_state.py create mode 100644 packages/reflex-base/src/reflex_base/components/client_state_context.py create mode 100644 tests/integration/tests_playwright/test_client_state.py create mode 100644 tests/units/experimental/__init__.py create mode 100644 tests/units/experimental/test_client_state.py create mode 100644 tests/units/reflex_base/test_client_state.py diff --git a/docs/app/reflex_docs/pages/integrations/integration_gallery.py b/docs/app/reflex_docs/pages/integrations/integration_gallery.py index 8bf4c41c2c6..2fdaab3eaf3 100644 --- a/docs/app/reflex_docs/pages/integrations/integration_gallery.py +++ b/docs/app/reflex_docs/pages/integrations/integration_gallery.py @@ -1,12 +1,11 @@ import reflex as rx import reflex_components_internal as ui -from reflex.experimental import ClientStateVar from reflex_site_shared.integrations import get_integration_logo_url from .integration_list import get_integration_path from .integration_request import request_integration_dialog -selected_filter = ClientStateVar.create("selected_filter", "All") +selected_filter = rx.client_state("selected_filter", "All") FilterOptions = [ {"name": "AI", "icon": "BotIcon"}, @@ -29,7 +28,7 @@ def integration_filter_button(data: dict): variant="outline", class_name="flex flex-row items-center " + rx.cond(selected_filter.value == data["name"], active_pill, "").to(str), - on_click=selected_filter.set_value(data["name"]), + on_click=selected_filter.set(data["name"]), ) diff --git a/docs/app/reflex_docs/templates/docpage/docpage.py b/docs/app/reflex_docs/templates/docpage/docpage.py index 30f93a9ccab..de0398a5f7d 100644 --- a/docs/app/reflex_docs/templates/docpage/docpage.py +++ b/docs/app/reflex_docs/templates/docpage/docpage.py @@ -6,7 +6,6 @@ import reflex as rx import reflex_components_internal as ui from reflex.components.radix.themes.base import LiteralAccentColor -from reflex.experimental.client_state import ClientStateVar from reflex.utils.format import to_snake_case, to_title_case from reflex_site_shared.components.blocks.code import * from reflex_site_shared.components.blocks.demo import * @@ -86,7 +85,7 @@ def feedback_button_toc() -> rx.Component: @rx.memo def copy_to_markdown(text: rx.Var[str]) -> rx.Component: - copied = ClientStateVar.create("is_copied", default=False, global_ref=False) + copied = rx.client_state("is_copied", default=False, global_ref=False) return marketing_button( rx.cond( copied.value, @@ -101,10 +100,10 @@ def copy_to_markdown(text: rx.Var[str]) -> rx.Component: variant="ghost", class_name="justify-start pl-0 text-secondary-11", on_click=[ - rx.call_function(copied.set_value(True)), + rx.call_function(copied.set(True)), rx.set_clipboard(text), ], - on_mouse_down=rx.call_function(copied.set_value(False)).debounce(1500), + on_mouse_down=rx.call_function(copied.set(False)).debounce(1500), ) diff --git a/docs/library/data-display/icon.md b/docs/library/data-display/icon.md index 24038fcf8df..cd4efa6e678 100644 --- a/docs/library/data-display/icon.md +++ b/docs/library/data-display/icon.md @@ -7,9 +7,8 @@ components: import reflex as rx from reflex_components_lucide.icon import LUCIDE_ICON_LIST -from reflex.experimental.client_state import ClientStateVar -icon_search_cs = ClientStateVar.create("icon_search", default="") +icon_search_cs = rx.client_state("icon_search", default="") @rx.memo @@ -26,7 +25,7 @@ def lucide_icons() -> rx.Component: ), rx.el.input( placeholder="Search icons...", - on_change=icon_search_cs.set_value, + on_change=icon_search_cs.set, class_name="relative box-border border-secondary-4 focus:border-violet-9 focus:border-1 bg-secondary-2 p-[0.5rem_0.75rem] border rounded-xl font-base text-secondary-11 placeholder:text-secondary-9 outline-none focus:outline-none w-full mb-2 pl-10", ), class_name="relative flex items-center", diff --git a/docs/wrapping-react/overview.md b/docs/wrapping-react/overview.md index 453c38d47fe..c23f7b99b44 100644 --- a/docs/wrapping-react/overview.md +++ b/docs/wrapping-react/overview.md @@ -58,7 +58,6 @@ We also have a var `color` which is the current color of the color picker. Since this component has interaction we must specify any event triggers that the component takes. The color picker has a single trigger `on_change` to specify when the color changes. This trigger takes in a single argument `color` which is the new color. ```python exec -from reflex.experimental.client_state import ClientStateVar from reflex.components.component import NoSSRComponent @@ -71,7 +70,7 @@ class ColorPicker(NoSSRComponent): color_picker = ColorPicker.create -ColorPickerState = ClientStateVar.create(default="#db114b", var_name="color") +ColorPickerState = rx.client_state(default="#db114b", var_name="color") ``` ```python eval @@ -79,7 +78,7 @@ rx.box( ColorPickerState, rx.vstack( rx.heading(ColorPickerState.value, as_="h2", color="white"), - color_picker(on_change=ColorPickerState.set_value), + color_picker(on_change=ColorPickerState.set), ), background_color=ColorPickerState.value, padding="5em", @@ -122,6 +121,41 @@ def index(): ) ``` +## Setting Client State From Plain JavaScript + +`value` and `set` are the normal way to use a client state var, but they resolve to a +hook, so they only work inside a component that Reflex renders. When you are wrapping a +library that hands you a plain JavaScript callback -- or you are writing your own JS in +`add_custom_code` -- use `global_value` and `global_set` instead. They need no hook, so +they work anywhere in your compiled page: + +```python +picker_color = rx.client_state("picker_color", default="#db114b") + + +class MyPicker(rx.Component): + library = "some-non-react-picker" + tag = "Picker" + + def add_custom_code(self) -> list[str]: + # `global_set` is a plain function, so a non-React callback can call it. + return [f"const onPickerChange = {picker_color.global_set};"] +``` + +Reads through `global_value` are a point-in-time snapshot with no reactivity, so prefer +`value` inside components. Writes through `global_set` re-render every component +subscribed to that var, exactly like `set` does. Both require a named (non-local) +client state var, since the name is what identifies the value. + +`rx.call_script` is the one place these do not work: its code is evaluated inside the +Reflex runtime module, so your page's imports are not in scope there. Reach the store +through the `refs` object instead, which is also how you inspect client state from the +browser devtools console: + +```python +rx.call_script('refs["__client_state"].set("picker_color", "#ffffff")') +``` + ## What Not To Wrap There are some libraries on npm that are not do not expose React components and therefore are very hard to wrap with Reflex. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js new file mode 100644 index 00000000000..ea8465c4a3b --- /dev/null +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js @@ -0,0 +1,197 @@ +/** + * Client-only state, shared by name across components without a backend rx.State. + * + * `useClientState` is the only thing compiled components call. Everything else + * here is the bookkeeping it needs: a store of independently-subscribable slots, + * the context that delivers it, and a module-level door for JS that runs outside + * the React tree (see `getClientState` / `setClientState`). + * + * Each slot owns its own listener set, so writing one var only re-renders the + * components subscribed to *that* var. The context value is the store object + * itself and never changes identity, so mounting the provider never cascades. + */ +import { + createContext, + createElement, + useContext, + useEffect, + useRef, + useSyncExternalStore, +} from "react"; + +import { refs } from "$/utils/state"; + +/** The single `refs` key holding the live store, for devtools introspection. */ +export const CLIENT_STATE_REF = "__client_state"; + +/** + * Create a slot: one named (or anonymous) piece of client state. + * @param value The initial value. + * @returns A slot with its own listener set. + */ +const createSlot = (value) => { + const listeners = new Set(); + const slot = { + value, + // Stable identities: useSyncExternalStore requires them. + subscribe: (onStoreChange) => { + listeners.add(onStoreChange); + return () => listeners.delete(onStoreChange); + }, + getSnapshot: () => slot.value, + set: (next) => { + // Match the useState contract: a function is an updater, not a value. + const resolved = typeof next === "function" ? next(slot.value) : next; + if (Object.is(resolved, slot.value)) { + return; + } + slot.value = resolved; + listeners.forEach((listener) => listener()); + }, + }; + return slot; +}; + +/** + * Create a store of client state slots. + * @returns The store. + */ +export const createClientStateStore = () => { + const slots = new Map(); + + /** + * Get the slot for `name`, creating it if absent. + * @param name The slot name. + * @param defaultValue Initial value, used only when creating the slot. + * @returns The named slot. + */ + const namedSlot = (name, defaultValue) => { + let slot = slots.get(name); + if (slot === undefined) { + slot = createSlot(defaultValue); + slots.set(name, slot); + } + return slot; + }; + + return { + /** + * Resolve the slot a `useClientState` call should bind to. + * @param name The shared name, or a falsy value for a private slot. + * @param defaultValue The initial value. + * @returns A shared slot when named, else a fresh anonymous one. + */ + slot: (name, defaultValue) => + name ? namedSlot(name, defaultValue) : createSlot(defaultValue), + /** + * Read a named slot's current value. + * @param name The slot name. + * @returns The value, or undefined if the slot does not exist yet. + */ + get: (name) => slots.get(name)?.value, + /** + * Write a named slot, creating it if it does not exist yet, so a value + * pushed before any component mounts is picked up on mount. + * @param name The slot name. + * @param value The value, or an updater function. + */ + set: (name, value) => { + namedSlot(name, undefined).set(value); + }, + }; +}; + +let _clientStore = null; + +/** + * The client-side store singleton. + * + * Shared so that non-React callers and the hooks operate on the same slots + * regardless of mount order. Never used during SSR — `ClientStateProvider` + * builds a per-render store on the server so requests stay isolated. + * @returns The store. + */ +export const getClientStore = () => { + if (_clientStore === null) { + _clientStore = createClientStateStore(); + } + return _clientStore; +}; + +export const ClientStateContext = createContext(null); + +/** + * Read a named client state var from outside the React tree. + * + * A point-in-time snapshot with no reactivity; prefer the value returned by + * `useClientState` inside components. + * @param name The client state var name. + * @returns The current value. + */ +export const getClientState = (name) => getClientStore().get(name); + +/** + * Write a named client state var from outside the React tree. + * + * Every subscribed component re-renders. Use this to drive client state from + * third-party library callbacks or other non-React JS. + * @param name The client state var name. + * @param value The value, or an updater function. + */ +export const setClientState = (name, value) => { + getClientStore().set(name, value); +}; + +/** + * Provide the client state store to the tree. + * @param props The component props. + * @param props.children The children to render. + * @returns The provider element. + */ +export function ClientStateProvider({ children }) { + const storeRef = useRef(null); + if (storeRef.current === null) { + // A per-render store on the server keeps requests isolated; on the client, + // share the singleton so `setClientState` reaches these same slots. + storeRef.current = + typeof document === "undefined" + ? createClientStateStore() + : getClientStore(); + } + const store = storeRef.current; + + useEffect(() => { + // Client-only, so the server's module-scope `refs` is never written. + refs[CLIENT_STATE_REF] = store; + return () => { + if (refs[CLIENT_STATE_REF] === store) { + delete refs[CLIENT_STATE_REF]; + } + }; + }, [store]); + + return createElement(ClientStateContext.Provider, { value: store }, children); +} + +/** + * Subscribe to a piece of client state. + * @param defaultValue The initial value. + * @param name Shared name, or omitted for state private to this component. + * @returns A `[value, setValue]` pair, like `useState`. + */ +export function useClientState(defaultValue, name) { + const store = useContext(ClientStateContext) ?? getClientStore(); + const slotRef = useRef(null); + if (slotRef.current === null) { + // `name` is a compile-time constant per call site, so the slot a mounted + // hook is bound to can never change. + slotRef.current = store.slot(name, defaultValue); + } + const slot = slotRef.current; + const value = useSyncExternalStore( + slot.subscribe, + slot.getSnapshot, + slot.getSnapshot, + ); + return [value, slot.set]; +} diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index 8ba6d00509c..c11a8a58601 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -216,6 +216,41 @@ function urlFrom(string) { return undefined; } +/** + * Invoke an event's result callback, if it declared one. + * + * The callback arrives as a string built by ``format_queue_events``, which + * references ``queueEvents``/``processEvent`` (module-level here) plus ``socket``, + * ``navigate`` and ``params``. Those three MUST stay the parameter names below: + * the ``eval`` resolves them from this function's scope, so renaming them breaks + * every callback. + * @param event The event whose callback to run. + * @param eval_result The value to pass to the callback, awaited if thenable. + * @param socket The socket object to send events on. + * @param navigate The navigate function from useNavigate. + * @param params The params object from useParams. + */ +const applyResultCallback = async ( + event, + eval_result, + socket, + navigate, + params, +) => { + if (!event.payload.callback) { + return; + } + const final_result = + !!eval_result && typeof eval_result.then === "function" + ? await eval_result + : eval_result; + const callback = + typeof event.payload.callback === "string" + ? eval(event.payload.callback) + : event.payload.callback; + callback(final_result); +}; + /** * Handle frontend event or send the event to the backend via Websocket. * @param event The event to send. @@ -342,23 +377,58 @@ export const applyEvent = async (event, socket, navigate, params) => { return; } + // Client state is reached through `refs` rather than an import: `client_state.js` + // imports `refs` from here, so importing it back would be a cycle. The key must + // stay in sync with CLIENT_STATE_REF in `$/utils/client_state`. + if (event.name == "_client_state_set") { + const store = refs["__client_state"]; + 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); + } + return; + } + + if (event.name == "_client_state_get") { + const store = refs["__client_state"]; + if (store === undefined) { + console.error( + `Cannot read client state "${event.payload.var_name}": no ClientStateProvider is mounted.`, + ); + return; + } + try { + await applyResultCallback( + event, + store.get(event.payload.var_name), + socket, + navigate, + params, + ); + } catch (e) { + console.log("_client_state_get", e); + if (window && window?.onerror) { + window.onerror(e.message, null, null, null, e); + } + } + return; + } + if ( event.name == "_call_function" && typeof event.payload.function !== "string" ) { try { - const eval_result = event.payload.function(); - if (event.payload.callback) { - const final_result = - !!eval_result && typeof eval_result.then === "function" - ? await eval_result - : eval_result; - const callback = - typeof event.payload.callback === "string" - ? eval(event.payload.callback) - : event.payload.callback; - callback(final_result); - } + await applyResultCallback( + event, + event.payload.function(), + socket, + navigate, + params, + ); } catch (e) { console.log("_call_function", e); if (window && window?.onerror) { @@ -375,17 +445,7 @@ export const applyEvent = async (event, socket, navigate, params) => { ? eval(event.payload.javascript_code) : eval(event.payload.function)(); - if (event.payload.callback) { - const final_result = - !!eval_result && typeof eval_result.then === "function" - ? await eval_result - : eval_result; - const callback = - typeof event.payload.callback === "string" - ? eval(event.payload.callback) - : event.payload.callback; - callback(final_result); - } + await applyResultCallback(event, eval_result, socket, navigate, params); } catch (e) { console.log("_call_script", e); if (window && window?.onerror) { diff --git a/packages/reflex-base/src/reflex_base/client_state.py b/packages/reflex-base/src/reflex_base/client_state.py new file mode 100644 index 00000000000..d6ea6a5ce16 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/client_state.py @@ -0,0 +1,485 @@ +"""Handle client side state with `useClientState`.""" + +from __future__ import annotations + +import dataclasses +import inspect +import itertools +import re +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from reflex_base.components.client_state_context import get_client_state_app_wraps +from reflex_base.constants import Dirs +from reflex_base.constants.state import ( + CAMEL_CASE_CLIENT_STATE_MARKER, + CAMEL_CASE_MEMO_MARKER, + FIELD_MARKER, +) +from reflex_base.event import EventChain, EventHandler, EventSpec, server_side +from reflex_base.utils import console, format +from reflex_base.utils.exceptions import VarTypeError +from reflex_base.utils.imports import ImportVar +from reflex_base.vars import VarData +from reflex_base.vars.base import LiteralVar, Var +from reflex_base.vars.function import ArgsFunctionOperationBuilder, FunctionVar + +if TYPE_CHECKING: + from typing_extensions import deprecated + +NoValue = object() + +_CLIENT_STATE_IMPORT = { + f"$/{Dirs.CLIENT_STATE_PATH}": [ImportVar(tag="useClientState")], +} +_CLIENT_STATE_ESCAPE_IMPORT = { + f"$/{Dirs.CLIENT_STATE_PATH}": [ + ImportVar(tag="getClientState"), + ImportVar(tag="setClientState"), + ], +} + +# Generated names come from a dedicated counter rather than +# `get_unique_variable_name`, which draws from a process-wide generator shared +# with every other consumer -- so an unrelated `ArrayVar.map` would shift every +# subsequent client state name. This keeps a name dependent only on how many +# client state vars were created before it. +_name_counter = itertools.count() + +# Separate from _name_counter so tracing a lambda updater never shifts the +# generated var-name sequence. +_placeholder_counter = itertools.count() + +_VALID_NAME = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*$") + +# Reflex marks every identifier it puts in scope; an unmarked `_`-leading name is +# an event-arg placeholder from `parse_args_spec`. +_IN_SCOPE_MARKERS = ( + CAMEL_CASE_CLIENT_STATE_MARKER, + CAMEL_CASE_MEMO_MARKER, + FIELD_MARKER, +) +_LEADING_EVENT_ARG = re.compile(r"^_[A-Za-z0-9_$]*") + + +def _recovered_event_arg(value_str: str) -> str | None: + """Get the event-arg parameter an emitted setter wrapper must declare. + + A value bound into a setter may reference the event args of the trigger it is + attached to, in which case the wrapper has to declare them or they are + unbound when it fires. + + Args: + value_str: The rendered value expression. + + Returns: + The parameter name to declare, or None if the value references no event arg. + """ + match = _LEADING_EVENT_ARG.match(value_str) + if match is None: + return None + name = match.group() + if name.endswith(_IN_SCOPE_MARKERS): + return None + return name + + +def _client_state_set(var_name: str, value: Any): + """Signature holder for the ``_client_state_set`` event. + + Args: + var_name: The client state var name. + value: The value to set. + """ + + +def _client_state_get(var_name: str): + """Signature holder for the ``_client_state_get`` event. + + Args: + var_name: The client state var name. + """ + + +@dataclasses.dataclass( + eq=False, + frozen=True, + slots=True, +) +class ClientStateSetter(FunctionVar[Any]): + """The setter for a ClientStateVar. + + Attach it to an event trigger directly to forward the trigger's argument, or + call it to bind a specific value or a functional updater. + """ + + # The type of the value being set, used to type lambda updater placeholders. + _value_type: Any = dataclasses.field(default=Any) + + def __call__(self, value: Any = NoValue) -> Var: # pyright: ignore [reportIncompatibleMethodOverride] + """Bind a value to this setter. + + Args: + value: The value to set. A ``Var`` or literal is set directly; a + callable is traced at compile time and receives the current + value, so ``cs.set(lambda v: v + 1)`` becomes an updater. + + Returns: + A Var which sets the value when triggered. + """ + if value is NoValue: + return self + + # Check Var before callable: FunctionVars are themselves callable, and a + # Var is always passed through (the store treats a function value as an + # updater at runtime). + if isinstance(value, Var): + value_var = value + elif callable(value): + value_var = self._trace_updater(value) + else: + value_var = LiteralVar.create(value) + + value_str = str(value_var) + event_arg = _recovered_event_arg(value_str) + return ArgsFunctionOperationBuilder.create( + args_names=(event_arg,) if event_arg is not None else (), + return_expr=self.to(FunctionVar).call(value_var), + ).to(FunctionVar, EventChain) + + def _trace_updater(self, fn: Callable) -> Var: + """Trace a Python callable into a functional-updater Var. + + Args: + fn: The callable, taking at most one argument (the current value). + + Returns: + The traced updater, or the plain value for a zero-argument callable. + + Raises: + VarTypeError: If fn takes more than one argument. + """ + num_args = len(inspect.signature(fn).parameters) + if num_args > 1: + msg = "The function passed to ClientStateVar.set should take at most one argument." + raise VarTypeError(msg) + if num_args == 0: + return Var.create(fn()) + placeholder = Var( + _js_expr=f"prev{next(_placeholder_counter)}{CAMEL_CASE_CLIENT_STATE_MARKER}", + _var_type=self._value_type, + ).guess_type() + return ArgsFunctionOperationBuilder.create( + args_names=(placeholder._js_expr,), + return_expr=Var.create(fn(placeholder)), + ) + + +@dataclasses.dataclass( + eq=False, + frozen=True, + slots=True, +) +class ClientStateVar(Var): + """A Var that exists on the client via useClientState.""" + + # Track the names of the getters and setters + _setter_name: str = dataclasses.field(default="") + _getter_name: str = dataclasses.field(default="") + # The bare name keying this var in the client state store. + _state_name: str = dataclasses.field(default="") + + # Whether the state is shared by name (and reachable from the backend). + _global_ref: bool = dataclasses.field(default=True) + + # VarData without the hook, for accessors that work in any JS scope. + _escape_var_data: VarData | None = dataclasses.field(default=None) + + def __hash__(self) -> int: + """Define a hash function for a var. + + Returns: + The hash of the var. + """ + return hash(( + self._js_expr, + str(self._var_type), + self._getter_name, + self._setter_name, + )) + + @classmethod + def create( + cls, + var_name: str | None = None, + default: Any = NoValue, + global_ref: bool = True, + ) -> ClientStateVar: + """Create a local_state Var that can be accessed and updated on the client. + + The `ClientStateVar` should be included in the highest parent component + that contains the components which will access and manipulate the client + state. It has no visual rendering, including it ensures that the + `useClientState` hook is called in the correct scope. + + To render the var in a component, use the `value` property. + + To update the var in a component, use the `set` property: attach it to a + trigger to forward the trigger's argument, or call it with a value or a + function of the current value. + + To access the var in an event handler, use the `retrieve` method with + `callback` set to the event handler which should receive the value. + + To update the var in an event handler, use the `push` method with the + value to update. + + To read or write the var from JS outside a React component, use the + `global_value` and `global_set` properties. + + Args: + var_name: The name of the variable. + default: The default value of the variable. + global_ref: Whether the state should be accessible in any Component and on the backend. + + Returns: + ClientStateVar + + Raises: + ValueError: If var_name is not a valid identifier string. + """ + if var_name is None: + var_name = f"cs{next(_name_counter)}" + if isinstance(var_name, Var): + msg = ( + "var_name must be a string, not a Var. The name keys the client " + "state store and is embedded in the events that `push`, " + "`retrieve` and `global_set` send, so it has to be known at " + "compile time." + ) + raise ValueError(msg) + if not isinstance(var_name, str): + msg = "var_name must be a string." + raise ValueError(msg) + if not _VALID_NAME.match(var_name): + msg = ( + f"var_name {var_name!r} is not a valid javascript identifier; it " + "is emitted as one in the compiled app." + ) + raise ValueError(msg) + if default is NoValue: + default_var = Var(_js_expr="") + elif not isinstance(default, Var): + default_var = LiteralVar.create(default) + else: + default_var = default + # The marker keeps a user-chosen name from colliding with a JS reserved + # word; the store key stays the bare name. + getter_name = f"{var_name}{CAMEL_CASE_CLIENT_STATE_MARKER}" + setter_name = f"set{var_name[0].upper()}{var_name[1:]}" + name_arg = f", {LiteralVar.create(var_name)!s}" if global_ref else "" + hooks: dict[str, VarData | None] = { + f"const [{getter_name}, {setter_name}] = useClientState({default_var!s}{name_arg})": None, + } + app_wraps = get_client_state_app_wraps() + return cls( + _js_expr="null", + _setter_name=setter_name, + _getter_name=getter_name, + _state_name=var_name, + _global_ref=global_ref, + _var_type=default_var._var_type, + _var_data=VarData.merge( + default_var._var_data, + VarData( + hooks=hooks, + imports=_CLIENT_STATE_IMPORT, + app_wraps=app_wraps, + ), + ), + _escape_var_data=VarData( + imports=_CLIENT_STATE_ESCAPE_IMPORT, + app_wraps=app_wraps, + ), + ) + + @property + def value(self) -> Var: + """Get a placeholder for the Var. + + This property can only be rendered on the frontend. + + To access the value in a backend event handler, see `retrieve`. To read + it from JS outside a React component, see `global_value`. + + Returns: + an accessor for the client state variable. + """ + return Var(_js_expr=self._getter_name, _var_data=self._var_data).to( + self._var_type + ) + + @property + def set(self) -> ClientStateSetter: + """Set the value of the client state variable. + + Attach this to a frontend event trigger to forward the trigger's + argument, or call it with a value (``cs.set(True)``) or a function of the + current value (``cs.set(lambda v: v + 1)``). + + To set a value from a backend event handler, see `push`. To set it from + JS outside a React component, see `global_set`. + + Returns: + A special EventChain Var which will set the value when triggered. + """ + return ClientStateSetter( + _js_expr=self._setter_name, + _var_type=EventChain, + _var_data=self._var_data, + _value_type=self._var_type, + ) + + if TYPE_CHECKING: + + @deprecated("Use `set` instead.") + def set_value(self, value: Any = NoValue) -> Var: + """Set the value of the client state variable. + + Args: + value: The value to set. + + Returns: + A special EventChain Var which will set the value when triggered. + """ + ... + + else: + + def set_value(self, value: Any = NoValue) -> Var: + """Set the value of the client state variable. + + Args: + value: The value to set. + + Returns: + A special EventChain Var which will set the value when triggered. + """ + console.deprecate( + feature_name="ClientStateVar.set_value", + reason=( + "Use .set instead -- `cs.set` for the bare setter, " + "`cs.set(value)` to bind a value." + ), + deprecation_version="0.9.9", + removal_version="1.0", + ) + return self.set(value) + + @property + def global_value(self) -> Var: + """Read the client state variable from JS outside a React component. + + Unlike `value` this needs no hook, so it can be used in any javascript + scope -- a wrapped library's callback, `add_custom_code`, or + `rx.call_script`. It is a point-in-time read with no reactivity; prefer + `value` inside components. + + Returns: + An accessor for the client state variable. + + Raises: + ValueError: If the ClientStateVar is not global. + """ + if not self._global_ref: + msg = "ClientStateVar must be global to read the value from any scope." + raise ValueError(msg) + return Var( + _js_expr=f"getClientState({LiteralVar.create(self._state_name)!s})", + _var_data=self._escape_var_data, + ).to(self._var_type) + + @property + def global_set(self) -> Var: + """Set the client state variable from JS outside a React component. + + Unlike `set` this needs no hook, so the returned function can be handed + to a wrapped library as a plain callback. Every subscribed component + re-renders. + + Returns: + A function Var which sets the value when called. + + Raises: + ValueError: If the ClientStateVar is not global. + """ + if not self._global_ref: + msg = "ClientStateVar must be global to set the value from any scope." + raise ValueError(msg) + return Var( + _js_expr=( + f"((value) => setClientState({LiteralVar.create(self._state_name)!s}, value))" + ), + _var_data=self._escape_var_data, + ).to(FunctionVar) + + def retrieve(self, callback: EventHandler | Callable | None = None) -> EventSpec: + """Pass the value of the client state variable to a backend EventHandler. + + The event handler must `yield` or `return` the EventSpec to trigger the event. + + Args: + callback: The callback to pass the value to. + + Returns: + An EventSpec which will retrieve the value when triggered. + + Raises: + ValueError: If the ClientStateVar is not global. + """ + if not self._global_ref: + msg = "ClientStateVar must be global to retrieve the value." + raise ValueError(msg) + callback_kwargs = {"callback": None} + if callback is not None: + callback_kwargs = { + "callback": str( + format.format_queue_events( + callback, + args_spec=lambda result: [result], + ) + ), + } + return server_side( + "_client_state_get", + inspect.signature(_client_state_get), + var_name=self._state_name, + **callback_kwargs, + ) + + def push(self, value: Any) -> EventSpec: + """Push a value to the client state variable from the backend. + + The event handler must `yield` or `return` the EventSpec to trigger the event. + + Args: + value: The value to update. + + Returns: + An EventSpec which will push the value when triggered. + + Raises: + ValueError: If the ClientStateVar is not global. + """ + if not self._global_ref: + msg = "ClientStateVar must be global to push the value." + raise ValueError(msg) + return server_side( + "_client_state_set", + inspect.signature(_client_state_set), + var_name=self._state_name, + value=value, + ) + + +client_state = ClientStateVar.create diff --git a/packages/reflex-base/src/reflex_base/components/client_state_context.py b/packages/reflex-base/src/reflex_base/components/client_state_context.py new file mode 100644 index 00000000000..9e765dbe3d5 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/components/client_state_context.py @@ -0,0 +1,39 @@ +"""App-wrap component mounting the client-state React provider. + +Wraps children in the ``ClientStateProvider`` exported by +``utils/client_state.js``. It is attached to the ``VarData`` a +:class:`~reflex_base.client_state.ClientStateVar` carries, so the compiler picks +it up through the generic Var-driven app-wrap pipeline rather than the JS Layout +template hard-coding it around every app. +""" + +from __future__ import annotations + +from reflex_base.components.component import Component +from reflex_base.constants import Dirs + +# Inside ErrorBoundary (55) so a client-state error is caught, outside the +# theme/toaster/overlay wraps. It depends on neither StateProvider nor +# EventLoopProvider. +CLIENT_STATE_APP_WRAP_PRIORITY = 50 + + +class ClientStateContextProvider(Component): + """App wrap that mounts the React client-state provider around children.""" + + library = f"$/{Dirs.CLIENT_STATE_PATH}" + tag = "ClientStateProvider" + + +def get_client_state_app_wraps() -> tuple[tuple[int, Component], ...]: + """Build the app-wrap entry advertising the client-state provider. + + Returns a fresh instance per call so render-cache state can't leak across + compile runs via ``copy.deepcopy``. Entries are deduped by + ``(priority, tag)``, and equal instances collapse to one wrapper, so any + number of client state vars on a page yield a single provider. + + Returns: + A single ``(priority, provider)`` entry. + """ + return ((CLIENT_STATE_APP_WRAP_PRIORITY, ClientStateContextProvider.create()),) diff --git a/packages/reflex-base/src/reflex_base/constants/base.py b/packages/reflex-base/src/reflex_base/constants/base.py index b5c9079517e..8bc0aefb46d 100644 --- a/packages/reflex-base/src/reflex_base/constants/base.py +++ b/packages/reflex-base/src/reflex_base/constants/base.py @@ -38,6 +38,8 @@ class Dirs(SimpleNamespace): COMPONENTS_PATH = UTILS + "/components" # The name of the contexts file. CONTEXTS_PATH = UTILS + "/context" + # The name of the client state file. + CLIENT_STATE_PATH = UTILS + "/client_state" # The name of the output directory. BUILD_DIR = "build" # The name of the static files directory. diff --git a/packages/reflex-base/src/reflex_base/constants/state.py b/packages/reflex-base/src/reflex_base/constants/state.py index 8742f76e185..b26a440aa20 100644 --- a/packages/reflex-base/src/reflex_base/constants/state.py +++ b/packages/reflex-base/src/reflex_base/constants/state.py @@ -14,3 +14,6 @@ class StateManagerMode(str, Enum): FIELD_MARKER = "_rx_state_" MEMO_MARKER = "_rx_memo_" CAMEL_CASE_MEMO_MARKER = "RxMemo" +# Suffix on the JS identifier a ClientStateVar binds its value to, so a user-chosen +# name can never collide with a JS reserved word (`class`, `const`, ...). +CAMEL_CASE_CLIENT_STATE_MARKER = "RxClientState" diff --git a/packages/reflex-components-internal/src/reflex_components_internal/blocks/demo_form.py b/packages/reflex-components-internal/src/reflex_components_internal/blocks/demo_form.py index cae1362ac7e..f00d638cc15 100644 --- a/packages/reflex-components-internal/src/reflex_components_internal/blocks/demo_form.py +++ b/packages/reflex-components-internal/src/reflex_components_internal/blocks/demo_form.py @@ -9,7 +9,6 @@ import reflex as rx from reflex.event import EventType -from reflex.experimental.client_state import ClientStateVar from reflex.vars.base import get_unique_variable_name from reflex_components_internal.blocks.telemetry.posthog import ( track_demo_form_posthog_submission, @@ -22,8 +21,8 @@ from reflex_components_internal.components.icons.others import select_arrow from reflex_components_internal.utils.twmerge import cn -demo_form_error_message = ClientStateVar.create("demo_form_error_message", "") -demo_form_open_cs = ClientStateVar.create("demo_form_open", False) +demo_form_error_message = rx.client_state("demo_form_error_message", "") +demo_form_open_cs = rx.client_state("demo_form_open", False) PERSONAL_EMAIL_PROVIDERS = r"^(?!.*@(gmail|outlook|hotmail|yahoo|icloud|aol|protonmail|mail|yandex|zoho|live|msn|me|mac|googlemail)\.com$|.*@(yahoo|outlook|hotmail)\.co\.uk$|.*@yahoo\.ca$|.*@yahoo\.co\.in$|.*@proton\.me$).*$" @@ -376,7 +375,7 @@ def demo_form( ), on_submit=[ DemoFormStateUI.track_demo_form_posthog, - rx.call_function(demo_form_open_cs.set_value(False)), + rx.call_function(demo_form_open_cs.set(False)), *extra_on_submit, ], data_default_form_id="965991", @@ -439,10 +438,8 @@ def demo_form_dialog( ), ), open=demo_form_open_cs.value, - on_open_change=demo_form_open_cs.set_value, - on_open_change_complete=[ - rx.call_function(demo_form_error_message.set_value("")) - ], + on_open_change=demo_form_open_cs.set, + on_open_change_complete=[rx.call_function(demo_form_error_message.set(""))], class_name=class_name, **props, ) diff --git a/packages/reflex-components-internal/src/reflex_components_internal/blocks/intro_form.py b/packages/reflex-components-internal/src/reflex_components_internal/blocks/intro_form.py index a5028c5296b..09a848d4dd8 100644 --- a/packages/reflex-components-internal/src/reflex_components_internal/blocks/intro_form.py +++ b/packages/reflex-components-internal/src/reflex_components_internal/blocks/intro_form.py @@ -7,7 +7,6 @@ import reflex as rx from reflex.event import EventType, IndividualEventType -from reflex.experimental.client_state import ClientStateVar from reflex.vars.base import get_unique_variable_name from reflex_components_internal.blocks.telemetry.posthog import ( track_intro_form_posthog_submission, @@ -20,9 +19,9 @@ from reflex_components_internal.components.icons.others import select_arrow from reflex_components_internal.utils.twmerge import cn -intro_form_error_message = ClientStateVar.create("intro_form_error_message", "") -intro_form_open_cs = ClientStateVar.create("intro_form_open", False) -is_submitting_intro_form_cs = ClientStateVar.create("is_submitting_intro_form", False) +intro_form_error_message = rx.client_state("intro_form_error_message", "") +intro_form_open_cs = rx.client_state("intro_form_open", False) +is_submitting_intro_form_cs = rx.client_state("is_submitting_intro_form", False) PERSONAL_EMAIL_PROVIDERS = r"^(?!.*@(gmail|outlook|hotmail|yahoo|icloud|aol|protonmail|mail|yandex|zoho|live|msn|me|mac|googlemail)\.com$|.*@(yahoo|outlook|hotmail)\.co\.uk$|.*@yahoo\.ca$|.*@yahoo\.co\.in$|.*@proton\.me$).*$" @@ -418,7 +417,7 @@ def intro_form_dialog( hi("Cancel01Icon"), variant="ghost", size="icon-sm", - on_click=intro_form_open_cs.set_value(False), + on_click=intro_form_open_cs.set(False), class_name="text-secondary-11", ), ), @@ -436,8 +435,8 @@ def intro_form_dialog( ), open=intro_form_open_cs.value, on_open_change_complete=[ - rx.call_function(intro_form_error_message.set_value("")), - rx.call_function(is_submitting_intro_form_cs.set_value(False)), + rx.call_function(intro_form_error_message.set("")), + rx.call_function(is_submitting_intro_form_cs.set(False)), ], class_name=class_name, **props, diff --git a/pyi_hashes.json b/pyi_hashes.json index f8a05b8d672..6730004e3a3 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -118,7 +118,7 @@ "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "99ebcfc07868061bdc3c2010d85a153f", "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "4f6c26f8c76543cc41e2b9dc400ece8a", "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f170ac685b6ba5892370166c80684db3", - "reflex/__init__.pyi": "a3e1782fab4a9aed55f66cc98af8c217", + "reflex/__init__.pyi": "630f98a9a6b1c357373ecb33f83194c1", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", "reflex/experimental/memo.pyi": "bc8b48357bef580e70a5881b65d3d3f7" } diff --git a/reflex/__init__.py b/reflex/__init__.py index 8aa2bfc2880..f96755bd1e8 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -161,6 +161,7 @@ ], "reflex_components_sonner.toast": ["toast"], "reflex_base.components.props": ["PropsBase"], + "reflex_base.client_state": ["ClientStateVar", "client_state"], "reflex_components_core.datadisplay.logo": ["logo"], "reflex_components_gridjs": ["data_table"], "reflex_components_moment": ["MomentDelta", "moment"], diff --git a/reflex/experimental/client_state.py b/reflex/experimental/client_state.py index e24315b4734..da4b7d501e5 100644 --- a/reflex/experimental/client_state.py +++ b/reflex/experimental/client_state.py @@ -1,297 +1,14 @@ -"""Handle client side state with `useState`.""" +"""Handle client side state with `useClientState`. -from __future__ import annotations - -import dataclasses -import re -from collections.abc import Callable -from typing import Any - -from reflex_base import constants -from reflex_base.event import EventChain, EventHandler, EventSpec, run_script -from reflex_base.utils.imports import ImportVar -from reflex_base.vars import VarData, get_unique_variable_name -from reflex_base.vars.base import LiteralVar, Var -from reflex_base.vars.function import ArgsFunctionOperationBuilder, FunctionVar - -NoValue = object() - - -_refs_import = { - f"$/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")], -} - - -def _client_state_ref(var_name: str) -> Var: - """Get the ref accessor Var for a ClientStateVar. - - Args: - var_name: The name of the variable. - - Returns: - A Var that accesses the ClientStateVar ref slot, carrying the - ``refs`` import from ``$/utils/state``. - """ - return Var( - _js_expr=f"refs['_client_state_{var_name}']", - _var_data=VarData(imports=_refs_import), - ) - - -def _client_state_ref_dict(var_name: str) -> Var: - """Get the per-instance ref-dict accessor Var for a ClientStateVar. - - Args: - var_name: The name of the variable. - - Returns: - A Var that accesses the ClientStateVar's per-instance ref dict, - carrying the ``refs`` import from ``$/utils/state``. - """ - return Var( - _js_expr=f"refs['_client_state_dict_{var_name}']", - _var_data=VarData(imports=_refs_import), - ) - - -@dataclasses.dataclass( - eq=False, - frozen=True, - slots=True, -) -class ClientStateVar(Var): - """A Var that exists on the client via useState.""" - - # Track the names of the getters and setters - _setter_name: str = dataclasses.field(default="") - _getter_name: str = dataclasses.field(default="") - _id_name: str = dataclasses.field(default="") - - # Whether to add the var and setter to the global `refs` object for use in any Component. - _global_ref: bool = dataclasses.field(default=True) - - def __hash__(self) -> int: - """Define a hash function for a var. - - Returns: - The hash of the var. - """ - return hash(( - self._js_expr, - str(self._var_type), - self._getter_name, - self._setter_name, - )) - - @classmethod - def create( - cls, - var_name: str | None = None, - default: Any = NoValue, - global_ref: bool = True, - ) -> ClientStateVar: - """Create a local_state Var that can be accessed and updated on the client. - - The `ClientStateVar` should be included in the highest parent component - that contains the components which will access and manipulate the client - state. It has no visual rendering, including it ensures that the - `useState` hook is called in the correct scope. - - To render the var in a component, use the `value` property. - - To update the var in a component, use the `set` property or `set_value` method. - - To access the var in an event handler, use the `retrieve` method with - `callback` set to the event handler which should receive the value. - - To update the var in an event handler, use the `push` method with the - value to update. - - Args: - var_name: The name of the variable. - default: The default value of the variable. - global_ref: Whether the state should be accessible in any Component and on the backend. +Deprecated location. The implementation moved to +:mod:`reflex_base.client_state` and is exposed as ``rx.client_state``; this +module re-exports it so existing imports keep working. +""" - Returns: - ClientStateVar - - Raises: - ValueError: If the var_name is not a string. - """ - if var_name is None: - var_name = get_unique_variable_name() - id_name = "id_" + get_unique_variable_name() - if not isinstance(var_name, str): - msg = "var_name must be a string." - raise ValueError(msg) - if default is NoValue: - default_var = Var(_js_expr="") - elif not isinstance(default, Var): - default_var = LiteralVar.create(default) - else: - default_var = default - setter_name = f"set{var_name.capitalize()}" - hooks: dict[str, VarData | None] = { - f"const {id_name} = useId()": None, - f"const [{var_name}, {setter_name}] = useState({default_var!s})": None, - } - imports = { - "react": [ImportVar(tag="useState"), ImportVar(tag="useId")], - } - if global_ref: - arg_name = get_unique_variable_name() - setter_ref = _client_state_ref(setter_name) - var_ref = _client_state_ref(var_name) - var_dict_ref = _client_state_ref_dict(var_name) - setter_dict_ref = _client_state_ref_dict(setter_name) - func = ArgsFunctionOperationBuilder.create( - args_names=(arg_name,), - return_expr=Var("Array.prototype.forEach.call") - .to(FunctionVar) - .call( - ( - Var("Object.values") - .to(FunctionVar) - .call(setter_dict_ref) - .to(list) - .to(list) - ) - + Var.create([Var(f"(value) => {{ {var_ref} = value; }}")]).to( - list - ), - ArgsFunctionOperationBuilder.create( - args_names=("setter",), - return_expr=Var("setter").to(FunctionVar).call(Var(arg_name)), - ), - ), - ) - - hooks[f"{setter_ref!s} = {func!s}"] = setter_ref._get_all_var_data() - hooks[f"{var_ref!s} ??= {var_name!s}"] = var_ref._get_all_var_data() - hooks[f"{var_dict_ref!s} ??= {{}}"] = var_dict_ref._get_all_var_data() - hooks[f"{setter_dict_ref!s} ??= {{}}"] = setter_dict_ref._get_all_var_data() - hooks[f"{var_dict_ref!s}[{id_name}] = {var_ref!s}"] = VarData.merge( - var_dict_ref._get_all_var_data(), var_ref._get_all_var_data() - ) - hooks[f"{setter_dict_ref!s}[{id_name}] = {setter_name}"] = ( - setter_dict_ref._get_all_var_data() - ) - return cls( - _js_expr="null", - _setter_name=setter_name, - _getter_name=var_name, - _id_name=id_name, - _global_ref=global_ref, - _var_type=default_var._var_type, - _var_data=VarData.merge( - default_var._var_data, - VarData( - hooks=hooks, - imports=imports, - ), - ), - ) - - @property - def value(self) -> Var: - """Get a placeholder for the Var. - - This property can only be rendered on the frontend. - - To access the value in a backend event handler, see `retrieve`. - - Returns: - an accessor for the client state variable. - """ - js_expr = ( - f"{_client_state_ref_dict(self._getter_name)}[{self._id_name}]" - if self._global_ref - else self._getter_name - ) - return Var(_js_expr=js_expr, _var_data=self._var_data).to(self._var_type) - - def set_value(self, value: Any = NoValue) -> Var: - """Set the value of the client state variable. - - This property can only be attached to a frontend event trigger. - - To set a value from a backend event handler, see `push`. - - Args: - value: The value to set. - - Returns: - A special EventChain Var which will set the value when triggered. - """ - setter = ( - _client_state_ref(self._setter_name) - if self._global_ref - else Var(self._setter_name) - ).to(FunctionVar) - - if value is not NoValue: - # This is a hack to make it work like an EventSpec taking an arg - value_var = LiteralVar.create(value) - value_str = str(value_var) - - setter = ArgsFunctionOperationBuilder.create( - # remove patterns of ["*"] from the value_str using regex - args_names=(re.sub(r"(\?\.)?\[\".*\"\]", "", value_str),) - if value_str.startswith("_") - else (), - return_expr=setter.call(value_var), - ) - - return setter.to(FunctionVar, EventChain) - - @property - def set(self) -> Var: - """Set the value of the client state variable. - - This property can only be attached to a frontend event trigger. - - To set a value from a backend event handler, see `push`. - - Returns: - A special EventChain Var which will set the value when triggered. - """ - return self.set_value() - - def retrieve(self, callback: EventHandler | Callable | None = None) -> EventSpec: - """Pass the value of the client state variable to a backend EventHandler. - - The event handler must `yield` or `return` the EventSpec to trigger the event. - - Args: - callback: The callback to pass the value to. - - Returns: - An EventSpec which will retrieve the value when triggered. - - Raises: - ValueError: If the ClientStateVar is not global. - """ - if not self._global_ref: - msg = "ClientStateVar must be global to retrieve the value." - raise ValueError(msg) - return run_script(_client_state_ref(self._getter_name), callback=callback) - - def push(self, value: Any) -> EventSpec: - """Push a value to the client state variable from the backend. - - The event handler must `yield` or `return` the EventSpec to trigger the event. - - Args: - value: The value to update. +from __future__ import annotations - Returns: - An EventSpec which will push the value when triggered. +from reflex_base.client_state import ClientStateVar as ClientStateVar +from reflex_base.client_state import NoValue as NoValue +from reflex_base.client_state import client_state as client_state - Raises: - ValueError: If the ClientStateVar is not global. - """ - if not self._global_ref: - msg = "ClientStateVar must be global to push the value." - raise ValueError(msg) - value = Var.create(value) - return run_script(f"{_client_state_ref(self._setter_name)}({value})") +__all__ = ["ClientStateVar", "NoValue", "client_state"] diff --git a/tests/integration/tests_playwright/test_client_state.py b/tests/integration/tests_playwright/test_client_state.py new file mode 100644 index 00000000000..44878043f83 --- /dev/null +++ b/tests/integration/tests_playwright/test_client_state.py @@ -0,0 +1,231 @@ +"""Integration tests for ``rx.client_state`` runtime behavior. + +Covers what unit tests cannot: the React runtime in ``utils/client_state.js``. +Shared named vars staying in sync across components, backend ``push``/``retrieve`` +over the new wire events, ``global_ref=False`` isolation, the non-React escape +hatch, functional updaters, and — the property the store exists to guarantee — +that writing one var does not re-render components subscribed only to another. +""" + +from collections.abc import Generator + +import pytest +from playwright.sync_api import Page, expect + +from reflex.testing import AppHarness + + +def ClientStateApp(): + """App exercising ``rx.client_state`` runtime behavior.""" + from reflex_base.vars.function import ArgsFunctionOperationBuilder, FunctionVar + + import reflex as rx + + shared = rx.client_state("shared", default="initial") + counter = rx.client_state("counter", default=0) + other = rx.client_state("other", default="untouched") + + class ClientStateAppState(rx.State): + retrieved: str = "" + + @rx.event + def push_shared(self): + return shared.push("from-backend") + + @rx.event + def do_retrieve(self): + return shared.retrieve(ClientStateAppState.got_value) + + @rx.event + def got_value(self, value: str): + self.retrieved = value + + @rx.memo + def local_input(label: rx.Var[str]) -> rx.Component: + # global_ref=False: each rendered instance owns a private slot. + local = rx.client_state(global_ref=False, default="") + return rx.hstack( + rx.input( + value=local.value, + on_change=local.set, + id=f"local-input-{label}", + ), + rx.text(local.value, id=f"local-echo-{label}"), + ) + + def index() -> rx.Component: + return rx.vstack( + rx.input( + value=ClientStateAppState.router.session.client_token, + read_only=True, + id="token", + ), + # Two independent readers of the same named var. + rx.text(shared.value, id="shared-a"), + rx.text(shared.value, id="shared-b"), + rx.input(value=shared.value, on_change=shared.set, id="shared-input"), + rx.button("set-shared", id="set-shared", on_click=shared.set("clicked")), + # Functional updater. + rx.text(counter.value, id="counter-value"), + rx.button( + "increment", id="increment", on_click=counter.set(lambda v: v + 1) + ), + # A var nothing else writes, to prove writes are isolated. + rx.text(other.value, id="other-value"), + # Backend round trips. + rx.button("push", id="push", on_click=ClientStateAppState.push_shared), + rx.button( + "retrieve", id="retrieve", on_click=ClientStateAppState.do_retrieve + ), + rx.text(ClientStateAppState.retrieved, id="retrieved"), + # Escape hatch: a plain JS function, no hook in scope. This is what + # gets handed to a wrapped library as a callback. + rx.button( + "global-set", + id="global-set", + on_click=rx.call_function( + ArgsFunctionOperationBuilder.create( + args_names=(), + return_expr=shared.global_set.to(FunctionVar).call( + "from-plain-js" + ), + ) + ), + ), + # rx.call_script evals inside the Reflex runtime module, where the + # page's imports are not in scope, so reach the store via refs. + rx.button( + "global-set-script", + id="global-set-script", + on_click=rx.call_script( + 'refs["__client_state"].set("shared", "from-call-script")' + ), + ), + local_input(label="one"), + local_input(label="two"), + ) + + app = rx.App() + app.add_page(index) + + +@pytest.fixture(scope="module") +def client_state_app(tmp_path_factory) -> Generator[AppHarness, None, None]: + """Start the client state app. + + Args: + tmp_path_factory: pytest tmp_path_factory fixture. + + Yields: + The running AppHarness. + """ + with AppHarness.create( + root=tmp_path_factory.mktemp("client_state_app"), + app_source=ClientStateApp, + ) as harness: + yield harness + + +@pytest.fixture +def page(client_state_app: AppHarness, page: Page) -> Page: + """Navigate to the app and wait for hydration. + + Args: + client_state_app: The running harness. + page: The playwright page. + + Returns: + The page, loaded and hydrated. + """ + assert client_state_app.frontend_url is not None + page.goto(client_state_app.frontend_url) + expect(page.locator("#token")).not_to_have_value("") + return page + + +def test_named_var_is_shared_across_components(page: Page) -> None: + """Two components reading one named var stay in sync.""" + expect(page.locator("#shared-a")).to_have_text("initial") + expect(page.locator("#shared-b")).to_have_text("initial") + + page.locator("#shared-input").fill("typed") + + expect(page.locator("#shared-a")).to_have_text("typed") + expect(page.locator("#shared-b")).to_have_text("typed") + + +def test_set_with_bound_value(page: Page) -> None: + """``set(value)`` attached to a trigger sets that value.""" + page.locator("#set-shared").click() + expect(page.locator("#shared-a")).to_have_text("clicked") + + +def test_functional_updater_derives_from_current_value(page: Page) -> None: + """``set(lambda v: v + 1)`` increments rather than overwriting.""" + expect(page.locator("#counter-value")).to_have_text("0") + for expected in ("1", "2", "3"): + page.locator("#increment").click() + expect(page.locator("#counter-value")).to_have_text(expected) + + +def test_push_from_backend(page: Page) -> None: + """A backend ``push`` reaches the mounted components.""" + page.locator("#push").click() + expect(page.locator("#shared-a")).to_have_text("from-backend") + expect(page.locator("#shared-b")).to_have_text("from-backend") + + +def test_retrieve_to_backend(page: Page) -> None: + """``retrieve`` round-trips the current value to a backend handler.""" + page.locator("#shared-input").fill("to-retrieve") + expect(page.locator("#shared-a")).to_have_text("to-retrieve") + + page.locator("#retrieve").click() + expect(page.locator("#retrieved")).to_have_text("to-retrieve") + + +def test_global_set_from_plain_javascript(page: Page) -> None: + """The escape hatch drives the var from JS with no hook in scope.""" + page.locator("#global-set").click() + expect(page.locator("#shared-a")).to_have_text("from-plain-js") + expect(page.locator("#shared-b")).to_have_text("from-plain-js") + + +def test_store_is_reachable_through_refs(page: Page) -> None: + """``refs["__client_state"]`` is the documented entry point for eval'd code. + + ``rx.call_script`` runs inside the Reflex runtime module, so a page-level + import of ``setClientState`` is not in scope there; the single ``refs`` key + is what makes the store reachable (and introspectable from devtools). + """ + page.locator("#global-set-script").click() + expect(page.locator("#shared-a")).to_have_text("from-call-script") + expect(page.locator("#shared-b")).to_have_text("from-call-script") + + +def test_local_vars_are_isolated_between_instances(page: Page) -> None: + """``global_ref=False`` gives each rendered instance its own slot.""" + page.locator("#local-input-one").fill("only-one") + + expect(page.locator("#local-echo-one")).to_have_text("only-one") + expect(page.locator("#local-echo-two")).to_have_text("") + + page.locator("#local-input-two").fill("only-two") + + expect(page.locator("#local-echo-one")).to_have_text("only-one") + expect(page.locator("#local-echo-two")).to_have_text("only-two") + + +def test_writing_one_var_leaves_other_readers_untouched(page: Page) -> None: + """Per-var subscriptions: writing ``shared`` must not disturb ``other``. + + The old implementation fanned every write out to every registered setter, + so a component reading an unrelated var still re-rendered. + """ + expect(page.locator("#other-value")).to_have_text("untouched") + + page.locator("#shared-input").fill("churn") + page.locator("#increment").click() + + expect(page.locator("#shared-a")).to_have_text("churn") + expect(page.locator("#other-value")).to_have_text("untouched") diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 4f47acc1120..a7f7ea11745 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -1320,27 +1320,24 @@ def page() -> Component: ) -def test_client_state_setter_in_call_function_event_imports_refs() -> None: - """A button whose ``on_click`` calls a global ``ClientStateVar`` setter - must memoize and the resulting memo body's imports must include ``refs`` - from ``$/utils/state``. - - Regression: ``ClientStateVar.set_value`` builds its setter as - ``refs['_client_state_']`` but the returned setter ``Var`` does not - carry the ``refs`` import. When the on_click event chain is compiled into - the memo body, the body references ``refs['_client_state_'](42)`` - with no matching ``import { refs } from "$/utils/state"`` — producing a - ``ReferenceError: refs is not defined`` at runtime. +def test_client_state_setter_in_call_function_event_imports_hook() -> None: + """A button whose ``on_click`` calls a ``ClientStateVar`` setter must memoize + and the resulting memo body must declare the ``useClientState`` hook and + import it from ``$/utils/client_state``. + + The setter is the local binding returned by the hook, so the memo body is + only valid if ``ClientStateVar.set`` carries its own hook VarData. When it + did not, the body referenced a setter that nothing declared, producing a + ``ReferenceError`` at runtime. """ from reflex.compiler.compiler import compile_memo_components - from reflex.experimental.client_state import ClientStateVar - counter = ClientStateVar.create("counter", default=0) + counter = rx.client_state("counter", default=0) def page() -> Component: return rx.el.button( "click", - on_click=rx.call_function(counter.set_value(42)), + on_click=rx.call_function(counter.set(42)), ) ctx, _page_ctx = _compile_single_page(page) @@ -1358,25 +1355,32 @@ def page() -> Component: code for path, code in memo_files if Path(path).name == f"{wrapper_tag}.jsx" ) - assert "refs['_client_state_setCounter'](42)" in memo_code, ( - "Expected the memo body to call the client-state setter via refs.\n" + assert "setCounter(42)" in memo_code, ( + "Expected the memo body to call the client-state setter.\n" f"Memo code snippet: {memo_code[:2000]}" ) + assert 'useClientState(0, "counter")' in memo_code, ( + "Expected the memo body to declare the client-state hook so the setter " + f"binding exists.\nMemo code snippet: {memo_code[:2000]}" + ) - state_import_match = re.search( - r'^import\s*\{([^}]*)\}\s*from\s*"\$/utils/state"', + import_match = re.search( + r'^import\s*\{([^}]*)\}\s*from\s*"\$/utils/client_state"', memo_code, flags=re.MULTILINE, ) - assert state_import_match is not None, ( - "Memo body must import from $/utils/state since the on_click handler " - "uses refs['_client_state_setCounter'].\n" - f"Memo code snippet: {memo_code[:2000]}" + assert import_match is not None, ( + "Memo body must import from $/utils/client_state since it calls " + f"useClientState.\nMemo code snippet: {memo_code[:2000]}" + ) + imported_names = {name.strip() for name in import_match.group(1).split(",")} + assert "useClientState" in imported_names, ( + f"Memo body imports {imported_names!r} from $/utils/client_state but is " + f"missing 'useClientState'.\nMemo code snippet: {memo_code[:2000]}" ) - imported_names = {name.strip() for name in state_import_match.group(1).split(",")} - assert "refs" in imported_names, ( - f"Memo body imports {imported_names!r} from $/utils/state but is missing " - "'refs' — the on_click handler references refs['_client_state_setCounter'].\n" + + assert "refs['_client_state" not in memo_code, ( + "Client state must no longer route through the global refs object.\n" f"Memo code snippet: {memo_code[:2000]}" ) @@ -2126,15 +2130,13 @@ def test_client_state_value_inside_snapshot_boundary_is_memoized( ) -> None: """Client-state Vars are reactive and must trigger boundary memoization. - A ``client_state`` Var contributes its ``useState``/``useId`` hooks via + A ``client_state`` Var contributes its ``useClientState`` hook via ``var_data.hooks`` without setting ``var_data.state``. The reactive-Var walk must catch the hooks-only case so client-state-driven content inside a snapshot boundary lands in the memo body. Both global and page-local ``ClientStateVar`` Vars must drive the same wrapping. """ - from reflex.experimental.client_state import ClientStateVar - - cs_var = ClientStateVar.create("titletest", default="hi", global_ref=global_ref) + cs_var = rx.client_state("titletest", default="hi", global_ref=global_ref) title = Title.create(cs_var.value) ctx, page_ctx = _compile_single_page(lambda: title) assert len(ctx.memoize_wrappers) == 1, ( @@ -2143,7 +2145,7 @@ def test_client_state_value_inside_snapshot_boundary_is_memoized( ) page_output = page_ctx.output_code assert page_output is not None - assert "useState" not in page_output, ( + assert "useClientState" not in page_output, ( "Client-state hooks should be inside the memo body, not the page.\n" f"Page output snippet: {page_output[:2000]}" ) diff --git a/tests/units/experimental/__init__.py b/tests/units/experimental/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/units/experimental/test_client_state.py b/tests/units/experimental/test_client_state.py new file mode 100644 index 00000000000..0665f10b59a --- /dev/null +++ b/tests/units/experimental/test_client_state.py @@ -0,0 +1,21 @@ +"""The deprecated reflex.experimental.client_state path still resolves.""" + +import reflex as rx + + +def test_experimental_import_is_the_promoted_class() -> None: + """``reflex.experimental.client_state`` re-exports the reflex-base class.""" + from reflex.experimental.client_state import ClientStateVar + + assert ClientStateVar is rx.ClientStateVar + + +def test_experimental_namespace_factory_still_works() -> None: + """``rx._x.client_state`` keeps building the same vars.""" + assert rx._x.client_state("legacy", default=0)._state_name == "legacy" + + +def test_promoted_names_are_reachable_from_rx() -> None: + """The lazy-loader wiring only fails at attribute access, so assert it.""" + assert rx.client_state("promoted", default=0)._state_name == "promoted" + assert isinstance(rx.client_state("typed", default=0), rx.ClientStateVar) diff --git a/tests/units/reflex_base/test_client_state.py b/tests/units/reflex_base/test_client_state.py new file mode 100644 index 00000000000..29e877b7258 --- /dev/null +++ b/tests/units/reflex_base/test_client_state.py @@ -0,0 +1,460 @@ +"""Tests for reflex_base.client_state.""" + +from typing import Any + +import pytest +from reflex_base.client_state import ClientStateVar, _recovered_event_arg, client_state +from reflex_base.components.client_state_context import CLIENT_STATE_APP_WRAP_PRIORITY +from reflex_base.components.memo import MEMOS +from reflex_base.constants import Dirs +from reflex_base.utils.exceptions import VarTypeError +from reflex_base.vars.base import Var, VarData +from reflex_base.vars.function import FunctionVar + +import reflex as rx +from reflex.compiler import compiler + + +def _hook(cs: ClientStateVar) -> str: + """Get the single hook a client state var contributes. + + Args: + cs: The client state var. + + Returns: + The hook source line. + """ + hooks = list(cs._var_data.hooks) # pyright: ignore [reportOptionalMemberAccess] + assert len(hooks) == 1, f"expected exactly one hook, got {hooks}" + return hooks[0] + + +def _app_wraps(var_data: VarData | None) -> list[tuple[int, str]]: + """Summarize the app wraps a VarData carries. + + Args: + var_data: The var data to inspect. + + Returns: + A list of (priority, tag) pairs. + """ + assert var_data is not None + return [(priority, wrap.tag or "") for priority, wrap in var_data.app_wraps] # pyright: ignore [reportAttributeAccessIssue] + + +def test_single_hook_no_useState() -> None: + """A global var emits one useClientState hook and no raw useState/useId.""" + cs = client_state("counter", default=0) + hook = _hook(cs) + assert ( + hook + == 'const [counterRxClientState, setCounter] = useClientState(0, "counter")' + ) + assert "useState(" not in hook + assert "useId" not in hook + assert "refs[" not in hook + + +def test_local_var_omits_store_name() -> None: + """A ``global_ref=False`` var gets no name, so its slot stays private.""" + cs = client_state("copied", default=False, global_ref=False) + assert _hook(cs) == "const [copiedRxClientState, setCopied] = useClientState(false)" + + +def test_hook_imports_use_client_state() -> None: + """The hook carries the useClientState import.""" + imports = dict(cs_imports := client_state("x", default=0)._var_data.imports) # pyright: ignore [reportOptionalMemberAccess] + assert cs_imports is not None + tags = {i.tag for i in imports[f"$/{Dirs.CLIENT_STATE_PATH}"]} + assert tags == {"useClientState"} + + +@pytest.mark.parametrize("global_ref", [True, False]) +def test_provider_app_wrap_declared(global_ref: bool) -> None: + """The provider is requested in both modes; the hook always uses context.""" + cs = client_state("x", default=0, global_ref=global_ref) + assert _app_wraps(cs._var_data) == [ + (CLIENT_STATE_APP_WRAP_PRIORITY, "ClientStateProvider") + ] + + +def test_two_vars_dedupe_to_one_provider() -> None: + """Two client state vars must not conflict over the app-wrap slot.""" + from reflex_base.vars.base import insert_app_wraps + + target: dict[tuple[int, str], Any] = {} + for name in ("a", "b"): + cs = client_state(name, default=0) + insert_app_wraps(target, cs._var_data.app_wraps) # pyright: ignore [reportOptionalMemberAccess] + assert list(target) == [(CLIENT_STATE_APP_WRAP_PRIORITY, "ClientStateProvider")] + + +@pytest.mark.parametrize("global_ref", [True, False]) +def test_value_is_marked_identifier(global_ref: bool) -> None: + """``value`` renders the marked local binding in both modes.""" + cs = client_state("counter", default=0, global_ref=global_ref) + assert str(cs.value) == "counterRxClientState" + + +def test_set_bare_is_event_chain() -> None: + """``set`` renders the bare setter and is usable as an event trigger value.""" + from reflex_base.event import EventChain + + cs = client_state("counter", default=0) + assert str(cs.set) == "setCounter" + assert cs.set._var_type is EventChain + + +def test_set_bound_value() -> None: + """Calling ``set`` binds a value in a zero-arg wrapper.""" + cs = client_state("counter", default=0) + assert str(cs.set(42)) == "(() => (setCounter(42)))" + + +def test_set_carries_hook_import_and_app_wrap() -> None: + """The setter must drag in its own hook, import and provider.""" + cs = client_state("counter", default=0) + for setter in (cs.set, cs.set(42)): + var_data = setter._get_all_var_data() + assert var_data is not None + assert any("useClientState" in hook for hook in var_data.hooks) + assert f"$/{Dirs.CLIENT_STATE_PATH}" in dict(var_data.imports) + assert _app_wraps(var_data) == [ + (CLIENT_STATE_APP_WRAP_PRIORITY, "ClientStateProvider") + ] + + +@pytest.mark.parametrize( + ("default", "fn", "expected"), + [ + ( + 0, + lambda v: v + 1, + "(() => (setX(((prev{n}RxClientState) => (prev{n}RxClientState + 1)))))", + ), + ( + False, + lambda v: ~v, # noqa: FURB118 - a lambda is what is under test + "(() => (setX(((prev{n}RxClientState) => !(prev{n}RxClientState)))))", + ), + ( + "", + lambda v: v.upper(), + "(() => (setX(((prev{n}RxClientState) => prev{n}RxClientState.toUpperCase()))))", + ), + ], +) +def test_set_functional_updater_is_typed(default: Any, fn: Any, expected: str) -> None: + """A lambda is traced against a placeholder typed like the var.""" + cs = client_state("x", default=default) + rendered = str(cs.set(fn)) + # The placeholder counter is process-global; recover it from the output. + n = rendered.split("prev", 1)[1].split("RxClientState", 1)[0] + assert rendered == expected.format(n=n) + + +def test_set_zero_arg_callable_is_plain_value() -> None: + """A zero-argument callable is treated as the value, not an updater.""" + cs = client_state("x", default=0) + assert str(cs.set(lambda: 7)) == "(() => (setX(7)))" + + +def test_set_rejects_multi_arg_callable() -> None: + """An updater may only take the current value.""" + cs = client_state("x", default=0) + with pytest.raises(VarTypeError): + cs.set(lambda a, b: a + b) # pyright: ignore [reportCallIssue] # noqa: FURB118 - a lambda is what is under test + + +def test_set_passes_function_var_through() -> None: + """A FunctionVar is passed straight through as a runtime updater.""" + cs = client_state("x", default=0) + updater = Var("(p) => p + 1").to(FunctionVar) + assert str(cs.set(updater)) == "(() => (setX((p) => p + 1)))" + + +def test_set_declares_event_arg() -> None: + """A value referencing an event arg makes the wrapper declare it.""" + cs = client_state("x", default="") + assert ( + str(cs.set(Var('_e["target"]["value"]'))) + == '((_e) => (setX(_e["target"]["value"])))' + ) + + +def test_set_declares_event_arg_in_compound_expression() -> None: + """Only the event arg is declared, not the whole expression.""" + cs = client_state("x", default="") + assert ( + str(cs.set(Var('_e["target"]["value"] + "!"'))) + == '((_e) => (setX(_e["target"]["value"] + "!")))' + ) + + +def test_underscore_named_var_is_not_mistaken_for_event_arg() -> None: + """A marked identifier is an in-scope binding, never a trigger parameter.""" + private = client_state("_private", default="") + other = client_state("other", default="") + assert str(other.set(private.value)) == "(() => (setOther(_privateRxClientState)))" + + +@pytest.mark.parametrize( + ("value_str", "expected"), + [ + ('_e["target"]["value"]', "_e"), + ("_e", "_e"), + ('_e["a"] + "b"', "_e"), + ("_privateRxClientState", None), + ("valueRxMemo", None), + ("counterRxClientState", None), + ("42", None), + ('"literal"', None), + ], +) +def test_recovered_event_arg(value_str: str, expected: str | None) -> None: + """Event args are recovered; marked in-scope identifiers are not.""" + assert _recovered_event_arg(value_str) == expected + + +@pytest.mark.parametrize( + "reserved", + [ + "class", + "const", + "let", + "var", + "function", + "return", + "new", + "delete", + "default", + "typeof", + "await", + "if", + "for", + ], +) +def test_reserved_words_are_safe(reserved: str) -> None: + """A JS reserved word is a legal name; the marker keeps the codegen valid.""" + cs = client_state(reserved, default=1) + hook = _hook(cs) + assert hook.startswith(f"const [{reserved}RxClientState, ") + # The store key stays the bare word so the backend can still address it. + assert f'"{reserved}"' in hook + assert cs._state_name == reserved + + +def test_camel_case_names_get_distinct_setters() -> None: + """``myVar`` and ``myvar`` must not collapse onto one setter binding.""" + assert client_state("myVar")._setter_name != client_state("myvar")._setter_name + + +def test_var_name_rejects_var() -> None: + """A Var name would only exist at runtime, so it is rejected.""" + with pytest.raises(ValueError, match="not a Var"): + client_state(Var("dynamic")) # pyright: ignore [reportArgumentType] + + +@pytest.mark.parametrize("bad", ["1foo", "my-name", "a b", "", "a.b"]) +def test_var_name_must_be_identifier(bad: str) -> None: + """The name is emitted as a JS identifier, so it has to be one.""" + with pytest.raises(ValueError, match="identifier"): + client_state(bad) + + +def test_generated_names_are_sequential_and_distinct() -> None: + """Unnamed vars get distinct, counter-derived names.""" + names = [client_state()._state_name for _ in range(3)] + assert len(set(names)) == 3 + assert all(name.startswith("cs") for name in names) + numbers = [int(name.removeprefix("cs")) for name in names] + assert numbers == sorted(numbers) + + +def test_generated_names_unaffected_by_unrelated_var_names() -> None: + """An unrelated placeholder draw must not shift the client state sequence.""" + from reflex_base.vars.base import get_unique_variable_name + + before = int(client_state()._state_name.removeprefix("cs")) + get_unique_variable_name() + rx.Var.create([1, 2, 3]).to(list).map(lambda x: x) # pyright: ignore [reportAttributeAccessIssue] + after = int(client_state()._state_name.removeprefix("cs")) + assert after == before + 1 + + +def test_push_builds_wire_event() -> None: + """``push`` sends a first-class client-state event, not an eval'd script.""" + cs = client_state("counter", default=0) + spec = cs.push(5) + assert spec.handler.fn.__qualname__ == "_client_state_set" + assert {str(k): str(v) for k, v in spec.args} == { + "var_name": '"counter"', + "value": "5", + } + + +def test_retrieve_builds_wire_event() -> None: + """``retrieve`` sends a first-class client-state event with a callback slot.""" + cs = client_state("counter", default=0) + args = {str(k): str(v) for k, v in cs.retrieve().args} + assert cs.retrieve().handler.fn.__qualname__ == "_client_state_get" + assert args["var_name"] == '"counter"' + assert "callback" in args + + +def test_global_accessors_render_module_functions() -> None: + """The escape hatch reads and writes through the module-level functions.""" + cs = client_state("counter", default=0) + assert str(cs.global_value) == 'getClientState("counter")' + assert str(cs.global_set) == '((value) => setClientState("counter", value))' + + +def test_global_accessors_carry_no_hook() -> None: + """The escape hatch must work in any scope, so it drags in no hook.""" + cs = client_state("counter", default=0) + for accessor in (cs.global_value, cs.global_set): + var_data = accessor._get_all_var_data() + assert var_data is not None + assert not var_data.hooks + assert f"$/{Dirs.CLIENT_STATE_PATH}" in dict(var_data.imports) + assert _app_wraps(var_data) == [ + (CLIENT_STATE_APP_WRAP_PRIORITY, "ClientStateProvider") + ] + + +@pytest.mark.parametrize( + "accessor", + ["push", "retrieve", "global_value", "global_set"], +) +def test_name_addressed_paths_require_global(accessor: str) -> None: + """An anonymous slot has no name, so nothing can address it.""" + cs = client_state("x", default=0, global_ref=False) + with pytest.raises(ValueError, match="must be global"): + if accessor == "push": + cs.push(1) + elif accessor == "retrieve": + cs.retrieve() + else: + getattr(cs, accessor) + + +def test_set_value_delegates_and_deprecates(capsys: pytest.CaptureFixture) -> None: + """``set_value`` still works, and says to use ``set``.""" + cs = client_state("counter", default=0) + assert str(cs.set_value(42)) == str(cs.set(42)) + assert "set_value" in capsys.readouterr().out + + +def test_var_renders_as_null() -> None: + """The var object itself renders as null so it can sit in a component tree.""" + assert str(client_state("x", default=0)) == "null" + + +def test_acceptance_throttle_controlled_input_compiles() -> None: + """A memo composing local client state, `.set` bare and bound, and chains. + + This is the target ergonomics for the promoted API: two unnamed local vars + in one component, `.set` attached bare to a trigger and called with a memo + prop Var, and both forms mixed in one event-chain list. + """ + + @rx.memo + def debounce_controlled_input( + value: rx.Var[str], + on_change: rx.EventHandler, + debounce_ms: rx.Var[int], + rest: rx.RestProp, + ) -> rx.Component: + lc_var = rx.client_state(global_ref=False) + lc_last_var = rx.client_state(global_ref=False) + return rx.el.input( + rest, + rx.cond( + value != lc_var.value, + rx.fragment(), + ), + rx.fragment( + key=value, + on_mount=[lc_var.set(value), lc_last_var.set(value)], + ), + value=lc_var.value, + on_change=[lc_last_var.set(lc_var.value), lc_var.set], + ) + + component = debounce_controlled_input( + value="hello", on_change=rx.noop(), debounce_ms=200, class_name="x" + ) + assert component.render() + + definition = MEMOS["DebounceControlledInput", __name__] + files, _ = compiler.compile_memo_components((definition,)) + code = "\n".join(c for _, c in files) + + hook_lines = [ + line.strip() for line in code.splitlines() if "useClientState" in line + ] + declarations = [line for line in hook_lines if line.startswith("const [")] + assert len(declarations) == 2, ( + f"expected one hook per local var, got {declarations}" + ) + # Distinct bindings, and neither is registered under a shared store name. + assert len(set(declarations)) == 2 + assert all("useClientState()" in line for line in declarations) + assert 'from "$/utils/client_state"' in code + + +def test_set_binds_memo_prop_var_without_declaring_an_arg() -> None: + """A memo prop is an in-scope binding, so the wrapper takes no parameter.""" + captured: dict[str, rx.Var] = {} + + @rx.memo + def comp(value: rx.Var[str]) -> rx.Component: + captured["value"] = value + return rx.el.input(value=value) + + comp(value="x") + cs = rx.client_state("target", default="") + assert str(cs.set(captured["value"])) == "(() => (setTarget(valueRxMemo)))" + + +def test_set_with_no_argument_is_the_bare_setter() -> None: + """``cs.set()`` is the same forwarding setter as ``cs.set``.""" + cs = client_state("counter", default=0) + assert str(cs.set()) == str(cs.set) == "setCounter" + + +def test_hash_distinguishes_vars() -> None: + """Vars are hashable and distinct names hash differently.""" + a = client_state("a", default=0) + b = client_state("b", default=0) + assert hash(a) != hash(b) + assert len({a, b, a}) == 2 + + +def test_var_name_rejects_non_string() -> None: + """A non-string, non-Var name is rejected.""" + with pytest.raises(ValueError, match="must be a string"): + client_state(5) # pyright: ignore [reportArgumentType] + + +def test_var_default_is_used_directly() -> None: + """A Var default is embedded as-is and sets the var's type.""" + cs = client_state("x", default=Var("someExpr").to(int)) + assert "useClientState(someExpr" in _hook(cs) + assert cs._var_type is int + + +def test_retrieve_with_callback_serializes_the_handler() -> None: + """``retrieve(callback)`` embeds the queued-events callback in the payload.""" + + class RetrieveState(rx.State): + value: str = "" + + def got(self, value: str): + self.value = value + + cs = client_state("counter", default=0) + args = {str(k): str(v) for k, v in cs.retrieve(RetrieveState.got).args} + assert args["var_name"] == '"counter"' + assert "queueEvents" in args["callback"] + assert "got" in args["callback"] From 6225012893f46bafa2eec65a27d55ef2c62a7953 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 01:47:00 +0000 Subject: [PATCH 2/9] fix(client_state): valid codegen with no default, and two runtime fixes 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 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- .../.templates/web/utils/client_state.js | 16 +++---- .../src/reflex_base/client_state.py | 32 ++++++++++++- tests/units/reflex_base/test_client_state.py | 48 ++++++++++++++++++- 3 files changed, 84 insertions(+), 12 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js index ea8465c4a3b..97774354aaa 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js @@ -107,11 +107,14 @@ let _clientStore = null; * The client-side store singleton. * * Shared so that non-React callers and the hooks operate on the same slots - * regardless of mount order. Never used during SSR — `ClientStateProvider` - * builds a per-render store on the server so requests stay isolated. + * regardless of mount order. On the server a fresh store is returned every + * call and never memoized, so no value can leak between requests. * @returns The store. */ export const getClientStore = () => { + if (typeof document === "undefined") { + return createClientStateStore(); + } if (_clientStore === null) { _clientStore = createClientStateStore(); } @@ -151,12 +154,9 @@ export const setClientState = (name, value) => { export function ClientStateProvider({ children }) { const storeRef = useRef(null); if (storeRef.current === null) { - // A per-render store on the server keeps requests isolated; on the client, - // share the singleton so `setClientState` reaches these same slots. - storeRef.current = - typeof document === "undefined" - ? createClientStateStore() - : getClientStore(); + // On the client this is the shared singleton, so `setClientState` and the + // hooks reach the same slots; on the server it is per-render. + storeRef.current = getClientStore(); } const store = storeRef.current; diff --git a/packages/reflex-base/src/reflex_base/client_state.py b/packages/reflex-base/src/reflex_base/client_state.py index d6ea6a5ce16..90fe1a933c1 100644 --- a/packages/reflex-base/src/reflex_base/client_state.py +++ b/packages/reflex-base/src/reflex_base/client_state.py @@ -16,7 +16,13 @@ CAMEL_CASE_MEMO_MARKER, FIELD_MARKER, ) -from reflex_base.event import EventChain, EventHandler, EventSpec, server_side +from reflex_base.event import ( + EventChain, + EventHandler, + EventSpec, + run_script, + server_side, +) from reflex_base.utils import console, format from reflex_base.utils.exceptions import VarTypeError from reflex_base.utils.imports import ImportVar @@ -52,6 +58,17 @@ _VALID_NAME = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*$") +# The store's entry point on the global `refs` object. This is the only binding +# reachable from the scope `run_script` code is evaluated in, and doubles as the +# devtools handle for inspecting client state. Must match CLIENT_STATE_REF in +# `$/utils/client_state`. +_client_state_store_ref = Var( + _js_expr='refs["__client_state"]', + _var_data=VarData( + imports={f"$/{Dirs.STATE_PATH}": [ImportVar(tag="refs")]}, + ), +) + # Reflex marks every identifier it puts in scope; an unmarked `_`-leading name is # an event-arg placeholder from `parse_args_spec`. _IN_SCOPE_MARKERS = ( @@ -268,7 +285,10 @@ def create( ) raise ValueError(msg) if default is NoValue: - default_var = Var(_js_expr="") + # Explicit `undefined` rather than an empty expression: the name is + # passed as a second argument, so an empty first argument would emit + # `useClientState(, "name")` -- a syntax error. + default_var = Var(_js_expr="undefined") elif not isinstance(default, Var): default_var = LiteralVar.create(default) else: @@ -474,6 +494,14 @@ def push(self, value: Any) -> EventSpec: if not self._global_ref: msg = "ClientStateVar must be global to push the value." raise ValueError(msg) + if isinstance(value, Var): + # A Var is a client-side expression, which cannot survive the JSON + # event payload -- it would arrive as its own source text. Evaluate + # it on the client instead, reaching the store through `refs` (the + # only binding in scope where run_script's code is evaluated). + return run_script( + f"{_client_state_store_ref!s}.set({LiteralVar.create(self._state_name)!s}, {value!s})" + ) return server_side( "_client_state_set", inspect.signature(_client_state_set), diff --git a/tests/units/reflex_base/test_client_state.py b/tests/units/reflex_base/test_client_state.py index 29e877b7258..7f4de4ab3b2 100644 --- a/tests/units/reflex_base/test_client_state.py +++ b/tests/units/reflex_base/test_client_state.py @@ -55,6 +55,23 @@ def test_single_hook_no_useState() -> None: assert "refs[" not in hook +@pytest.mark.parametrize("global_ref", [True, False]) +def test_omitted_default_emits_valid_javascript(global_ref: bool) -> None: + """No default must still emit a syntactically valid hook call. + + Regression: an empty default expression rendered as + ``useClientState(, "name")`` once the store name became a second argument, + which is a syntax error that breaks the whole page build. + """ + cs = client_state("counter", global_ref=global_ref) + hook = _hook(cs) + assert "(," not in hook + expected = 'undefined, "counter"' if global_ref else "undefined" + assert ( + hook == f"const [counterRxClientState, setCounter] = useClientState({expected})" + ) + + def test_local_var_omits_store_name() -> None: """A ``global_ref=False`` var gets no name, so its slot stays private.""" cs = client_state("copied", default=False, global_ref=False) @@ -397,9 +414,10 @@ def debounce_controlled_input( assert len(declarations) == 2, ( f"expected one hook per local var, got {declarations}" ) - # Distinct bindings, and neither is registered under a shared store name. + # Distinct bindings, and neither is registered under a shared store name + # (a named var would pass the name as a second, string, argument). assert len(set(declarations)) == 2 - assert all("useClientState()" in line for line in declarations) + assert all("useClientState(undefined)" in line for line in declarations) assert 'from "$/utils/client_state"' in code @@ -458,3 +476,29 @@ def got(self, value: str): assert args["var_name"] == '"counter"' assert "queueEvents" in args["callback"] assert "got" in args["callback"] + + +def test_push_plain_value_uses_json_payload() -> None: + """A concrete value crosses the wire as JSON, not as JS source.""" + from reflex_base.event import fix_events + + cs = client_state("counter", default=0) + event = fix_events([cs.push({"a": 1})], token="tok")[0] + assert event.name.endswith("_client_state_set") + assert event.payload == {"var_name": "counter", "value": {"a": 1}} + + +def test_push_var_is_evaluated_on_the_client() -> None: + """A Var is a client-side expression, so it must not be sent as its text. + + A JSON payload would deliver the literal source (``"Date.now()"``), so a Var + keeps the evaluated path, reaching the store through ``refs``. + """ + from reflex_base.event import fix_events + + cs = client_state("counter", default=0) + event = fix_events([cs.push(Var("Date.now()"))], token="tok")[0] + assert event.name.endswith("_call_function") + assert 'refs["__client_state"].set("counter", Date.now())' in str( + event.payload["function"] + ) From b745638a3d58cfb1529b8af66dc4522238054dce Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 06:59:59 +0000 Subject: [PATCH 3/9] fix(client_state): address review nits on retrieve, provider teardown, 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 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- .../.templates/web/utils/client_state.js | 9 ++++++++- .../reflex_base/.templates/web/utils/state.js | 5 +++-- pyi_hashes.json | 2 +- reflex/__init__.py | 6 +++++- tests/units/experimental/test_client_state.py | 2 ++ tests/units/reflex_base/test_client_state.py | 20 +++++++++++++++++++ 6 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js index 97774354aaa..98ecc4e4060 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js @@ -103,6 +103,11 @@ export const createClientStateStore = () => { let _clientStore = null; +// How many providers are currently mounted. Several can share one store (an +// embedded app rendered alongside a main app), so the `refs` entry must survive +// until the last of them unmounts. +let _mountedProviders = 0; + /** * The client-side store singleton. * @@ -163,8 +168,10 @@ export function ClientStateProvider({ children }) { useEffect(() => { // Client-only, so the server's module-scope `refs` is never written. refs[CLIENT_STATE_REF] = store; + _mountedProviders += 1; return () => { - if (refs[CLIENT_STATE_REF] === store) { + _mountedProviders -= 1; + if (_mountedProviders === 0 && refs[CLIENT_STATE_REF] === store) { delete refs[CLIENT_STATE_REF]; } }; diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index c11a8a58601..edd68efb261 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -395,15 +395,16 @@ export const applyEvent = async (event, socket, navigate, params) => { if (event.name == "_client_state_get") { const store = refs["__client_state"]; if (store === undefined) { + // Still call back, with undefined: the handler awaiting this result would + // otherwise wait for a value that is never coming. console.error( `Cannot read client state "${event.payload.var_name}": no ClientStateProvider is mounted.`, ); - return; } try { await applyResultCallback( event, - store.get(event.payload.var_name), + store?.get(event.payload.var_name), socket, navigate, params, diff --git a/pyi_hashes.json b/pyi_hashes.json index 6730004e3a3..e76695790cd 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -118,7 +118,7 @@ "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "99ebcfc07868061bdc3c2010d85a153f", "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "4f6c26f8c76543cc41e2b9dc400ece8a", "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f170ac685b6ba5892370166c80684db3", - "reflex/__init__.pyi": "630f98a9a6b1c357373ecb33f83194c1", + "reflex/__init__.pyi": "6a1a667017c016e586c3af7f8486f329", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", "reflex/experimental/memo.pyi": "bc8b48357bef580e70a5881b65d3d3f7" } diff --git a/reflex/__init__.py b/reflex/__init__.py index f96755bd1e8..2603a96d60f 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -161,7 +161,11 @@ ], "reflex_components_sonner.toast": ["toast"], "reflex_base.components.props": ["PropsBase"], - "reflex_base.client_state": ["ClientStateVar", "client_state"], + "reflex_base.client_state": [ + "ClientStateSetter", + "ClientStateVar", + "client_state", + ], "reflex_components_core.datadisplay.logo": ["logo"], "reflex_components_gridjs": ["data_table"], "reflex_components_moment": ["MomentDelta", "moment"], diff --git a/tests/units/experimental/test_client_state.py b/tests/units/experimental/test_client_state.py index 0665f10b59a..ddf05b8c6cc 100644 --- a/tests/units/experimental/test_client_state.py +++ b/tests/units/experimental/test_client_state.py @@ -19,3 +19,5 @@ def test_promoted_names_are_reachable_from_rx() -> None: """The lazy-loader wiring only fails at attribute access, so assert it.""" assert rx.client_state("promoted", default=0)._state_name == "promoted" assert isinstance(rx.client_state("typed", default=0), rx.ClientStateVar) + # Exported so `.set` can be named in a type annotation. + assert isinstance(rx.client_state("setter", default=0).set, rx.ClientStateSetter) diff --git a/tests/units/reflex_base/test_client_state.py b/tests/units/reflex_base/test_client_state.py index 7f4de4ab3b2..046a5d7b247 100644 --- a/tests/units/reflex_base/test_client_state.py +++ b/tests/units/reflex_base/test_client_state.py @@ -502,3 +502,23 @@ def test_push_var_is_evaluated_on_the_client() -> None: assert 'refs["__client_state"].set("counter", Date.now())' in str( event.payload["function"] ) + + +def test_retrieve_callback_runs_even_without_a_store() -> None: + """The runtime must call back with undefined rather than never resuming. + + Asserted against the shipped ``state.js`` because a handler awaiting + ``retrieve`` would otherwise hang forever when no provider is mounted. + """ + from pathlib import Path + + import reflex_base + + state_js = ( + Path(reflex_base.__file__).parent / ".templates" / "web" / "utils" / "state.js" + ).read_text() + branch = state_js.split('event.name == "_client_state_get"')[1].split("return;")[0] + assert "applyResultCallback" in branch + # Optional chaining rather than an early return, so a missing store still + # reaches the callback with undefined. + assert "store?.get(" in branch From 047710ef640c0309930ca7e801d316838c793d8e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 07:32:28 +0000 Subject: [PATCH 4/9] test(js): add vitest unit tests for the shipped frontend javascript 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 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- .github/workflows/unit_tests.yml | 20 + .gitignore | 3 + AGENTS.md | 20 + tests/js/client_state.test.js | 323 +++++ tests/js/package-lock.json | 2139 ++++++++++++++++++++++++++++++ tests/js/package.json | 15 + tests/js/stubs/state.js | 8 + tests/js/vitest.config.js | 44 + 8 files changed, 2572 insertions(+) create mode 100644 tests/js/client_state.test.js create mode 100644 tests/js/package-lock.json create mode 100644 tests/js/package.json create mode 100644 tests/js/stubs/state.js create mode 100644 tests/js/vitest.config.js diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index d24923c456c..f2432925f0c 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -82,6 +82,26 @@ jobs: - name: Generate coverage report run: uv run coverage html + js-unit-tests: + # Unit tests for the javascript Reflex ships in + # `reflex-base/.templates/web`, which the python suites can only reach + # through compiled output. + timeout-minutes: 10 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: tests/js/package-lock.json + - run: npm ci + working-directory: tests/js + - run: npm test + working-directory: tests/js + unit-tests-macos: timeout-minutes: 30 if: github.event_name == 'push' && github.ref == 'refs/heads/main' diff --git a/.gitignore b/.gitignore index 533bcfcec8e..17d1903ac6d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ reflex.db .env.* node_modules package-lock.json +# ...except the javascript test harness, which CI installs with `npm ci`. +!tests/js/package-lock.json *.pyi .pre-commit-config.yaml .claude/.worktrees @@ -33,3 +35,4 @@ CLAUDE.local.md # Backups written by scripts/delete_automated_releases.sh automated-releases-backup-*.json +tests/js/node_modules diff --git a/AGENTS.md b/AGENTS.md index eae113a8ffa..bfbe255d4d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ uv run python scripts/check_min_deps.py # validate each uv run python scripts/check_min_deps.py --check-dev-pins [pkg] # publish gate: fail if pkg (default: all) declares an unpublishable *.dev dependency pin uv run python scripts/make_pyi.py # regenerate .pyi stubs uv run pre-commit run --all-files # all pre-commit hooks +npm --prefix tests/js ci && npm --prefix tests/js test # javascript unit tests (frontend templates) ``` ## Layout @@ -31,6 +32,7 @@ uv run pre-commit run --all-files # all pre-commi reflex/ # main framework package (app, state, compiler, components, utils, istate) packages/ # workspace sub-packages (reflex-base, reflex-components-*, reflex-docgen, reflex-components-internal) tests/units/ # unit tests, mirrors source tree +tests/js/ # vitest unit tests for the shipped frontend javascript tests/integration/ # Selenium integration tests (run in dev+prod modes) tests_playwright/ # Playwright integration tests (preferred for new tests) tests/benchmarks/ # performance benchmarks @@ -58,6 +60,24 @@ docs/ # documentation site (separate workspace member) - unit tests should primarily cover a single module, and should be named accordingly, including subdirectories (e.g. `tests/units/istate/test_manager.py` for `reflex/istate/manager.py`). For subpackages, also include the corresponding path below `src/` (e.g. `tests/units/reflex_base/event/test_context.py` for `packages/reflex-base/src/reflex_base/event/context.py`). - **Integration tests:** prefer Playwright (`tests/integration/tests_playwright/`). Integration tests are slow — extend existing test apps rather than creating new ones for trivial functionality. Multiple test cases sharing one app is fine. +### Frontend javascript tests + +The javascript Reflex ships lives in +`packages/reflex-base/src/reflex_base/.templates/web/` and is copied verbatim into a +user's `.web` directory, so tests must **not** live inside that tree. They go in +`tests/js/`, which has its own `package.json` and runs under vitest + jsdom: +`npm --prefix tests/js test`. + +Reach for these when behavior can only be observed at runtime and an integration +test would be indirect or impossible to set up — provider teardown, SSR vs. client +branches, subscription bookkeeping. Prefer a Playwright test when the thing you +want to assert is visible in a real app. + +`$/...` specifiers resolve to the template tree via a vitest alias. `$/utils/state` +is stubbed (`tests/js/stubs/state.js`) because the real module pulls in socket.io, +react-router and the per-app *generated* `utils/context.js`; a module needing those +is not currently unit-testable. + ### Integration test patterns Apps as factory functions, run via `AppHarness`: diff --git a/tests/js/client_state.test.js b/tests/js/client_state.test.js new file mode 100644 index 00000000000..98b0ef3f5ed --- /dev/null +++ b/tests/js/client_state.test.js @@ -0,0 +1,323 @@ +/** + * Unit tests for `utils/client_state.js`. + * + * These cover the parts the Python and Playwright suites structurally cannot: + * teardown when several providers share one store, SSR store isolation, and the + * per-slot subscription behavior the design rests on. + */ +import { readFileSync } from "node:fs"; + +import { act } from "react"; +import { createElement, StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { + CLIENT_STATE_REF, + ClientStateProvider, + createClientStateStore, + getClientState, + getClientStore, + setClientState, + useClientState, +} from "$/utils/client_state"; +import { refs } from "$/utils/state"; + +// React 19 wants this set when driving roots manually. +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +/** Mount a tree into a detached root, returning it and its container. */ +const mount = (element, { strict = false } = {}) => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(strict ? createElement(StrictMode, null, element) : element); + }); + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +}; + +afterEach(() => { + delete refs[CLIENT_STATE_REF]; +}); + +describe("store slots", () => { + test("a named slot is shared and seeded by the first default", () => { + const store = createClientStateStore(); + const first = store.slot("shared", "initial"); + const second = store.slot("shared", "ignored"); + + expect(second).toBe(first); + expect(store.get("shared")).toBe("initial"); + }); + + test("an unnamed slot is private and unaddressable by name", () => { + const store = createClientStateStore(); + const a = store.slot(undefined, "a"); + const b = store.slot(undefined, "b"); + + expect(a).not.toBe(b); + a.set("changed"); + expect(b.getSnapshot()).toBe("b"); + // Anonymous slots are never registered, so nothing can reach them by name. + expect(store.get(undefined)).toBeUndefined(); + }); + + test("writing one var does not notify another var's subscribers", () => { + const store = createClientStateStore(); + const watched = vi.fn(); + const unrelated = vi.fn(); + store.slot("a", 0).subscribe(watched); + store.slot("b", 0).subscribe(unrelated); + + store.set("a", 1); + + expect(watched).toHaveBeenCalledTimes(1); + expect(unrelated).not.toHaveBeenCalled(); + }); + + test("a function value is applied as an updater", () => { + const store = createClientStateStore(); + store.slot("n", 1); + + store.set("n", (previous) => previous + 41); + + expect(store.get("n")).toBe(42); + }); + + test("setting an equal value notifies nobody", () => { + const store = createClientStateStore(); + const listener = vi.fn(); + store.slot("n", 7).subscribe(listener); + + store.set("n", 7); + + expect(listener).not.toHaveBeenCalled(); + expect(store.get("n")).toBe(7); + }); + + test("writing an unknown name creates the slot", () => { + // A value pushed from the backend before any component mounts has to be + // retained, so the component picks it up when it does mount. + const store = createClientStateStore(); + + store.set("later", "pushed early"); + + expect(store.get("later")).toBe("pushed early"); + expect(store.slot("later", "default ignored").getSnapshot()).toBe( + "pushed early", + ); + }); + + test("unsubscribing detaches the listener", () => { + const store = createClientStateStore(); + const listener = vi.fn(); + const unsubscribe = store.slot("n", 0).subscribe(listener); + + unsubscribe(); + store.set("n", 1); + + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe("getClientStore", () => { + test("is a singleton on the client", () => { + expect(getClientStore()).toBe(getClientStore()); + }); + + test("is per-call on the server, so nothing leaks between requests", () => { + const realDocument = globalThis.document; + // The module keys off `typeof document`, which is how it tells SSR apart. + // @ts-expect-error - deleting a global for the duration of the test. + delete globalThis.document; + try { + const first = getClientStore(); + first.set("leaky", "request one"); + + const second = getClientStore(); + + expect(second).not.toBe(first); + expect(second.get("leaky")).toBeUndefined(); + } finally { + globalThis.document = realDocument; + } + }); +}); + +describe("ClientStateProvider", () => { + test("publishes the store on refs while mounted", () => { + expect(refs[CLIENT_STATE_REF]).toBeUndefined(); + + const { unmount } = mount(createElement(ClientStateProvider, null, null)); + + expect(refs[CLIENT_STATE_REF]).toBe(getClientStore()); + + unmount(); + + expect(refs[CLIENT_STATE_REF]).toBeUndefined(); + }); + + test("keeps the refs entry until the last provider unmounts", () => { + // Two app roots on one page (an embedded app beside a main app) share the + // client singleton, so the first teardown must not strand the other. + const first = mount(createElement(ClientStateProvider, null, null)); + const second = mount(createElement(ClientStateProvider, null, null)); + + first.unmount(); + + expect(refs[CLIENT_STATE_REF]).toBe(getClientStore()); + + second.unmount(); + + expect(refs[CLIENT_STATE_REF]).toBeUndefined(); + }); + + test("survives StrictMode's double mount", () => { + const { unmount } = mount(createElement(ClientStateProvider, null, null), { + strict: true, + }); + + expect(refs[CLIENT_STATE_REF]).toBe(getClientStore()); + + unmount(); + + expect(refs[CLIENT_STATE_REF]).toBeUndefined(); + }); +}); + +describe("useClientState", () => { + /** Render `useClientState(default, name)` and report renders and value. */ + const probe = (defaultValue, name, id) => { + const renders = { count: 0, value: undefined, set: undefined }; + const Probe = () => { + const [value, set] = useClientState(defaultValue, name); + renders.count += 1; + renders.value = value; + renders.set = set; + return createElement("span", { id }, String(value)); + }; + return { renders, element: createElement(Probe) }; + }; + + test("shares a named var across components", () => { + const a = probe("initial", "shared", "a"); + const b = probe("initial", "shared", "b"); + const { unmount } = mount( + createElement(ClientStateProvider, null, a.element, b.element), + ); + + act(() => a.renders.set("typed")); + + expect(a.renders.value).toBe("typed"); + expect(b.renders.value).toBe("typed"); + + unmount(); + }); + + test("keeps unnamed vars private to each component", () => { + const a = probe("", undefined, "a"); + const b = probe("", undefined, "b"); + const { unmount } = mount( + createElement(ClientStateProvider, null, a.element, b.element), + ); + + act(() => a.renders.set("mine")); + + expect(a.renders.value).toBe("mine"); + expect(b.renders.value).toBe(""); + + unmount(); + }); + + test("does not re-render a component reading an unrelated var", () => { + // The property the whole store design exists for. + const watched = probe(0, "watched", "watched"); + const unrelated = probe(0, "unrelated", "unrelated"); + const { unmount } = mount( + createElement( + ClientStateProvider, + null, + watched.element, + unrelated.element, + ), + ); + const before = unrelated.renders.count; + + act(() => watched.renders.set(1)); + + expect(watched.renders.value).toBe(1); + expect(unrelated.renders.count).toBe(before); + + unmount(); + }); + + test("a late mount reads the current value, not the default", () => { + const early = probe("default", "late", "early"); + const first = mount( + createElement(ClientStateProvider, null, early.element), + ); + act(() => early.renders.set("current")); + + const late = probe("default", "late", "late"); + const second = mount( + createElement(ClientStateProvider, null, late.element), + ); + + expect(late.renders.value).toBe("current"); + + second.unmount(); + first.unmount(); + }); + + test("accepts a functional updater", () => { + const counter = probe(1, "counter", "counter"); + const { unmount } = mount( + createElement(ClientStateProvider, null, counter.element), + ); + + act(() => counter.renders.set((previous) => previous + 41)); + + expect(counter.renders.value).toBe(42); + + unmount(); + }); +}); + +describe("non-React escape hatch", () => { + test("reads and writes the store the hooks are bound to", () => { + const renders = { count: 0, value: undefined }; + const Probe = () => { + const [value] = useClientState("initial", "escaped"); + renders.count += 1; + renders.value = value; + return null; + }; + const { unmount } = mount( + createElement(ClientStateProvider, null, createElement(Probe)), + ); + + expect(getClientState("escaped")).toBe("initial"); + + act(() => setClientState("escaped", "from plain js")); + + expect(renders.value).toBe("from plain js"); + expect(getClientState("escaped")).toBe("from plain js"); + + unmount(); + }); +}); + +test("CLIENT_STATE_REF matches the key state.js reads", () => { + // The runtime reaches the store through `refs` rather than an import, to + // avoid a cycle, so the key is duplicated and has to stay in sync. + const stateJs = readFileSync(`${__WEB_ROOT__}utils/state.js`, "utf8"); + + expect(stateJs).toContain(`refs["${CLIENT_STATE_REF}"]`); +}); diff --git a/tests/js/package-lock.json b/tests/js/package-lock.json new file mode 100644 index 00000000000..1554d47d4a9 --- /dev/null +++ b/tests/js/package-lock.json @@ -0,0 +1,2139 @@ +{ + "name": "reflex-frontend-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "reflex-frontend-tests", + "devDependencies": { + "jsdom": "^26.1.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "vitest": "^3.2.4" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/tests/js/package.json b/tests/js/package.json new file mode 100644 index 00000000000..fb813dc3294 --- /dev/null +++ b/tests/js/package.json @@ -0,0 +1,15 @@ +{ + "name": "reflex-frontend-tests", + "private": true, + "type": "module", + "description": "Unit tests for the javascript Reflex ships in reflex-base/.templates/web.", + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "jsdom": "^26.1.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "vitest": "^3.2.4" + } +} diff --git a/tests/js/stubs/state.js b/tests/js/stubs/state.js new file mode 100644 index 00000000000..8a8e3e11255 --- /dev/null +++ b/tests/js/stubs/state.js @@ -0,0 +1,8 @@ +/** + * Stand-in for `$/utils/state`, exposing only what the units under test import. + * + * The real module reaches for socket.io, react-router, `$/env.json` and the + * per-app generated `context.js`. `refs` itself is just a bare object there, so + * a stub is faithful as well as convenient. + */ +export const refs = {}; diff --git a/tests/js/vitest.config.js b/tests/js/vitest.config.js new file mode 100644 index 00000000000..c3c789aba81 --- /dev/null +++ b/tests/js/vitest.config.js @@ -0,0 +1,44 @@ +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +import { defineConfig } from "vitest/config"; + +// The units under test live outside this directory, so node resolution from +// them never reaches these node_modules. Point `react` at the copy installed +// here, which also guarantees one React instance across test and subject. +const require = createRequire(import.meta.url); + +// The files under test live in the template tree that `reflex init` copies into +// a user's `.web`. Tests deliberately sit outside it, since everything in there +// is copied verbatim into generated apps. +const webRoot = fileURLToPath( + new URL( + "../../packages/reflex-base/src/reflex_base/.templates/web/", + import.meta.url, + ), +); + +export default defineConfig({ + test: { + environment: "jsdom", + include: ["**/*.test.js"], + }, + // Under jsdom `import.meta.url` is an http:// URL, so tests that need to read + // a source file get the location from here instead. + define: { __WEB_ROOT__: JSON.stringify(webRoot) }, + resolve: { + alias: [ + // `$/utils/state` pulls in socket.io, react-router and the per-app + // generated `context.js`, none of which these units need. Stub the one + // binding they import from it. + { + find: "$/utils/state", + replacement: fileURLToPath( + new URL("./stubs/state.js", import.meta.url), + ), + }, + { find: /^\$\//, replacement: webRoot }, + { find: /^react$/, replacement: require.resolve("react") }, + ], + }, +}); From b7dc16da1c1fbcee5066d09f10ab4257de9b0ff2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 08:03:26 +0000 Subject: [PATCH 5/9] refactor(client_state): pass the registry in, and fix hash and docstring - `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 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- .../.templates/web/utils/client_state.js | 28 ++++++--- .../src/reflex_base/client_state.py | 11 ++-- .../components/client_state_context.py | 23 +++++++- tests/js/client_state.test.js | 59 ++++++++++--------- tests/js/stubs/state.js | 8 --- tests/js/vitest.config.js | 9 --- 6 files changed, 80 insertions(+), 58 deletions(-) delete mode 100644 tests/js/stubs/state.js diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js index 98ecc4e4060..4ece38868c1 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js @@ -19,9 +19,10 @@ import { useSyncExternalStore, } from "react"; -import { refs } from "$/utils/state"; - -/** The single `refs` key holding the live store, for devtools introspection. */ +/** + * Key under which the provider publishes its store on the `registry` object it + * is handed, for backend-evaluated code and devtools introspection. + */ export const CLIENT_STATE_REF = "__client_state"; /** @@ -154,9 +155,13 @@ export const setClientState = (name, value) => { * Provide the client state store to the tree. * @param props The component props. * @param props.children The children to render. + * @param props.registry Optional object to publish the store on, under + * `CLIENT_STATE_REF`, so code running outside the React tree can reach it. + * Passed in by the caller rather than imported, so this module stays + * independent of where that lives. * @returns The provider element. */ -export function ClientStateProvider({ children }) { +export function ClientStateProvider({ children, registry }) { const storeRef = useRef(null); if (storeRef.current === null) { // On the client this is the shared singleton, so `setClientState` and the @@ -166,16 +171,21 @@ export function ClientStateProvider({ children }) { const store = storeRef.current; useEffect(() => { - // Client-only, so the server's module-scope `refs` is never written. - refs[CLIENT_STATE_REF] = store; + if (registry === undefined) { + return undefined; + } + // In an effect, so the store is never published during an SSR render. + registry[CLIENT_STATE_REF] = store; _mountedProviders += 1; return () => { _mountedProviders -= 1; - if (_mountedProviders === 0 && refs[CLIENT_STATE_REF] === store) { - delete refs[CLIENT_STATE_REF]; + // Several providers can share one store, so only the last one out clears + // the entry. + if (_mountedProviders === 0 && registry[CLIENT_STATE_REF] === store) { + delete registry[CLIENT_STATE_REF]; } }; - }, [store]); + }, [store, registry]); return createElement(ClientStateContext.Provider, { value: store }, children); } diff --git a/packages/reflex-base/src/reflex_base/client_state.py b/packages/reflex-base/src/reflex_base/client_state.py index 90fe1a933c1..a5c1d104534 100644 --- a/packages/reflex-base/src/reflex_base/client_state.py +++ b/packages/reflex-base/src/reflex_base/client_state.py @@ -223,6 +223,8 @@ def __hash__(self) -> int: str(self._var_type), self._getter_name, self._setter_name, + self._state_name, + self._global_ref, )) @classmethod @@ -234,10 +236,11 @@ def create( ) -> ClientStateVar: """Create a local_state Var that can be accessed and updated on the client. - The `ClientStateVar` should be included in the highest parent component - that contains the components which will access and manipulate the client - state. It has no visual rendering, including it ensures that the - `useClientState` hook is called in the correct scope. + With ``global_ref`` set (the default) the state is keyed by name in a + store shared across the app, so it can be read and written from any + component and from the backend. Without it the state is anonymous: it is + private to the component the hook is emitted in, and `push`, `retrieve`, + `global_value` and `global_set` cannot address it. To render the var in a component, use the `value` property. diff --git a/packages/reflex-base/src/reflex_base/components/client_state_context.py b/packages/reflex-base/src/reflex_base/components/client_state_context.py index 9e765dbe3d5..6a95a82a708 100644 --- a/packages/reflex-base/src/reflex_base/components/client_state_context.py +++ b/packages/reflex-base/src/reflex_base/components/client_state_context.py @@ -9,14 +9,27 @@ from __future__ import annotations +from typing import Any + from reflex_base.components.component import Component from reflex_base.constants import Dirs +from reflex_base.utils.imports import ImportVar +from reflex_base.vars.base import Var, VarData # Inside ErrorBoundary (55) so a client-state error is caught, outside the # theme/toaster/overlay wraps. It depends on neither StateProvider nor # EventLoopProvider. CLIENT_STATE_APP_WRAP_PRIORITY = 50 +# The global object backend-evaluated code reaches the store through. Passed to +# the provider as a prop rather than imported by ``client_state.js``, so this +# side owns where the store is published and the javascript stays independent +# of it. +refs_var = Var( + _js_expr="refs", + _var_data=VarData(imports={f"$/{Dirs.STATE_PATH}": [ImportVar(tag="refs")]}), +) + class ClientStateContextProvider(Component): """App wrap that mounts the React client-state provider around children.""" @@ -24,6 +37,9 @@ class ClientStateContextProvider(Component): library = f"$/{Dirs.CLIENT_STATE_PATH}" tag = "ClientStateProvider" + # Object the provider publishes its store on, keyed by CLIENT_STATE_REF. + registry: Var[dict[str, Any]] + def get_client_state_app_wraps() -> tuple[tuple[int, Component], ...]: """Build the app-wrap entry advertising the client-state provider. @@ -36,4 +52,9 @@ def get_client_state_app_wraps() -> tuple[tuple[int, Component], ...]: Returns: A single ``(priority, provider)`` entry. """ - return ((CLIENT_STATE_APP_WRAP_PRIORITY, ClientStateContextProvider.create()),) + return ( + ( + CLIENT_STATE_APP_WRAP_PRIORITY, + ClientStateContextProvider.create(registry=refs_var), + ), + ) diff --git a/tests/js/client_state.test.js b/tests/js/client_state.test.js index 98b0ef3f5ed..a8c50fbf657 100644 --- a/tests/js/client_state.test.js +++ b/tests/js/client_state.test.js @@ -21,7 +21,6 @@ import { setClientState, useClientState, } from "$/utils/client_state"; -import { refs } from "$/utils/state"; // React 19 wants this set when driving roots manually. globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -43,8 +42,11 @@ const mount = (element, { strict = false } = {}) => { }; }; -afterEach(() => { - delete refs[CLIENT_STATE_REF]; +/** A stand-in for the global object the app publishes the store on. */ +let registry; + +beforeEach(() => { + registry = {}; }); describe("store slots", () => { @@ -152,43 +154,46 @@ describe("getClientStore", () => { }); describe("ClientStateProvider", () => { - test("publishes the store on refs while mounted", () => { - expect(refs[CLIENT_STATE_REF]).toBeUndefined(); + test("publishes the store on the registry it is given", () => { + expect(registry[CLIENT_STATE_REF]).toBeUndefined(); - const { unmount } = mount(createElement(ClientStateProvider, null, null)); + const { unmount } = mount(createElement(ClientStateProvider, { registry })); - expect(refs[CLIENT_STATE_REF]).toBe(getClientStore()); + expect(registry[CLIENT_STATE_REF]).toBe(getClientStore()); unmount(); - expect(refs[CLIENT_STATE_REF]).toBeUndefined(); + expect(registry[CLIENT_STATE_REF]).toBeUndefined(); }); - test("keeps the refs entry until the last provider unmounts", () => { + test("keeps the entry until the last provider unmounts", () => { // Two app roots on one page (an embedded app beside a main app) share the // client singleton, so the first teardown must not strand the other. - const first = mount(createElement(ClientStateProvider, null, null)); - const second = mount(createElement(ClientStateProvider, null, null)); + const first = mount(createElement(ClientStateProvider, { registry })); + const second = mount(createElement(ClientStateProvider, { registry })); first.unmount(); - expect(refs[CLIENT_STATE_REF]).toBe(getClientStore()); + expect(registry[CLIENT_STATE_REF]).toBe(getClientStore()); second.unmount(); - expect(refs[CLIENT_STATE_REF]).toBeUndefined(); + expect(registry[CLIENT_STATE_REF]).toBeUndefined(); }); test("survives StrictMode's double mount", () => { - const { unmount } = mount(createElement(ClientStateProvider, null, null), { - strict: true, - }); + const { unmount } = mount( + createElement(ClientStateProvider, { registry }), + { + strict: true, + }, + ); - expect(refs[CLIENT_STATE_REF]).toBe(getClientStore()); + expect(registry[CLIENT_STATE_REF]).toBe(getClientStore()); unmount(); - expect(refs[CLIENT_STATE_REF]).toBeUndefined(); + expect(registry[CLIENT_STATE_REF]).toBeUndefined(); }); }); @@ -210,7 +215,7 @@ describe("useClientState", () => { const a = probe("initial", "shared", "a"); const b = probe("initial", "shared", "b"); const { unmount } = mount( - createElement(ClientStateProvider, null, a.element, b.element), + createElement(ClientStateProvider, { registry }, a.element, b.element), ); act(() => a.renders.set("typed")); @@ -225,7 +230,7 @@ describe("useClientState", () => { const a = probe("", undefined, "a"); const b = probe("", undefined, "b"); const { unmount } = mount( - createElement(ClientStateProvider, null, a.element, b.element), + createElement(ClientStateProvider, { registry }, a.element, b.element), ); act(() => a.renders.set("mine")); @@ -243,7 +248,7 @@ describe("useClientState", () => { const { unmount } = mount( createElement( ClientStateProvider, - null, + { registry }, watched.element, unrelated.element, ), @@ -261,13 +266,13 @@ describe("useClientState", () => { test("a late mount reads the current value, not the default", () => { const early = probe("default", "late", "early"); const first = mount( - createElement(ClientStateProvider, null, early.element), + createElement(ClientStateProvider, { registry }, early.element), ); act(() => early.renders.set("current")); const late = probe("default", "late", "late"); const second = mount( - createElement(ClientStateProvider, null, late.element), + createElement(ClientStateProvider, { registry }, late.element), ); expect(late.renders.value).toBe("current"); @@ -279,7 +284,7 @@ describe("useClientState", () => { test("accepts a functional updater", () => { const counter = probe(1, "counter", "counter"); const { unmount } = mount( - createElement(ClientStateProvider, null, counter.element), + createElement(ClientStateProvider, { registry }, counter.element), ); act(() => counter.renders.set((previous) => previous + 41)); @@ -300,7 +305,7 @@ describe("non-React escape hatch", () => { return null; }; const { unmount } = mount( - createElement(ClientStateProvider, null, createElement(Probe)), + createElement(ClientStateProvider, { registry }, createElement(Probe)), ); expect(getClientState("escaped")).toBe("initial"); @@ -315,8 +320,8 @@ describe("non-React escape hatch", () => { }); test("CLIENT_STATE_REF matches the key state.js reads", () => { - // The runtime reaches the store through `refs` rather than an import, to - // avoid a cycle, so the key is duplicated and has to stay in sync. + // The runtime reaches the store through the object it is handed, so the key + // is duplicated on the reading side and has to stay in sync. const stateJs = readFileSync(`${__WEB_ROOT__}utils/state.js`, "utf8"); expect(stateJs).toContain(`refs["${CLIENT_STATE_REF}"]`); diff --git a/tests/js/stubs/state.js b/tests/js/stubs/state.js deleted file mode 100644 index 8a8e3e11255..00000000000 --- a/tests/js/stubs/state.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Stand-in for `$/utils/state`, exposing only what the units under test import. - * - * The real module reaches for socket.io, react-router, `$/env.json` and the - * per-app generated `context.js`. `refs` itself is just a bare object there, so - * a stub is faithful as well as convenient. - */ -export const refs = {}; diff --git a/tests/js/vitest.config.js b/tests/js/vitest.config.js index c3c789aba81..0c352b92848 100644 --- a/tests/js/vitest.config.js +++ b/tests/js/vitest.config.js @@ -28,15 +28,6 @@ export default defineConfig({ define: { __WEB_ROOT__: JSON.stringify(webRoot) }, resolve: { alias: [ - // `$/utils/state` pulls in socket.io, react-router and the per-app - // generated `context.js`, none of which these units need. Stub the one - // binding they import from it. - { - find: "$/utils/state", - replacement: fileURLToPath( - new URL("./stubs/state.js", import.meta.url), - ), - }, { find: /^\$\//, replacement: webRoot }, { find: /^react$/, replacement: require.resolve("react") }, ], From 7d80c8faf384723daaa7e59fd13d920227082d0e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 12:19:36 +0000 Subject: [PATCH 6/9] feat(client_state): scope client state by name down the component tree 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 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- .../pages/integrations/integration_gallery.py | 2 +- .../reflex_docs/templates/docpage/docpage.py | 2 +- docs/library/data-display/icon.md | 2 +- docs/wrapping-react/overview.md | 4 +- .../.templates/web/utils/client_state.js | 224 ++++++++++----- .../src/reflex_base/client_state.py | 94 +++++-- .../components/client_state_context.py | 28 ++ .../src/reflex_base/components/memo.py | 6 + .../blocks/demo_form.py | 4 +- .../blocks/intro_form.py | 6 +- reflex/compiler/utils.py | 10 + reflex/experimental/__init__.py | 3 +- reflex/experimental/client_state.py | 47 +++- .../tests_playwright/test_client_state.py | 11 +- tests/js/client_state.test.js | 261 ++++++++++++++++-- tests/units/compiler/test_memoize_plugin.py | 112 +++++++- tests/units/experimental/test_client_state.py | 38 ++- tests/units/reflex_base/test_client_state.py | 189 +++++++++---- 18 files changed, 832 insertions(+), 211 deletions(-) diff --git a/docs/app/reflex_docs/pages/integrations/integration_gallery.py b/docs/app/reflex_docs/pages/integrations/integration_gallery.py index 2fdaab3eaf3..17c9ccd365e 100644 --- a/docs/app/reflex_docs/pages/integrations/integration_gallery.py +++ b/docs/app/reflex_docs/pages/integrations/integration_gallery.py @@ -5,7 +5,7 @@ from .integration_list import get_integration_path from .integration_request import request_integration_dialog -selected_filter = rx.client_state("selected_filter", "All") +selected_filter = rx.client_state("All", name="selected_filter") FilterOptions = [ {"name": "AI", "icon": "BotIcon"}, diff --git a/docs/app/reflex_docs/templates/docpage/docpage.py b/docs/app/reflex_docs/templates/docpage/docpage.py index de0398a5f7d..02b6b6719a3 100644 --- a/docs/app/reflex_docs/templates/docpage/docpage.py +++ b/docs/app/reflex_docs/templates/docpage/docpage.py @@ -85,7 +85,7 @@ def feedback_button_toc() -> rx.Component: @rx.memo def copy_to_markdown(text: rx.Var[str]) -> rx.Component: - copied = rx.client_state("is_copied", default=False, global_ref=False) + copied = rx.client_state(False) return marketing_button( rx.cond( copied.value, diff --git a/docs/library/data-display/icon.md b/docs/library/data-display/icon.md index cd4efa6e678..7b6aedad345 100644 --- a/docs/library/data-display/icon.md +++ b/docs/library/data-display/icon.md @@ -8,7 +8,7 @@ import reflex as rx from reflex_components_lucide.icon import LUCIDE_ICON_LIST -icon_search_cs = rx.client_state("icon_search", default="") +icon_search_cs = rx.client_state("", name="icon_search") @rx.memo diff --git a/docs/wrapping-react/overview.md b/docs/wrapping-react/overview.md index c23f7b99b44..6bbcad55118 100644 --- a/docs/wrapping-react/overview.md +++ b/docs/wrapping-react/overview.md @@ -70,7 +70,7 @@ class ColorPicker(NoSSRComponent): color_picker = ColorPicker.create -ColorPickerState = rx.client_state(default="#db114b", var_name="color") +ColorPickerState = rx.client_state("#db114b", name="color") ``` ```python eval @@ -130,7 +130,7 @@ library that hands you a plain JavaScript callback -- or you are writing your ow they work anywhere in your compiled page: ```python -picker_color = rx.client_state("picker_color", default="#db114b") +picker_color = rx.client_state("#db114b", name="picker_color") class MyPicker(rx.Component): diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js index 4ece38868c1..552e8b23278 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js @@ -1,14 +1,20 @@ /** - * Client-only state, shared by name across components without a backend rx.State. + * Client-only state, scoped by name down the component tree. * * `useClientState` is the only thing compiled components call. Everything else - * here is the bookkeeping it needs: a store of independently-subscribable slots, - * the context that delivers it, and a module-level door for JS that runs outside - * the React tree (see `getClientState` / `setClientState`). + * here is the bookkeeping it needs: independently-subscribable slots, the scope + * chain that decides which slot a name resolves to, and a module-level door for + * JS that runs outside the React tree (`getClientState` / `setClientState`). + * + * Scoping: 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, so + * separate instances of a boundary get separate state while everything under one + * boundary shares. Compiler-inserted `ClientStateScope` elements create the + * boundaries; boundaries that only exist as a compiler optimization do not, so + * splitting a subtree across memo modules is semantically invisible. * * Each slot owns its own listener set, so writing one var only re-renders the - * components subscribed to *that* var. The context value is the store object - * itself and never changes identity, so mounting the provider never cascades. + * components subscribed to *that* var. */ import { createContext, @@ -26,9 +32,9 @@ import { export const CLIENT_STATE_REF = "__client_state"; /** - * Create a slot: one named (or anonymous) piece of client state. + * Create a slot: one piece of client state, with its own subscribers. * @param value The initial value. - * @returns A slot with its own listener set. + * @returns The slot. */ const createSlot = (value) => { const listeners = new Set(); @@ -54,61 +60,91 @@ const createSlot = (value) => { }; /** - * Create a store of client state slots. - * @returns The store. + * Create a scope: a node in the ownership chain. + * @param parent The enclosing scope, or null for a root. + * @returns The scope. */ -export const createClientStateStore = () => { - const slots = new Map(); - - /** - * Get the slot for `name`, creating it if absent. - * @param name The slot name. - * @param defaultValue Initial value, used only when creating the slot. - * @returns The named slot. - */ - const namedSlot = (name, defaultValue) => { - let slot = slots.get(name); - if (slot === undefined) { - slot = createSlot(defaultValue); - slots.set(name, slot); - } - return slot; +const createScope = (parent) => { + const owned = new Map(); + const scope = { + parent, + owned, + /** + * Claim `name` in this scope, or return the slot already claimed here. + * + * Get-or-create, so a double invocation under StrictMode or a re-entrant + * render converges on one slot rather than replacing it. + * @param name The client state name. + * @param defaultValue Initial value, used only when claiming. + * @returns The slot this scope owns for `name`. + */ + own: (name, defaultValue) => { + let slot = owned.get(name); + if (slot === undefined) { + slot = createSlot(defaultValue); + owned.set(name, slot); + } + return slot; + }, + /** + * Find the slot an ancestor (or this scope) already owns for `name`. + * @param name The client state name. + * @returns The slot, or undefined when nothing in the chain owns it. + */ + find: (name) => { + for (let current = scope; current !== null; current = current.parent) { + const found = current.owned.get(name); + if (found !== undefined) { + return found; + } + } + return undefined; + }, }; + return scope; +}; + +/** + * Walk to the root of a scope chain. + * @param scope Any scope in the chain. + * @returns The root scope. + */ +const rootOf = (scope) => { + let current = scope; + while (current.parent !== null) { + current = current.parent; + } + return current; +}; +/** + * Create a store: the root scope, plus the by-name access the backend uses. + * @returns The store. + */ +export const createClientStateStore = () => { + const root = createScope(null); return { + root, /** - * Resolve the slot a `useClientState` call should bind to. - * @param name The shared name, or a falsy value for a private slot. - * @param defaultValue The initial value. - * @returns A shared slot when named, else a fresh anonymous one. - */ - slot: (name, defaultValue) => - name ? namedSlot(name, defaultValue) : createSlot(defaultValue), - /** - * Read a named slot's current value. - * @param name The slot name. - * @returns The value, or undefined if the slot does not exist yet. + * Read a name from the root scope. + * @param name The client state name. + * @returns The value, or undefined if nothing owns the name yet. */ - get: (name) => slots.get(name)?.value, + get: (name) => root.owned.get(name)?.value, /** - * Write a named slot, creating it if it does not exist yet, so a value - * pushed before any component mounts is picked up on mount. - * @param name The slot name. + * Write a name in the root scope, claiming it if needed, so a value pushed + * before any component mounts is picked up on mount. + * @param name The client state name. * @param value The value, or an updater function. */ set: (name, value) => { - namedSlot(name, undefined).set(value); + root.own(name, undefined).set(value); }, }; }; let _clientStore = null; -// How many providers are currently mounted. Several can share one store (an -// embedded app rendered alongside a main app), so the `refs` entry must survive -// until the last of them unmounts. -let _mountedProviders = 0; - /** * The client-side store singleton. * @@ -127,20 +163,22 @@ export const getClientStore = () => { return _clientStore; }; -export const ClientStateContext = createContext(null); +/** The nearest owning scope. Null outside any provider. */ +export const ClientStateScopeContext = createContext(null); /** - * Read a named client state var from outside the React tree. + * Read a globally-named client state var from outside the React tree. * * A point-in-time snapshot with no reactivity; prefer the value returned by - * `useClientState` inside components. + * `useClientState` inside components. Only names declared as global resolve + * here — tree-scoped vars are deliberately unreachable from outside their tree. * @param name The client state var name. * @returns The current value. */ export const getClientState = (name) => getClientStore().get(name); /** - * Write a named client state var from outside the React tree. + * Write a globally-named client state var from outside the React tree. * * Every subscribed component re-renders. Use this to drive client state from * third-party library callbacks or other non-React JS. @@ -151,8 +189,10 @@ export const setClientState = (name, value) => { getClientStore().set(name, value); }; +let _mountedProviders = 0; + /** - * Provide the client state store to the tree. + * Provide the root scope to the tree. * @param props The component props. * @param props.children The children to render. * @param props.registry Optional object to publish the store on, under @@ -187,24 +227,84 @@ export function ClientStateProvider({ children, registry }) { }; }, [store, registry]); - return createElement(ClientStateContext.Provider, { value: store }, children); + return createElement( + ClientStateScopeContext.Provider, + { value: store.root }, + children, + ); +} + +/** + * Open a client state scope around a subtree. + * + * Emitted by the compiler at component-instance boundaries. Names first used + * inside are owned here, so each mounted instance gets its own state and its + * descendants share it. + * @param props The component props. + * @param props.children The children to render. + * @returns The provider element. + */ +export function ClientStateScope({ children }) { + const parent = useContext(ClientStateScopeContext); + const scopeRef = useRef(null); + if (scopeRef.current === null || scopeRef.current.parent !== parent) { + scopeRef.current = createScope(parent ?? getClientStore().root); + } + return createElement( + ClientStateScopeContext.Provider, + { value: scopeRef.current }, + children, + ); } +/** + * Wrap a component so each mounted instance gets its own client state scope. + * + * The scope must sit *above* the component, not inside what it returns: a + * component's hooks run before the elements it returns are mounted, so a + * provider in its own output would leave its own `useClientState` calls + * resolving against the enclosing scope and sharing state across instances. + * + * The compiler applies this to memo definitions that are real component + * instance boundaries, leaving optimizer-generated ones untouched so they stay + * semantically invisible. + * @param Component The component to wrap. + * @returns The wrapped component. + */ +export const withClientStateScope = (Component) => { + const Wrapped = (props) => + createElement(ClientStateScope, null, createElement(Component, props)); + Wrapped.displayName = `withClientStateScope(${ + Component.displayName ?? Component.name ?? "Component" + })`; + return Wrapped; +}; + /** * Subscribe to a piece of client state. * @param defaultValue The initial value. - * @param name Shared name, or omitted for state private to this component. + * @param name The name identifying this var. Compiler-generated when the caller + * did not choose one, and always a compile-time constant. + * @param isGlobal When true the name resolves in the root scope, ignoring any + * enclosing boundary, so it is shared app-wide and reachable from the backend. * @returns A `[value, setValue]` pair, like `useState`. */ -export function useClientState(defaultValue, name) { - const store = useContext(ClientStateContext) ?? getClientStore(); - const slotRef = useRef(null); - if (slotRef.current === null) { - // `name` is a compile-time constant per call site, so the slot a mounted - // hook is bound to can never change. - slotRef.current = store.slot(name, defaultValue); +export function useClientState(defaultValue, name, isGlobal) { + const contextScope = useContext(ClientStateScopeContext); + const nearest = contextScope ?? getClientStore().root; + const scope = isGlobal ? rootOf(nearest) : nearest; + + const bindingRef = useRef(null); + if (bindingRef.current === null || bindingRef.current.scope !== scope) { + // Re-resolve when the scope identity changes: binding once would strand a + // mounted hook on a slot from a scope that no longer applies. + bindingRef.current = { + scope, + slot: scope.find(name) ?? scope.own(name, defaultValue), + }; } - const slot = slotRef.current; + const { slot } = bindingRef.current; + const value = useSyncExternalStore( slot.subscribe, slot.getSnapshot, diff --git a/packages/reflex-base/src/reflex_base/client_state.py b/packages/reflex-base/src/reflex_base/client_state.py index a5c1d104534..6400fbcde5d 100644 --- a/packages/reflex-base/src/reflex_base/client_state.py +++ b/packages/reflex-base/src/reflex_base/client_state.py @@ -56,6 +56,16 @@ # generated var-name sequence. _placeholder_counter = itertools.count() +# Raised for every path that addresses a var by name from outside its tree. +_NOT_GLOBAL_MSG = ( + "Cannot {action}: this client state var is scoped to the component tree " + 'that uses it. Give it a name -- rx.client_state("my_name") -- to make it ' + "global and addressable." +) + +# Default prefix for generated names. +_DEFAULT_NAME_PREFIX = "cs" + _VALID_NAME = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*$") # The store's entry point on the global `refs` object. This is the only binding @@ -206,8 +216,9 @@ class ClientStateVar(Var): # The bare name keying this var in the client state store. _state_name: str = dataclasses.field(default="") - # Whether the state is shared by name (and reachable from the backend). - _global_ref: bool = dataclasses.field(default=True) + # Whether the name resolves in the app-wide root scope (and is therefore + # reachable from the backend) rather than being owned by a component tree. + _is_global: bool = dataclasses.field(default=True) # VarData without the hook, for accessors that work in any JS scope. _escape_var_data: VarData | None = dataclasses.field(default=None) @@ -224,23 +235,28 @@ def __hash__(self) -> int: self._getter_name, self._setter_name, self._state_name, - self._global_ref, + self._is_global, )) @classmethod def create( cls, - var_name: str | None = None, default: Any = NoValue, - global_ref: bool = True, + *, + name: str | None = None, + prefix: str = _DEFAULT_NAME_PREFIX, ) -> ClientStateVar: - """Create a local_state Var that can be accessed and updated on the client. + """Create a client state Var that can be accessed and updated on the client. - With ``global_ref`` set (the default) the state is keyed by name in a - store shared across the app, so it can be read and written from any - component and from the backend. Without it the state is anonymous: it is - private to the component the hook is emitted in, and `push`, `retrieve`, - `global_value` and `global_set` cannot address it. + Whether the state is shared app-wide follows from whether you name it: + + - ``rx.client_state("my_name")`` is **global**. It resolves in one + app-wide store, so any component and the backend can read and write + it, and `push`, `retrieve`, `global_value` and `global_set` work. + - ``rx.client_state()`` is **tree-scoped**. It gets a compile-time name + and the first component to use it claims it for its descendants, so + each instance of that component gets its own state -- like React's + ``useState`` -- and nothing outside the tree can address it. To render the var in a component, use the `value` property. @@ -258,32 +274,46 @@ def create( `global_value` and `global_set` properties. Args: - var_name: The name of the variable. default: The default value of the variable. - global_ref: Whether the state should be accessible in any Component and on the backend. + name: Optional name. Naming the var makes it global. + prefix: Prefix for the generated name when the var is unnamed, to + keep the compiled javascript readable. Ignored when ``name`` is + given. Returns: ClientStateVar Raises: - ValueError: If var_name is not a valid identifier string. + ValueError: If name or prefix is not a valid identifier string. """ - if var_name is None: - var_name = f"cs{next(_name_counter)}" + # Named -> global, unnamed -> tree-scoped. + is_global = name is not None + if name is None: + if not _VALID_NAME.match(prefix): + msg = ( + f"prefix {prefix!r} is not a valid javascript identifier; it " + "is emitted as one in the compiled app." + ) + raise ValueError(msg) + # One shared counter across every prefix, so a generated name is + # unique no matter what prefixes are in play. + var_name = f"{prefix}{next(_name_counter)}" + else: + var_name = name if isinstance(var_name, Var): msg = ( - "var_name must be a string, not a Var. The name keys the client " + "name must be a string, not a Var. The name keys the client " "state store and is embedded in the events that `push`, " "`retrieve` and `global_set` send, so it has to be known at " "compile time." ) raise ValueError(msg) if not isinstance(var_name, str): - msg = "var_name must be a string." + msg = "name must be a string." raise ValueError(msg) if not _VALID_NAME.match(var_name): msg = ( - f"var_name {var_name!r} is not a valid javascript identifier; it " + f"name {var_name!r} is not a valid javascript identifier; it " "is emitted as one in the compiled app." ) raise ValueError(msg) @@ -300,9 +330,13 @@ def create( # word; the store key stays the bare name. getter_name = f"{var_name}{CAMEL_CASE_CLIENT_STATE_MARKER}" setter_name = f"set{var_name[0].upper()}{var_name[1:]}" - name_arg = f", {LiteralVar.create(var_name)!s}" if global_ref else "" + # The name is always passed: it identifies the slot within whichever + # scope owns it. The trailing flag is what escapes to the root scope. + args = f"{default_var!s}, {LiteralVar.create(var_name)!s}" + if is_global: + args += ", true" hooks: dict[str, VarData | None] = { - f"const [{getter_name}, {setter_name}] = useClientState({default_var!s}{name_arg})": None, + f"const [{getter_name}, {setter_name}] = useClientState({args})": None, } app_wraps = get_client_state_app_wraps() return cls( @@ -310,7 +344,7 @@ def create( _setter_name=setter_name, _getter_name=getter_name, _state_name=var_name, - _global_ref=global_ref, + _is_global=is_global, _var_type=default_var._var_type, _var_data=VarData.merge( default_var._var_data, @@ -414,8 +448,8 @@ def global_value(self) -> Var: Raises: ValueError: If the ClientStateVar is not global. """ - if not self._global_ref: - msg = "ClientStateVar must be global to read the value from any scope." + if not self._is_global: + msg = _NOT_GLOBAL_MSG.format(action="read the value from outside the tree") raise ValueError(msg) return Var( _js_expr=f"getClientState({LiteralVar.create(self._state_name)!s})", @@ -436,8 +470,8 @@ def global_set(self) -> Var: Raises: ValueError: If the ClientStateVar is not global. """ - if not self._global_ref: - msg = "ClientStateVar must be global to set the value from any scope." + if not self._is_global: + msg = _NOT_GLOBAL_MSG.format(action="set the value from outside the tree") raise ValueError(msg) return Var( _js_expr=( @@ -460,8 +494,8 @@ def retrieve(self, callback: EventHandler | Callable | None = None) -> EventSpec Raises: ValueError: If the ClientStateVar is not global. """ - if not self._global_ref: - msg = "ClientStateVar must be global to retrieve the value." + if not self._is_global: + msg = _NOT_GLOBAL_MSG.format(action="retrieve the value") raise ValueError(msg) callback_kwargs = {"callback": None} if callback is not None: @@ -494,8 +528,8 @@ def push(self, value: Any) -> EventSpec: Raises: ValueError: If the ClientStateVar is not global. """ - if not self._global_ref: - msg = "ClientStateVar must be global to push the value." + if not self._is_global: + msg = _NOT_GLOBAL_MSG.format(action="push a value") raise ValueError(msg) if isinstance(value, Var): # A Var is a client-side expression, which cannot survive the JSON diff --git a/packages/reflex-base/src/reflex_base/components/client_state_context.py b/packages/reflex-base/src/reflex_base/components/client_state_context.py index 6a95a82a708..2c0e037a6e1 100644 --- a/packages/reflex-base/src/reflex_base/components/client_state_context.py +++ b/packages/reflex-base/src/reflex_base/components/client_state_context.py @@ -15,6 +15,7 @@ from reflex_base.constants import Dirs from reflex_base.utils.imports import ImportVar from reflex_base.vars.base import Var, VarData +from reflex_base.vars.function import FunctionVar # Inside ErrorBoundary (55) so a client-state error is caught, outside the # theme/toaster/overlay wraps. It depends on neither StateProvider nor @@ -31,6 +32,33 @@ ) +def scoped_memo_wrapper(inner: Var | None) -> Var: + """Compose a memo wrapper that also opens a client state scope. + + The scope has to sit *above* the component function: a component's hooks run + before the elements it returns are mounted, so a provider inside its own + output would leave its own ``useClientState`` calls resolving against the + enclosing scope and sharing state across instances. + + Args: + inner: The wrapper the definition would otherwise use, if any. + + Returns: + A function Var suitable for ``MemoComponentDefinition.wrapper``. + """ + scope_import = VarData( + imports={f"$/{Dirs.CLIENT_STATE_PATH}": [ImportVar(tag="withClientStateScope")]} + ) + if inner is None: + return Var(_js_expr="withClientStateScope", _var_data=scope_import).to( + FunctionVar + ) + return Var( + _js_expr=f"((Component) => withClientStateScope({inner!s}(Component)))", + _var_data=VarData.merge(scope_import, inner._get_all_var_data()), + ).to(FunctionVar) + + class ClientStateContextProvider(Component): """App wrap that mounts the React client-state provider around children.""" diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index 8c0d1e9d98a..8b0dba8cb56 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -324,6 +324,11 @@ class MemoComponentDefinition(MemoDefinition): # wrapper's ``VarData`` supplies its imports, so a custom wrapper brings # its own and ``None`` pulls in nothing. wrapper: Var | None = DEFAULT_MEMO_WRAPPER + # Whether each render of this memo is a distinct component instance from + # the user's point of view. True only for ``@rx.memo``; the auto-memoize + # optimizer's wrappers leave it False so they stay semantically invisible + # -- notably to client state, which opens a scope per instance boundary. + is_instance_boundary: bool = False @property def component(self) -> Component: @@ -2019,6 +2024,7 @@ def _memo_impl( ), _runtime_inferred_params=frozenset(missing_params), wrapper=wrapper, + is_instance_boundary=True, ) memo_callable = _create_component_wrapper(definition) else: diff --git a/packages/reflex-components-internal/src/reflex_components_internal/blocks/demo_form.py b/packages/reflex-components-internal/src/reflex_components_internal/blocks/demo_form.py index f00d638cc15..f0eced6a75e 100644 --- a/packages/reflex-components-internal/src/reflex_components_internal/blocks/demo_form.py +++ b/packages/reflex-components-internal/src/reflex_components_internal/blocks/demo_form.py @@ -21,8 +21,8 @@ from reflex_components_internal.components.icons.others import select_arrow from reflex_components_internal.utils.twmerge import cn -demo_form_error_message = rx.client_state("demo_form_error_message", "") -demo_form_open_cs = rx.client_state("demo_form_open", False) +demo_form_error_message = rx.client_state("", name="demo_form_error_message") +demo_form_open_cs = rx.client_state(False, name="demo_form_open") PERSONAL_EMAIL_PROVIDERS = r"^(?!.*@(gmail|outlook|hotmail|yahoo|icloud|aol|protonmail|mail|yandex|zoho|live|msn|me|mac|googlemail)\.com$|.*@(yahoo|outlook|hotmail)\.co\.uk$|.*@yahoo\.ca$|.*@yahoo\.co\.in$|.*@proton\.me$).*$" diff --git a/packages/reflex-components-internal/src/reflex_components_internal/blocks/intro_form.py b/packages/reflex-components-internal/src/reflex_components_internal/blocks/intro_form.py index 09a848d4dd8..8bb9631f697 100644 --- a/packages/reflex-components-internal/src/reflex_components_internal/blocks/intro_form.py +++ b/packages/reflex-components-internal/src/reflex_components_internal/blocks/intro_form.py @@ -19,9 +19,9 @@ from reflex_components_internal.components.icons.others import select_arrow from reflex_components_internal.utils.twmerge import cn -intro_form_error_message = rx.client_state("intro_form_error_message", "") -intro_form_open_cs = rx.client_state("intro_form_open", False) -is_submitting_intro_form_cs = rx.client_state("is_submitting_intro_form", False) +intro_form_error_message = rx.client_state("", name="intro_form_error_message") +intro_form_open_cs = rx.client_state(False, name="intro_form_open") +is_submitting_intro_form_cs = rx.client_state(False, name="is_submitting_intro_form") PERSONAL_EMAIL_PROVIDERS = r"^(?!.*@(gmail|outlook|hotmail|yahoo|icloud|aol|protonmail|mail|yandex|zoho|live|msn|me|mac|googlemail)\.com$|.*@(yahoo|outlook|hotmail)\.co\.uk$|.*@yahoo\.ca$|.*@yahoo\.co\.in$|.*@proton\.me$).*$" diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 216b5d4bed6..110bb6362ee 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -17,6 +17,7 @@ from urllib.parse import urlparse from reflex_base import constants +from reflex_base.components.client_state_context import scoped_memo_wrapper from reflex_base.components.component import Component, ComponentStyle from reflex_base.components.memo import ( MemoComponentDefinition, @@ -434,6 +435,15 @@ def compile_experimental_component_memo( # var itself, so a custom wrapper brings its own imports and ``None`` # pulls in nothing. wrapper = definition.wrapper + if ( + definition.is_instance_boundary + and f"$/{constants.Dirs.CLIENT_STATE_PATH}" in imports + ): + # This memo is a real component instance boundary and its body uses + # client state, so wrap it in a client state scope: names it declares are + # owned per instance, and its descendants resolve to the same slots. + # Gated on actual usage so pages don't pay a provider per memo. + wrapper = scoped_memo_wrapper(wrapper) if wrapper is not None and (wrapper_var_data := wrapper._get_all_var_data()): for lib, fields in wrapper_var_data.imports: imports.setdefault(lib, []).extend(fields) diff --git a/reflex/experimental/__init__.py b/reflex/experimental/__init__.py index ffb49c16d0e..e65c172a59b 100644 --- a/reflex/experimental/__init__.py +++ b/reflex/experimental/__init__.py @@ -12,6 +12,7 @@ from . import hooks as hooks from .client_state import ClientStateVar as ClientStateVar +from .client_state import client_state as _legacy_client_state logger = logging.getLogger(__name__) @@ -71,7 +72,7 @@ def register_component_warning(component_name: str): _x = ExperimentalNamespace( - client_state=ClientStateVar.create, + client_state=_legacy_client_state, hooks=hooks, code_block=code_block, hybrid_property=hybrid_property, diff --git a/reflex/experimental/client_state.py b/reflex/experimental/client_state.py index da4b7d501e5..0831092f0df 100644 --- a/reflex/experimental/client_state.py +++ b/reflex/experimental/client_state.py @@ -1,14 +1,51 @@ -"""Handle client side state with `useClientState`. +"""Deprecated `ClientStateVar` entry point. -Deprecated location. The implementation moved to -:mod:`reflex_base.client_state` and is exposed as ``rx.client_state``; this -module re-exports it so existing imports keep working. +The implementation moved to :mod:`reflex_base.client_state` and is exposed as +``rx.client_state``, whose signature is `client_state(default, *, name=None)`. +This module keeps the original signature working and is where the deprecation +notices live, so the new API carries none of them. """ from __future__ import annotations +from typing import Any + from reflex_base.client_state import ClientStateVar as ClientStateVar from reflex_base.client_state import NoValue as NoValue -from reflex_base.client_state import client_state as client_state +from reflex_base.utils import console __all__ = ["ClientStateVar", "NoValue", "client_state"] + + +def client_state( + var_name: str | None = None, + default: Any = NoValue, + global_ref: bool | Any = NoValue, +) -> ClientStateVar: + """Create a client state var using the original argument order. + + Args: + var_name: The name of the variable. Naming it makes the var global. + default: The default value of the variable. + global_ref: Formerly selected whether the state was app-wide. Scoping now + follows from whether the var is named, so this is only honored to + keep existing callers behaving as they did. + + Returns: + The client state var. + """ + console.deprecate( + feature_name="rx._x.client_state", + reason=( + "Use rx.client_state(default, name=...) instead. Naming a var makes " + "it global; an unnamed var is scoped to the component tree that " + "first uses it, so `global_ref` is no longer needed." + ), + deprecation_version="0.9.9", + removal_version="1.0", + ) + # `global_ref=False` meant "anonymous": the name was never a store key, so + # dropping it reproduces that exactly under the new scoping rules. + if global_ref is not NoValue and not global_ref: + var_name = None + return ClientStateVar.create(default=default, name=var_name) diff --git a/tests/integration/tests_playwright/test_client_state.py b/tests/integration/tests_playwright/test_client_state.py index 44878043f83..fe0b300c317 100644 --- a/tests/integration/tests_playwright/test_client_state.py +++ b/tests/integration/tests_playwright/test_client_state.py @@ -21,9 +21,9 @@ def ClientStateApp(): import reflex as rx - shared = rx.client_state("shared", default="initial") - counter = rx.client_state("counter", default=0) - other = rx.client_state("other", default="untouched") + shared = rx.client_state("initial", name="shared") + counter = rx.client_state(0, name="counter") + other = rx.client_state("untouched", name="other") class ClientStateAppState(rx.State): retrieved: str = "" @@ -42,8 +42,9 @@ def got_value(self, value: str): @rx.memo def local_input(label: rx.Var[str]) -> rx.Component: - # global_ref=False: each rendered instance owns a private slot. - local = rx.client_state(global_ref=False, default="") + # Unnamed: constructed inside the component, so each rendered instance + # owns its own slot. + local = rx.client_state("") return rx.hstack( rx.input( value=local.value, diff --git a/tests/js/client_state.test.js b/tests/js/client_state.test.js index a8c50fbf657..881dc33f359 100644 --- a/tests/js/client_state.test.js +++ b/tests/js/client_state.test.js @@ -15,11 +15,13 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { CLIENT_STATE_REF, ClientStateProvider, + ClientStateScope, createClientStateStore, getClientState, getClientStore, setClientState, useClientState, + withClientStateScope, } from "$/utils/client_state"; // React 19 wants this set when driving roots manually. @@ -50,33 +52,31 @@ beforeEach(() => { }); describe("store slots", () => { - test("a named slot is shared and seeded by the first default", () => { + test("a name is claimed once and seeded by the first default", () => { const store = createClientStateStore(); - const first = store.slot("shared", "initial"); - const second = store.slot("shared", "ignored"); + const first = store.root.own("shared", "initial"); + const second = store.root.own("shared", "ignored"); expect(second).toBe(first); expect(store.get("shared")).toBe("initial"); }); - test("an unnamed slot is private and unaddressable by name", () => { + test("a child scope shadows nothing it does not own", () => { const store = createClientStateStore(); - const a = store.slot(undefined, "a"); - const b = store.slot(undefined, "b"); - - expect(a).not.toBe(b); - a.set("changed"); - expect(b.getSnapshot()).toBe("b"); - // Anonymous slots are never registered, so nothing can reach them by name. - expect(store.get(undefined)).toBeUndefined(); + const parentSlot = store.root.own("shared", "from parent"); + + // A child that has not claimed the name resolves to the parent's slot. + const child = { parent: store.root, owned: new Map() }; + expect(store.root.find("shared")).toBe(parentSlot); + expect(child.parent.find("shared")).toBe(parentSlot); }); test("writing one var does not notify another var's subscribers", () => { const store = createClientStateStore(); const watched = vi.fn(); const unrelated = vi.fn(); - store.slot("a", 0).subscribe(watched); - store.slot("b", 0).subscribe(unrelated); + store.root.own("a", 0).subscribe(watched); + store.root.own("b", 0).subscribe(unrelated); store.set("a", 1); @@ -86,7 +86,7 @@ describe("store slots", () => { test("a function value is applied as an updater", () => { const store = createClientStateStore(); - store.slot("n", 1); + store.root.own("n", 1); store.set("n", (previous) => previous + 41); @@ -96,7 +96,7 @@ describe("store slots", () => { test("setting an equal value notifies nobody", () => { const store = createClientStateStore(); const listener = vi.fn(); - store.slot("n", 7).subscribe(listener); + store.root.own("n", 7).subscribe(listener); store.set("n", 7); @@ -112,7 +112,7 @@ describe("store slots", () => { store.set("later", "pushed early"); expect(store.get("later")).toBe("pushed early"); - expect(store.slot("later", "default ignored").getSnapshot()).toBe( + expect(store.root.own("later", "default ignored").getSnapshot()).toBe( "pushed early", ); }); @@ -120,7 +120,7 @@ describe("store slots", () => { test("unsubscribing detaches the listener", () => { const store = createClientStateStore(); const listener = vi.fn(); - const unsubscribe = store.slot("n", 0).subscribe(listener); + const unsubscribe = store.root.own("n", 0).subscribe(listener); unsubscribe(); store.set("n", 1); @@ -199,10 +199,10 @@ describe("ClientStateProvider", () => { describe("useClientState", () => { /** Render `useClientState(default, name)` and report renders and value. */ - const probe = (defaultValue, name, id) => { + const probe = (defaultValue, name, id, isGlobal) => { const renders = { count: 0, value: undefined, set: undefined }; const Probe = () => { - const [value, set] = useClientState(defaultValue, name); + const [value, set] = useClientState(defaultValue, name, isGlobal); renders.count += 1; renders.value = value; renders.set = set; @@ -226,17 +226,19 @@ describe("useClientState", () => { unmount(); }); - test("keeps unnamed vars private to each component", () => { - const a = probe("", undefined, "a"); - const b = probe("", undefined, "b"); + test("shares one scope between siblings under the same boundary", () => { + // Two consumers of the same name with no boundary between them: the + // enclosing scope owns it, so an auto-memo split stays invisible. + const a = probe("", "sibling", "a"); + const b = probe("", "sibling", "b"); const { unmount } = mount( createElement(ClientStateProvider, { registry }, a.element, b.element), ); - act(() => a.renders.set("mine")); + act(() => a.renders.set("shared")); - expect(a.renders.value).toBe("mine"); - expect(b.renders.value).toBe(""); + expect(a.renders.value).toBe("shared"); + expect(b.renders.value).toBe("shared"); unmount(); }); @@ -319,6 +321,213 @@ describe("non-React escape hatch", () => { }); }); +describe("scope chain", () => { + /** Render `useClientState` and report renders, value and setter. */ + const probe = (defaultValue, name, isGlobal) => { + const renders = { count: 0, value: undefined, set: undefined }; + const Probe = () => { + const [value, set] = useClientState(defaultValue, name, isGlobal); + renders.count += 1; + renders.value = value; + renders.set = set; + return null; + }; + return { renders, element: createElement(Probe) }; + }; + + test("a descendant inherits the slot its ancestor scope owns", () => { + // The boundary's own consumer claims the name; a component further down + // resolves up the chain to that same slot. + const owner = probe("initial", "claimed"); + const descendant = probe("initial", "claimed"); + const { unmount } = mount( + createElement( + ClientStateProvider, + { registry }, + createElement( + ClientStateScope, + null, + owner.element, + createElement(ClientStateScope, null, descendant.element), + ), + ), + ); + + act(() => owner.renders.set("written by owner")); + + expect(descendant.renders.value).toBe("written by owner"); + + unmount(); + }); + + test("sibling boundaries get separate slots for the same name", () => { + const first = probe("initial", "perInstance"); + const second = probe("initial", "perInstance"); + const { unmount } = mount( + createElement( + ClientStateProvider, + { registry }, + createElement(ClientStateScope, null, first.element), + createElement(ClientStateScope, null, second.element), + ), + ); + + act(() => first.renders.set("only mine")); + + expect(first.renders.value).toBe("only mine"); + expect(second.renders.value).toBe("initial"); + + unmount(); + }); + + test("a nested boundary claims a name its ancestors have not", () => { + const outer = probe("initial", "onlyInner"); + const inner = probe("initial", "onlyInner"); + const { unmount } = mount( + createElement( + ClientStateProvider, + { registry }, + createElement( + ClientStateScope, + null, + createElement(ClientStateScope, null, inner.element), + outer.element, + ), + ), + ); + + // The inner boundary rendered first and claimed it there, so the outer + // consumer -- which is not a descendant of it -- is unaffected. + act(() => inner.renders.set("inner only")); + + expect(inner.renders.value).toBe("inner only"); + expect(outer.renders.value).toBe("initial"); + + unmount(); + }); + + test("a global name stays global inside a boundary", () => { + const scoped = probe("initial", "globalName", true); + const atRoot = probe("initial", "globalName", true); + const { unmount } = mount( + createElement( + ClientStateProvider, + { registry }, + createElement(ClientStateScope, null, scoped.element), + atRoot.element, + ), + ); + + act(() => scoped.renders.set("from inside a boundary")); + + expect(atRoot.renders.value).toBe("from inside a boundary"); + // Reachable from outside React, which is the point of being global. + expect(getClientState("globalName")).toBe("from inside a boundary"); + + unmount(); + }); + + test("a scoped name is not reachable from outside the tree", () => { + const scoped = probe("initial", "treeOnly"); + const { unmount } = mount( + createElement( + ClientStateProvider, + { registry }, + createElement(ClientStateScope, null, scoped.element), + ), + ); + + act(() => scoped.renders.set("private")); + + expect(scoped.renders.value).toBe("private"); + expect(getClientState("treeOnly")).toBeUndefined(); + + unmount(); + }); + + test("writing in one boundary leaves a sibling boundary unrendered", () => { + const first = probe(0, "isolated"); + const second = probe(0, "isolated"); + const { unmount } = mount( + createElement( + ClientStateProvider, + { registry }, + createElement(ClientStateScope, null, first.element), + createElement(ClientStateScope, null, second.element), + ), + ); + const before = second.renders.count; + + act(() => first.renders.set(1)); + + expect(first.renders.value).toBe(1); + expect(second.renders.count).toBe(before); + + unmount(); + }); +}); + +describe("withClientStateScope", () => { + test("gives each mounted instance its own state", () => { + // The shape the compiler emits for a real component boundary. The scope has + // to be outside the component, since its hooks run before its output + // mounts -- inside, every instance would share the enclosing scope. + // Track the LATEST value per instance: comparing first-render snapshots + // passes even when the state is shared. + const latest = {}; + const setters = {}; + const Counter = ({ which }) => { + const [value, set] = useClientState(0, "perInstanceHoc"); + latest[which] = value; + setters[which] = set; + return null; + }; + const Scoped = withClientStateScope(Counter); + + const { unmount } = mount( + createElement( + ClientStateProvider, + { registry }, + createElement(Scoped, { which: "a" }), + createElement(Scoped, { which: "b" }), + ), + ); + + act(() => setters.a(1)); + + expect(latest.a).toBe(1); + expect(latest.b).toBe(0); + + unmount(); + }); + + test("descendants of a wrapped instance share its state", () => { + const parentSeen = []; + const childSeen = []; + const Child = () => { + const [value] = useClientState("initial", "sharedWithChild"); + childSeen.push(value); + return null; + }; + const Parent = () => { + const [value, set] = useClientState("initial", "sharedWithChild"); + parentSeen.push({ value, set }); + return createElement(Child); + }; + const Scoped = withClientStateScope(Parent); + + const { unmount } = mount( + createElement(ClientStateProvider, { registry }, createElement(Scoped)), + ); + + act(() => parentSeen[0].set("from parent")); + + expect(childSeen.at(-1)).toBe("from parent"); + + unmount(); + }); +}); + test("CLIENT_STATE_REF matches the key state.js reads", () => { // The runtime reaches the store through the object it is handed, so the key // is duplicated on the reading side and has to stay in sync. diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index a7f7ea11745..24fe0f53102 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -1332,7 +1332,7 @@ def test_client_state_setter_in_call_function_event_imports_hook() -> None: """ from reflex.compiler.compiler import compile_memo_components - counter = rx.client_state("counter", default=0) + counter = rx.client_state(0, name="counter") def page() -> Component: return rx.el.button( @@ -1359,7 +1359,7 @@ def page() -> Component: "Expected the memo body to call the client-state setter.\n" f"Memo code snippet: {memo_code[:2000]}" ) - assert 'useClientState(0, "counter")' in memo_code, ( + assert 'useClientState(0, "counter", true)' in memo_code, ( "Expected the memo body to declare the client-state hook so the setter " f"binding exists.\nMemo code snippet: {memo_code[:2000]}" ) @@ -2124,19 +2124,19 @@ def test_static_restricted_element_no_id_no_children_does_not_memoize() -> None: ) -@pytest.mark.parametrize("global_ref", [True, False]) +@pytest.mark.parametrize("name", ["titletest", None]) def test_client_state_value_inside_snapshot_boundary_is_memoized( - global_ref: bool, + name: str | None, ) -> None: """Client-state Vars are reactive and must trigger boundary memoization. A ``client_state`` Var contributes its ``useClientState`` hook via ``var_data.hooks`` without setting ``var_data.state``. The reactive-Var walk must catch the hooks-only case so client-state-driven content - inside a snapshot boundary lands in the memo body. Both global and - page-local ``ClientStateVar`` Vars must drive the same wrapping. + inside a snapshot boundary lands in the memo body. Both a named (global) + and an unnamed (tree-scoped) var must drive the same wrapping. """ - cs_var = rx.client_state("titletest", default="hi", global_ref=global_ref) + cs_var = rx.client_state("hi", name=name) title = Title.create(cs_var.value) ctx, page_ctx = _compile_single_page(lambda: title) assert len(ctx.memoize_wrappers) == 1, ( @@ -2376,3 +2376,101 @@ def test_each_memo_wrapper_emits_one_component_module_file() -> None: "for Plain, one for WithProp, and one snapshot wrapper for the " f"LeafComponent boundary. Got: {sorted(ctx.memoize_wrappers)}" ) + + +def _memo_export_line(files: object, symbol: str) -> str: + """Get the `export const` line for one memo symbol. + + Memos from the same source module are grouped into one JS file, so assertions + about a single memo's wrapper have to look at its own export line rather than + the whole file. + + Args: + files: The compiled (path, code) pairs. + symbol: Substring identifying the memo's exported symbol. + + Returns: + The matching export line. + """ + for _path, code in files: # pyright: ignore [reportGeneralTypeIssues] + for line in code.splitlines(): + if line.startswith("export const") and symbol in line: + return line + msg = f"no export line found for {symbol!r}" + raise AssertionError(msg) + + +def test_explicit_memo_using_client_state_opens_a_scope() -> None: + """An ``@rx.memo`` whose body uses client state is an instance boundary. + + The scope must wrap the component function, not sit inside what it returns: + a component's hooks run before its own output mounts, so an inner provider + would leave its own ``useClientState`` resolving against the enclosing scope + and sharing state across instances. + """ + from reflex_base.components.memo import MEMOS + + from reflex.compiler.compiler import compile_memo_components + + @rx.memo + def scoped_toggle(label: rx.Var[str]) -> Component: + local = rx.client_state(False) + return rx.el.button(label, on_click=local.set(True)) + + scoped_toggle(label="x") + files, _ = compile_memo_components(memos=tuple(MEMOS.values())) + export_line = _memo_export_line(files, "ScopedToggle") + + assert "withClientStateScope(memo(Component))" in export_line, ( + f"expected the memo wrapped in a client state scope.\n{export_line}" + ) + assert any( + "withClientStateScope" in line + for _path, code in files + for line in code.splitlines() + if line.startswith("import") + ), "the scope HOC must be imported" + + +def test_memo_without_client_state_is_not_wrapped() -> None: + """Pages must not pay for a scope provider on every memo.""" + from reflex_base.components.memo import MEMOS + + from reflex.compiler.compiler import compile_memo_components + + @rx.memo + def unscoped_label(label: rx.Var[str]) -> Component: + return rx.text(label) + + unscoped_label(label="y") + files, _ = compile_memo_components(memos=tuple(MEMOS.values())) + export_line = _memo_export_line(files, "UnscopedLabel") + + assert "= memo(" in export_line, f"expected the plain memo wrapper.\n{export_line}" + assert "withClientStateScope" not in export_line + + +def test_auto_memo_wrappers_do_not_open_a_scope() -> None: + """Optimizer-generated wrappers must stay semantically invisible. + + Auto-memoization splits one logical component across modules; if each split + opened a scope, the pieces would resolve different slots and a var read in + one and written in another would silently disconnect. + """ + from reflex.compiler.compiler import compile_memo_components + + counter = rx.client_state(0, name="autotransparent") + + def page() -> Component: + return rx.vstack( + rx.text(counter.value), + rx.el.button("set", on_click=counter.set(1)), + ) + + ctx, _page_ctx = _compile_single_page(page) + files, _ = compile_memo_components(memos=tuple(ctx.auto_memo_components.values())) + assert files, "expected auto-memo wrappers for the client-state consumers" + for path, code in files: + assert "withClientStateScope" not in code, ( + f"auto-memo wrapper {path} must not open a client state scope" + ) diff --git a/tests/units/experimental/test_client_state.py b/tests/units/experimental/test_client_state.py index ddf05b8c6cc..b03938aacc3 100644 --- a/tests/units/experimental/test_client_state.py +++ b/tests/units/experimental/test_client_state.py @@ -1,5 +1,7 @@ """The deprecated reflex.experimental.client_state path still resolves.""" +import pytest + import reflex as rx @@ -12,12 +14,40 @@ def test_experimental_import_is_the_promoted_class() -> None: def test_experimental_namespace_factory_still_works() -> None: """``rx._x.client_state`` keeps building the same vars.""" - assert rx._x.client_state("legacy", default=0)._state_name == "legacy" + # The shim keeps the original positional signature. + assert rx._x.client_state("legacy", 0)._state_name == "legacy" def test_promoted_names_are_reachable_from_rx() -> None: """The lazy-loader wiring only fails at attribute access, so assert it.""" - assert rx.client_state("promoted", default=0)._state_name == "promoted" - assert isinstance(rx.client_state("typed", default=0), rx.ClientStateVar) + assert rx.client_state(0, name="promoted")._state_name == "promoted" + assert isinstance(rx.client_state(0, name="typed"), rx.ClientStateVar) # Exported so `.set` can be named in a type annotation. - assert isinstance(rx.client_state("setter", default=0).set, rx.ClientStateSetter) + assert isinstance(rx.client_state(0, name="setter").set, rx.ClientStateSetter) + + +def test_legacy_named_var_is_global() -> None: + """The old positional form keeps naming -- and therefore globalizing -- vars.""" + cs = rx._x.client_state("legacy_named", 0) + assert cs._state_name == "legacy_named" + assert cs._is_global + + +def test_legacy_global_ref_false_drops_the_name() -> None: + """``global_ref=False`` meant anonymous, which is now a dropped name. + + The name was never a store key in that mode, so discarding it reproduces the + old behavior exactly under the new scoping rules. + """ + cs = rx._x.client_state("is_copied", False, False) + assert cs._state_name != "is_copied" + assert not cs._is_global + + +def test_legacy_path_warns(capsys: pytest.CaptureFixture) -> None: + """All the deprecation noise lives on the old entry point, not the new API.""" + rx._x.client_state("warned", 0) + assert "rx._x.client_state" in capsys.readouterr().out + + rx.client_state(0, name="quiet") + assert "deprecat" not in capsys.readouterr().out.lower() diff --git a/tests/units/reflex_base/test_client_state.py b/tests/units/reflex_base/test_client_state.py index 046a5d7b247..f125fc63ed5 100644 --- a/tests/units/reflex_base/test_client_state.py +++ b/tests/units/reflex_base/test_client_state.py @@ -44,52 +44,58 @@ def _app_wraps(var_data: VarData | None) -> list[tuple[int, str]]: def test_single_hook_no_useState() -> None: """A global var emits one useClientState hook and no raw useState/useId.""" - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") hook = _hook(cs) - assert ( - hook - == 'const [counterRxClientState, setCounter] = useClientState(0, "counter")' + assert hook == ( + 'const [counterRxClientState, setCounter] = useClientState(0, "counter", true)' ) assert "useState(" not in hook assert "useId" not in hook assert "refs[" not in hook -@pytest.mark.parametrize("global_ref", [True, False]) -def test_omitted_default_emits_valid_javascript(global_ref: bool) -> None: +def test_omitted_default_emits_valid_javascript() -> None: """No default must still emit a syntactically valid hook call. Regression: an empty default expression rendered as ``useClientState(, "name")`` once the store name became a second argument, which is a syntax error that breaks the whole page build. """ - cs = client_state("counter", global_ref=global_ref) + cs = client_state(name="counter") hook = _hook(cs) assert "(," not in hook - expected = 'undefined, "counter"' if global_ref else "undefined" - assert ( - hook == f"const [counterRxClientState, setCounter] = useClientState({expected})" + assert hook == ( + "const [counterRxClientState, setCounter] = " + 'useClientState(undefined, "counter", true)' ) -def test_local_var_omits_store_name() -> None: - """A ``global_ref=False`` var gets no name, so its slot stays private.""" - cs = client_state("copied", default=False, global_ref=False) - assert _hook(cs) == "const [copiedRxClientState, setCopied] = useClientState(false)" +def test_unnamed_var_is_scoped_not_global() -> None: + """An unnamed var still gets a name, but no flag escaping it to the root.""" + cs = client_state(default=False) + assert _hook(cs).endswith(f'useClientState(false, "{cs._state_name}")') + assert not cs._is_global + + +def test_named_var_is_global() -> None: + """Naming a var is what makes it app-wide and backend-addressable.""" + cs = client_state(False, name="shared") + assert _hook(cs).endswith('useClientState(false, "shared", true)') + assert cs._is_global def test_hook_imports_use_client_state() -> None: """The hook carries the useClientState import.""" - imports = dict(cs_imports := client_state("x", default=0)._var_data.imports) # pyright: ignore [reportOptionalMemberAccess] + imports = dict(cs_imports := client_state(0, name="x")._var_data.imports) # pyright: ignore [reportOptionalMemberAccess] assert cs_imports is not None tags = {i.tag for i in imports[f"$/{Dirs.CLIENT_STATE_PATH}"]} assert tags == {"useClientState"} -@pytest.mark.parametrize("global_ref", [True, False]) -def test_provider_app_wrap_declared(global_ref: bool) -> None: - """The provider is requested in both modes; the hook always uses context.""" - cs = client_state("x", default=0, global_ref=global_ref) +@pytest.mark.parametrize("name", ["x", None]) +def test_provider_app_wrap_declared(name: str | None) -> None: + """The provider is requested either way; the hook always uses context.""" + cs = client_state(0, name=name) assert _app_wraps(cs._var_data) == [ (CLIENT_STATE_APP_WRAP_PRIORITY, "ClientStateProvider") ] @@ -101,15 +107,14 @@ def test_two_vars_dedupe_to_one_provider() -> None: target: dict[tuple[int, str], Any] = {} for name in ("a", "b"): - cs = client_state(name, default=0) + cs = client_state(0, name=name) insert_app_wraps(target, cs._var_data.app_wraps) # pyright: ignore [reportOptionalMemberAccess] assert list(target) == [(CLIENT_STATE_APP_WRAP_PRIORITY, "ClientStateProvider")] -@pytest.mark.parametrize("global_ref", [True, False]) -def test_value_is_marked_identifier(global_ref: bool) -> None: - """``value`` renders the marked local binding in both modes.""" - cs = client_state("counter", default=0, global_ref=global_ref) +def test_value_is_marked_identifier() -> None: + """``value`` renders the marked local binding.""" + cs = client_state(0, name="counter") assert str(cs.value) == "counterRxClientState" @@ -117,20 +122,20 @@ def test_set_bare_is_event_chain() -> None: """``set`` renders the bare setter and is usable as an event trigger value.""" from reflex_base.event import EventChain - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") assert str(cs.set) == "setCounter" assert cs.set._var_type is EventChain def test_set_bound_value() -> None: """Calling ``set`` binds a value in a zero-arg wrapper.""" - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") assert str(cs.set(42)) == "(() => (setCounter(42)))" def test_set_carries_hook_import_and_app_wrap() -> None: """The setter must drag in its own hook, import and provider.""" - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") for setter in (cs.set, cs.set(42)): var_data = setter._get_all_var_data() assert var_data is not None @@ -163,7 +168,7 @@ def test_set_carries_hook_import_and_app_wrap() -> None: ) def test_set_functional_updater_is_typed(default: Any, fn: Any, expected: str) -> None: """A lambda is traced against a placeholder typed like the var.""" - cs = client_state("x", default=default) + cs = client_state(default, name="x") rendered = str(cs.set(fn)) # The placeholder counter is process-global; recover it from the output. n = rendered.split("prev", 1)[1].split("RxClientState", 1)[0] @@ -172,27 +177,27 @@ def test_set_functional_updater_is_typed(default: Any, fn: Any, expected: str) - def test_set_zero_arg_callable_is_plain_value() -> None: """A zero-argument callable is treated as the value, not an updater.""" - cs = client_state("x", default=0) + cs = client_state(0, name="x") assert str(cs.set(lambda: 7)) == "(() => (setX(7)))" def test_set_rejects_multi_arg_callable() -> None: """An updater may only take the current value.""" - cs = client_state("x", default=0) + cs = client_state(0, name="x") with pytest.raises(VarTypeError): cs.set(lambda a, b: a + b) # pyright: ignore [reportCallIssue] # noqa: FURB118 - a lambda is what is under test def test_set_passes_function_var_through() -> None: """A FunctionVar is passed straight through as a runtime updater.""" - cs = client_state("x", default=0) + cs = client_state(0, name="x") updater = Var("(p) => p + 1").to(FunctionVar) assert str(cs.set(updater)) == "(() => (setX((p) => p + 1)))" def test_set_declares_event_arg() -> None: """A value referencing an event arg makes the wrapper declare it.""" - cs = client_state("x", default="") + cs = client_state("", name="x") assert ( str(cs.set(Var('_e["target"]["value"]'))) == '((_e) => (setX(_e["target"]["value"])))' @@ -201,7 +206,7 @@ def test_set_declares_event_arg() -> None: def test_set_declares_event_arg_in_compound_expression() -> None: """Only the event arg is declared, not the whole expression.""" - cs = client_state("x", default="") + cs = client_state("", name="x") assert ( str(cs.set(Var('_e["target"]["value"] + "!"'))) == '((_e) => (setX(_e["target"]["value"] + "!")))' @@ -210,8 +215,8 @@ def test_set_declares_event_arg_in_compound_expression() -> None: def test_underscore_named_var_is_not_mistaken_for_event_arg() -> None: """A marked identifier is an in-scope binding, never a trigger parameter.""" - private = client_state("_private", default="") - other = client_state("other", default="") + private = client_state("", name="_private") + other = client_state("", name="other") assert str(other.set(private.value)) == "(() => (setOther(_privateRxClientState)))" @@ -253,7 +258,7 @@ def test_recovered_event_arg(value_str: str, expected: str | None) -> None: ) def test_reserved_words_are_safe(reserved: str) -> None: """A JS reserved word is a legal name; the marker keeps the codegen valid.""" - cs = client_state(reserved, default=1) + cs = client_state(1, name=reserved) hook = _hook(cs) assert hook.startswith(f"const [{reserved}RxClientState, ") # The store key stays the bare word so the backend can still address it. @@ -263,20 +268,23 @@ def test_reserved_words_are_safe(reserved: str) -> None: def test_camel_case_names_get_distinct_setters() -> None: """``myVar`` and ``myvar`` must not collapse onto one setter binding.""" - assert client_state("myVar")._setter_name != client_state("myvar")._setter_name + assert ( + client_state(name="myVar")._setter_name + != client_state(name="myvar")._setter_name + ) def test_var_name_rejects_var() -> None: """A Var name would only exist at runtime, so it is rejected.""" with pytest.raises(ValueError, match="not a Var"): - client_state(Var("dynamic")) # pyright: ignore [reportArgumentType] + client_state(name=Var("dynamic")) # pyright: ignore [reportArgumentType] @pytest.mark.parametrize("bad", ["1foo", "my-name", "a b", "", "a.b"]) def test_var_name_must_be_identifier(bad: str) -> None: """The name is emitted as a JS identifier, so it has to be one.""" with pytest.raises(ValueError, match="identifier"): - client_state(bad) + client_state(name=bad) def test_generated_names_are_sequential_and_distinct() -> None: @@ -301,7 +309,7 @@ def test_generated_names_unaffected_by_unrelated_var_names() -> None: def test_push_builds_wire_event() -> None: """``push`` sends a first-class client-state event, not an eval'd script.""" - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") spec = cs.push(5) assert spec.handler.fn.__qualname__ == "_client_state_set" assert {str(k): str(v) for k, v in spec.args} == { @@ -312,7 +320,7 @@ def test_push_builds_wire_event() -> None: def test_retrieve_builds_wire_event() -> None: """``retrieve`` sends a first-class client-state event with a callback slot.""" - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") args = {str(k): str(v) for k, v in cs.retrieve().args} assert cs.retrieve().handler.fn.__qualname__ == "_client_state_get" assert args["var_name"] == '"counter"' @@ -321,14 +329,14 @@ def test_retrieve_builds_wire_event() -> None: def test_global_accessors_render_module_functions() -> None: """The escape hatch reads and writes through the module-level functions.""" - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") assert str(cs.global_value) == 'getClientState("counter")' assert str(cs.global_set) == '((value) => setClientState("counter", value))' def test_global_accessors_carry_no_hook() -> None: """The escape hatch must work in any scope, so it drags in no hook.""" - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") for accessor in (cs.global_value, cs.global_set): var_data = accessor._get_all_var_data() assert var_data is not None @@ -345,8 +353,8 @@ def test_global_accessors_carry_no_hook() -> None: ) def test_name_addressed_paths_require_global(accessor: str) -> None: """An anonymous slot has no name, so nothing can address it.""" - cs = client_state("x", default=0, global_ref=False) - with pytest.raises(ValueError, match="must be global"): + cs = client_state(default=0) + with pytest.raises(ValueError, match="scoped to the component tree"): if accessor == "push": cs.push(1) elif accessor == "retrieve": @@ -357,14 +365,14 @@ def test_name_addressed_paths_require_global(accessor: str) -> None: def test_set_value_delegates_and_deprecates(capsys: pytest.CaptureFixture) -> None: """``set_value`` still works, and says to use ``set``.""" - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") assert str(cs.set_value(42)) == str(cs.set(42)) assert "set_value" in capsys.readouterr().out def test_var_renders_as_null() -> None: """The var object itself renders as null so it can sit in a component tree.""" - assert str(client_state("x", default=0)) == "null" + assert str(client_state(0, name="x")) == "null" def test_acceptance_throttle_controlled_input_compiles() -> None: @@ -382,8 +390,8 @@ def debounce_controlled_input( debounce_ms: rx.Var[int], rest: rx.RestProp, ) -> rx.Component: - lc_var = rx.client_state(global_ref=False) - lc_last_var = rx.client_state(global_ref=False) + lc_var = rx.client_state() + lc_last_var = rx.client_state() return rx.el.input( rest, rx.cond( @@ -414,10 +422,10 @@ def debounce_controlled_input( assert len(declarations) == 2, ( f"expected one hook per local var, got {declarations}" ) - # Distinct bindings, and neither is registered under a shared store name - # (a named var would pass the name as a second, string, argument). + # Distinct bindings, and neither escapes to the root scope (a named var + # would pass a trailing `true`). assert len(set(declarations)) == 2 - assert all("useClientState(undefined)" in line for line in declarations) + assert all(not line.rstrip(";").endswith("true)") for line in declarations) assert 'from "$/utils/client_state"' in code @@ -431,20 +439,20 @@ def comp(value: rx.Var[str]) -> rx.Component: return rx.el.input(value=value) comp(value="x") - cs = rx.client_state("target", default="") + cs = rx.client_state("", name="target") assert str(cs.set(captured["value"])) == "(() => (setTarget(valueRxMemo)))" def test_set_with_no_argument_is_the_bare_setter() -> None: """``cs.set()`` is the same forwarding setter as ``cs.set``.""" - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") assert str(cs.set()) == str(cs.set) == "setCounter" def test_hash_distinguishes_vars() -> None: """Vars are hashable and distinct names hash differently.""" - a = client_state("a", default=0) - b = client_state("b", default=0) + a = client_state(0, name="a") + b = client_state(0, name="b") assert hash(a) != hash(b) assert len({a, b, a}) == 2 @@ -452,12 +460,12 @@ def test_hash_distinguishes_vars() -> None: def test_var_name_rejects_non_string() -> None: """A non-string, non-Var name is rejected.""" with pytest.raises(ValueError, match="must be a string"): - client_state(5) # pyright: ignore [reportArgumentType] + client_state(name=5) # pyright: ignore [reportArgumentType] def test_var_default_is_used_directly() -> None: """A Var default is embedded as-is and sets the var's type.""" - cs = client_state("x", default=Var("someExpr").to(int)) + cs = client_state(Var("someExpr").to(int), name="x") assert "useClientState(someExpr" in _hook(cs) assert cs._var_type is int @@ -471,7 +479,7 @@ class RetrieveState(rx.State): def got(self, value: str): self.value = value - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") args = {str(k): str(v) for k, v in cs.retrieve(RetrieveState.got).args} assert args["var_name"] == '"counter"' assert "queueEvents" in args["callback"] @@ -482,7 +490,7 @@ def test_push_plain_value_uses_json_payload() -> None: """A concrete value crosses the wire as JSON, not as JS source.""" from reflex_base.event import fix_events - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") event = fix_events([cs.push({"a": 1})], token="tok")[0] assert event.name.endswith("_client_state_set") assert event.payload == {"var_name": "counter", "value": {"a": 1}} @@ -496,7 +504,7 @@ def test_push_var_is_evaluated_on_the_client() -> None: """ from reflex_base.event import fix_events - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") event = fix_events([cs.push(Var("Date.now()"))], token="tok")[0] assert event.name.endswith("_call_function") assert 'refs["__client_state"].set("counter", Date.now())' in str( @@ -522,3 +530,62 @@ def test_retrieve_callback_runs_even_without_a_store() -> None: # Optional chaining rather than an early return, so a missing store still # reaches the callback with undefined. assert "store?.get(" in branch + + +def test_each_construction_gets_its_own_name() -> None: + """A plain helper called N times yields N independent states. + + ``rx.client_state()`` runs once per call at compile time, so a helper + function that constructs one gives every call site its own slot -- no memo, + no keys, no configuration. + """ + names = [client_state(0)._state_name for _ in range(3)] + assert len(set(names)) == 3 + + +def test_all_consumers_of_one_var_share_the_name() -> None: + """Every consumer emits the same hook, so an auto-memo split still shares. + + This is the property the redesign exists for: reading and writing a var + compile to separate memo modules, and they must resolve the same slot. + """ + cs = client_state(0) + hooks = { + _hook(cs), + *( + hook + for accessor in (cs.value, cs.set, cs.set(1)) + for hook in (accessor._get_all_var_data() or VarData()).hooks + ), + } + assert len(hooks) == 1, f"expected one shared hook, got {hooks}" + + +def test_prefix_customizes_the_generated_name() -> None: + """A prefix keeps the compiled javascript readable for internal use.""" + cs = client_state(0, prefix="counter") + assert cs._state_name.startswith("counter") + assert _hook(cs).startswith("const [counter") + + +def test_prefix_is_ignored_when_named() -> None: + """An explicit name wins; the prefix only shapes generated names.""" + cs = client_state(0, name="explicit", prefix="ignored") + assert cs._state_name == "explicit" + + +def test_generated_names_stay_unique_across_prefixes() -> None: + """One shared counter, so mixing prefixes can never collide.""" + names = [ + client_state(0, prefix="a")._state_name, + client_state(0, prefix="b")._state_name, + client_state(0)._state_name, + ] + assert len(set(names)) == 3 + + +@pytest.mark.parametrize("bad", ["1bad", "my-prefix", "", "a b"]) +def test_prefix_must_be_an_identifier(bad: str) -> None: + """The prefix is emitted as part of a JS identifier, so it has to be one.""" + with pytest.raises(ValueError, match="prefix"): + client_state(0, prefix=bad) From c94d4b9f91700e20bccc40cc1cef68cb70726ff2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 12:22:01 +0000 Subject: [PATCH 7/9] docs(client_state): document naming and tree scoping 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 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- docs/wrapping-react/overview.md | 56 +++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/docs/wrapping-react/overview.md b/docs/wrapping-react/overview.md index 6bbcad55118..5eb897e394f 100644 --- a/docs/wrapping-react/overview.md +++ b/docs/wrapping-react/overview.md @@ -121,6 +121,58 @@ def index(): ) ``` +## Naming And Scoping Client State + +Whether a client state var is shared app-wide follows from whether you name it: + +```python +shared = rx.client_state( + "", name="search_query" +) # global: any component, and the backend +private = rx.client_state("") # scoped to the component tree using it +``` + +A **named** var resolves in one app-wide store, so any component can read and write it and +`push`, `retrieve`, `global_value` and `global_set` all work against it. + +An **unnamed** var gets a compile-time name and is scoped: the first component in a tree to +use it claims it for that tree, so each instance of that component gets its own state, the +way React's `useState` does. Nothing outside the tree can address it. + +Where you *construct* the var decides who shares it. Construct it inside a component and +each instance gets its own: + +```python +@rx.memo +def copy_button(text: rx.Var[str]) -> rx.Component: + copied = rx.client_state(False) # one per rendered button + ... +``` + +Construct it once at module level and reference it from several components and they share +it, owned by the outermost one that uses it — the same way lifting state up works in React. +Note the consequence: adding a read at page level makes the page the owner, which collapses +per-instance state below it into one shared value. + +A plain helper function called several times gives each call its own state for free, since +the var is constructed once per call: + +```python +def counter(): + count = rx.client_state(0) # a distinct var per call + return rx.hstack( + rx.button("-", on_click=count.set(lambda v: v - 1)), + rx.heading(count.value), + rx.button("+", on_click=count.set(lambda v: v + 1)), + ) + + +rx.vstack(counter(), counter(), counter()) # three independent counters +``` + +Pass `prefix=` to make generated names readable in the compiled output: +`rx.client_state(0, prefix="counter")`. + ## Setting Client State From Plain JavaScript `value` and `set` are the normal way to use a client state var, but they resolve to a @@ -144,8 +196,8 @@ class MyPicker(rx.Component): Reads through `global_value` are a point-in-time snapshot with no reactivity, so prefer `value` inside components. Writes through `global_set` re-render every component -subscribed to that var, exactly like `set` does. Both require a named (non-local) -client state var, since the name is what identifies the value. +subscribed to that var, exactly like `set` does. Both require a **named** client state +var, since the name is what identifies the value. `rx.call_script` is the one place these do not work: its code is evaluated inside the Reflex runtime module, so your page's imports are not in scope there. Reach the store From 73add078d5deaf2cbed122d1290156375080791d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 13:14:25 +0000 Subject: [PATCH 8/9] feat(foreach): scope loop vars per item, fixing #3210 `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 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- docs/library/dynamic-rendering/foreach.md | 46 +++++ docs/wrapping-react/overview.md | 12 ++ .../.templates/web/utils/client_state.js | 56 ++++++ .../src/reflex_base/compiler/templates.py | 11 +- .../reflex_base/components/tags/iter_tag.py | 56 ++++-- .../reflex_components_core/core/foreach.py | 25 ++- reflex/compiler/plugins/memoize.py | 110 ++++++++++-- .../integration/tests_playwright/test_memo.py | 80 +++++++++ tests/js/client_state.test.js | 167 ++++++++++++++++++ tests/units/compiler/test_memoize_plugin.py | 120 ++++++++++++- tests/units/components/core/test_foreach.py | 76 ++++++++ 11 files changed, 722 insertions(+), 37 deletions(-) diff --git a/docs/library/dynamic-rendering/foreach.md b/docs/library/dynamic-rendering/foreach.md index 3d0252a6398..e8d39adc65d 100644 --- a/docs/library/dynamic-rendering/foreach.md +++ b/docs/library/dynamic-rendering/foreach.md @@ -217,8 +217,54 @@ def foreach_complex_dict_example(): ) ``` +## Per-Item State And Event Handlers + +Each rendered item gets its own scope, so the item and index are available +anywhere in that item's subtree -- including in event handlers and in +components the compiler splits out on its own: + +```python +class TodoState(rx.State): + items: list[str] = ["write docs", "ship it"] + + @rx.event + def done(self, item: str, index: int): ... + + +def todo_row(item: rx.Var[str], index: rx.Var[int]) -> rx.Component: + return rx.hstack( + rx.text(item), + rx.button("done", on_click=TodoState.done(item, index)), + ) + + +def todo_list(): + return rx.vstack(rx.foreach(TodoState.items, todo_row)) +``` + +Client state works the same way: an unnamed `rx.client_state` var in a +`foreach` body is per item, the way `useState` would be in a React list. + +```python +def expandable_row(item: rx.Var[str]) -> rx.Component: + expanded = rx.client_state(False) # one per rendered row + return rx.vstack( + rx.button(item, on_click=expanded.set(~expanded.value)), + rx.cond(expanded.value, rx.text(f"details for {item}")), + ) +``` + +By default each item is keyed by its position in the list. Pass `key=` on the +item to key by identity instead, which is what preserves a row's DOM state +(a typed-in value, focus, an in-flight animation) when the list is reordered: + +```python +rx.foreach(TodoState.items, lambda item: todo_row(item, key=item)) +``` + ## API Reference + ### `rx.foreach` ```python diff --git a/docs/wrapping-react/overview.md b/docs/wrapping-react/overview.md index 5eb897e394f..0f78a83483e 100644 --- a/docs/wrapping-react/overview.md +++ b/docs/wrapping-react/overview.md @@ -170,6 +170,18 @@ def counter(): rx.vstack(counter(), counter(), counter()) # three independent counters ``` +Each item rendered by `rx.foreach` is its own scope too, so an unnamed var used in a +loop body is per item: + +```python +def row(item: rx.Var[str]) -> rx.Component: + expanded = rx.client_state(False) # one per rendered row + ... + + +rx.foreach(State.items, row) +``` + Pass `prefix=` to make generated names readable in the compiled output: `rx.client_state(0, prefix="counter")`. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js index 552e8b23278..d4b0c0b5a34 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js @@ -280,6 +280,62 @@ export const withClientStateScope = (Component) => { return Wrapped; }; +/** + * Per-render values provided down the tree, chained like the slot scopes. + * + * Distinct from the slot scopes on purpose: these are read-only values that + * change every render (a loop's item and index), so their context identity has + * to be free to change, while a slot scope must stay stable or mounted hooks + * would rebind. Null outside any provider. + */ +export const ScopedValuesContext = createContext(null); + +/** + * Provide read-only values to a subtree, keyed by name. + * + * This is how a loop hands its item and index to descendants that compile into + * their own components: they read by name from context instead of closing over + * a variable that only exists inside the loop callback. + * + * It also opens a client state scope, because it marks a component instance the + * same way a memo boundary does -- one rendered item. That makes an unnamed + * client state var used in a loop body per item, which is what a reader of the + * Python expects and what React's `useState` would do. + * @param props The component props. + * @param props.children The children to render. + * @param props.values Mapping of name to value for this subtree. + * @returns The provider element. + */ +export function ScopedValues({ children, values }) { + const parent = useContext(ScopedValuesContext); + // A fresh object each render is correct here -- the values themselves change + // per render, and nothing subscribes to them. + const chained = { parent, values }; + return createElement( + ScopedValuesContext.Provider, + { value: chained }, + createElement(ClientStateScope, null, children), + ); +} + +/** + * Read a value provided by an enclosing `ScopedValues`. + * + * Walks outward, so a nested loop's descendants can still reach the outer loop's + * values. Names are generated at compile time, so nested loops never collide. + * @param name The value's name. + * @returns The value, or undefined when nothing provides it. + */ +export function useScopedValue(name) { + const scope = useContext(ScopedValuesContext); + for (let current = scope; current !== null; current = current.parent) { + if (name in current.values) { + return current.values[name]; + } + } + return undefined; +} + /** * Subscribe to a piece of client state. * @param defaultValue The initial value. diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 59520613899..e95b0cea440 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -87,7 +87,16 @@ def render_iterable_tag(component: Any) -> str: children_rendered = "".join([ _RenderUtils.render(child) for child in component.get("children", []) ]) - return f"Array.prototype.map.call({component['iterable_state']} ?? [],(({component['arg_name']},{component['arg_index']})=>({children_rendered})))" + arg = component["arg_name"] + index = component["arg_index"] + # Provide the item and index to the subtree by name. Descendants that + # compile into their own components read them from context, so a hoisted + # handler or a lifted memo body no longer loses the loop scope. + values = f"{{{arg}:{arg},{index}:{index}}}" + # The provider is the element the map yields, so it carries the key. + key = component["item_key"] + wrapped = f"jsx(ScopedValues,{{key:{key},values:{values}}},{children_rendered})" + return f"Array.prototype.map.call({component['iterable_state']} ?? [],(({arg},{index})=>({wrapped})))" @staticmethod def render_match_tag(component: Any) -> str: diff --git a/packages/reflex-base/src/reflex_base/components/tags/iter_tag.py b/packages/reflex-base/src/reflex_base/components/tags/iter_tag.py index f5391905ea3..1e153069159 100644 --- a/packages/reflex-base/src/reflex_base/components/tags/iter_tag.py +++ b/packages/reflex-base/src/reflex_base/components/tags/iter_tag.py @@ -8,14 +8,56 @@ from typing import TYPE_CHECKING from reflex_base.components.tags.tag import Tag +from reflex_base.constants import Dirs +from reflex_base.utils.imports import ImportVar from reflex_base.utils.types import GenericType from reflex_base.vars import LiteralArrayVar, Var, get_unique_variable_name +from reflex_base.vars.base import LiteralVar, VarData from reflex_base.vars.sequence import _determine_value_of_array_index if TYPE_CHECKING: from reflex_base.components.component import Component +_SCOPED_VALUE_IMPORT = { + f"$/{Dirs.CLIENT_STATE_PATH}": [ImportVar(tag="useScopedValue")] +} + + +def scoped_loop_var(name: str, var_type: GenericType) -> Var: + """Build a loop var that reads its value from the enclosing scope. + + A loop var used to render as nothing but the map callback's parameter, which + broke the moment anything referencing it compiled into its own function -- an + event handler hoisted into a ``useCallback``, or a subtree lifted into its own + memo module (reflex-dev/reflex#3210). The loop now publishes the item and + index by name around each rendered item, so a consumer reads them from + context wherever the compiler puts it. + + The hook declares the *same* identifier as the map callback's parameter, on + purpose. Hooks float to the top of whichever component they land in, so in + the module that renders the loop itself the declaration sits above the + ``.map`` and would read nothing -- the parameter shadows it for everything + inside the callback, which is exactly the scope where the parameter is the + real value. Anywhere else there is no parameter, and the context read wins. + + Args: + name: The name the value is provided under. + var_type: The type of the value. + + Returns: + A Var carrying the hook that reads the value. + """ + return Var( + _js_expr=name, + _var_type=var_type, + _var_data=VarData( + hooks={f"const {name} = useScopedValue({LiteralVar.create(name)!s})": None}, + imports=_SCOPED_VALUE_IMPORT, + ), + ).guess_type() + + @dataclasses.dataclass(frozen=True) class IterTag(Tag): """An iterator tag.""" @@ -50,10 +92,7 @@ def get_index_var(self) -> Var: Returns: The index var. """ - return Var( - _js_expr=self.index_var_name, - _var_type=int, - ).guess_type() + return scoped_loop_var(self.index_var_name, int) def get_arg_var(self) -> Var: """Get the arg var for the tag (with curly braces). @@ -63,10 +102,7 @@ def get_arg_var(self) -> Var: Returns: The arg var. """ - return Var( - _js_expr=self.arg_var_name, - _var_type=self.get_iterable_var_type(), - ).guess_type() + return scoped_loop_var(self.arg_var_name, self.get_iterable_var_type()) def render_component(self) -> Component: """Render the component. @@ -110,8 +146,4 @@ def render_component(self) -> Component: msg = "The render function must return a component." raise ValueError(msg) - # Set the component key. - if component.key is None: - component.key = index - return component diff --git a/packages/reflex-components-core/src/reflex_components_core/core/foreach.py b/packages/reflex-components-core/src/reflex_components_core/core/foreach.py index 1df59feda9d..80e5e4abef7 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/foreach.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/foreach.py @@ -10,7 +10,7 @@ from reflex_base.components.component import Component, field from reflex_base.components.tags import IterTag -from reflex_base.constants import MemoizationMode +from reflex_base.constants import Dirs from reflex_base.constants.state import FIELD_MARKER from reflex_base.utils import types from reflex_base.utils.exceptions import UntypedVarError @@ -31,10 +31,16 @@ class ForeachRenderError(TypeError): class Foreach(Component): """A component that takes in an iterable and a render function and renders a list of components.""" - _memoization_mode = MemoizationMode(recursive=False) - iterable: Var[Iterable] = field(doc="The iterable to create components from.") + def add_imports(self) -> dict[str, str]: + """Import the provider each item's subtree is wrapped in. + + Returns: + The imports for the component. + """ + return {f"$/{Dirs.CLIENT_STATE_PATH}": "ScopedValues"} + render_fn: Callable = field( doc="A function from the render args to the component.", default=Fragment.create, @@ -168,12 +174,25 @@ def render(self): The dictionary for template of component. """ tag = self._render() + # The per-item 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 up to it; otherwise the loop index + # keys by position, as it always has. + item_key = next( + ( + str(LiteralVar.create(child.key)) + for child in self.children + if isinstance(child, Component) and child.key is not None + ), + tag.index_var_name, + ) return dict( tag, iterable_state=str(tag.iterable), arg_name=tag.arg_var_name, arg_index=tag.index_var_name, + item_key=item_key, ) diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index 36aee008ae7..f671a9ed6ea 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -21,6 +21,7 @@ from __future__ import annotations import dataclasses +import functools from typing import Any from reflex_base.components.component import BaseComponent, Component @@ -35,6 +36,7 @@ from reflex_base.constants.compiler import MemoizationDisposition from reflex_base.plugins import ComponentAndChildren, PageContext from reflex_base.plugins.base import Plugin +from reflex_base.plugins.compiler import CompilerHooks from reflex.compiler.plugins.builtin import ( collect_var_app_wraps_for_component, @@ -199,6 +201,21 @@ def _should_memoize(component: Component) -> bool: return bool(component.event_triggers) +@functools.cache +def _memoize_only_hooks() -> CompilerHooks: + """Return a hook chain that runs auto-memoization and nothing else. + + Used to walk a structural snapshot child's subtree: it must keep memoizing + so descendants get their own modules, but no page-level collector may see + it -- the subtree is compiled into the snapshot's own memo body. The plugin + holds no state, so one chain is shared across compiles. + + Returns: + A single-plugin hook chain. + """ + return CompilerHooks(plugins=(MemoizeStatefulPlugin(),)) + + @dataclasses.dataclass(frozen=True, slots=True) class MemoizeStatefulPlugin(Plugin): """Auto-memoize stateful components with experimental-memo wrappers. @@ -208,18 +225,22 @@ class MemoizeStatefulPlugin(Plugin): wrappers (see ``get_memoization_strategy``): - Snapshot wrappers (``MemoizationLeaf``-style boundaries and structural - ``Foreach`` wrappers): wrapped in ``enter_component`` - and returned with empty structural children. The walker skips descent, so - hooks attached to the captured body are compiled into the memo body only. + ``Foreach`` wrappers): wrapped in ``enter_component`` and returned with + empty structural children, so hooks attached to the captured body are + compiled into the memo body only. - Passthrough wrappers are wrapped in ``leave_component`` after descendants have already compiled, so any inner memo wrappers flow into this wrapper's children. - Descendants of a snapshot boundary are never independently memoized; the + Descendants of a snapshot *boundary* are never independently memoized; the boundary owns the wrapping decision for its whole subtree. This is tracked via ``PageContext.memoize_suppressor_stack`` — a stack of component ids that pushed suppression, popped in ``leave_component`` when the matching component leaves. + + A structural snapshot child is the one case in between: its subtree is user + content, so it keeps memoizing, but under a memoize-only hook chain that no + page-level collector sees (``_memoize_structural_child``). """ def enter_component( @@ -231,7 +252,7 @@ def enter_component( compile_context: Any, in_prop_tree: bool = False, ) -> BaseComponent | ComponentAndChildren | None: - """Memoize snapshot-boundary subtrees before descent. + """Memoize snapshot subtrees before descent. Snapshot boundaries (``MemoizationLeaf``-style, see ``is_snapshot_boundary``) stash state-referencing hooks inside @@ -243,7 +264,10 @@ def enter_component( entirely — the boundary's full snapshot lives only in the memo component definition compiled separately. - Non-boundary components are handled in ``leave_component`` so their + Structural snapshot children (``Foreach``) seal the same way, but their + subtree is memoized on the way in rather than skipped. + + Everything else is handled in ``leave_component`` so its already-compiled children flow into the wrapper. Args: @@ -262,16 +286,20 @@ def enter_component( return None if page_context.memoize_suppressor_stack: return None - strategy = get_memoization_strategy(comp) - if strategy is not MemoizationStrategy.SNAPSHOT: - return None - snapshot_boundary = is_snapshot_boundary(comp) + if not is_snapshot_boundary(comp): + if get_memoization_strategy(comp) is not MemoizationStrategy.SNAPSHOT: + return None + # A structural snapshot child (``Foreach``) also renders its whole + # subtree into its own memo body, but unlike a boundary that + # subtree is user content that must keep memoizing: a descendant + # needs its own module so its hooks land below the per-item scope + # the loop provides. Memoize it here, sealed from the page walk. + return self._memoize_structural_child(comp, page_context, compile_context) if not _should_memoize(comp): # Boundary not worth wrapping — still suppress descendants so # they don't memoize independently of the boundary's subtree. - if snapshot_boundary: - page_context.memoize_suppressor_stack.append(id(comp)) + page_context.memoize_suppressor_stack.append(id(comp)) return None wrapper = self._build_wrapper( @@ -299,7 +327,7 @@ def leave_component( compile_context: Any, in_prop_tree: bool = False, ) -> BaseComponent | ComponentAndChildren | None: - """Wrap non-boundary memoizables and pop any suppression this component pushed. + """Wrap memoizables handled after descent, and pop this component's suppression. Args: comp: The component being visited. @@ -333,8 +361,8 @@ def leave_component( comp = page_context.own(comp) comp.children = list(children) - strategy = get_memoization_strategy(comp) - if strategy is MemoizationStrategy.SNAPSHOT: + if is_snapshot_boundary(comp): + # Already handled (and sealed) in ``enter_component``. return None if not _should_memoize(comp): @@ -352,6 +380,54 @@ def leave_component( return self._build_wrapper(comp, page_context, compile_context) + def _memoize_structural_child( + self, + comp: Component, + page_context: PageContext, + compile_context: Any, + ) -> ComponentAndChildren | None: + """Memoize a structural snapshot child's subtree without exposing it. + + The subtree is walked with this plugin alone, so descendants still get + their own memo modules while the page collector never sees them — their + hooks, imports, refs and custom code belong to the memo body that + renders them, exactly as when the walker skipped the subtree outright. + + Args: + comp: The structural snapshot child. + page_context: The active page context. + compile_context: The active compile context. + + Returns: + A ``(wrapper, ())`` replacement, or ``None`` if not worth wrapping. + """ + if not _should_memoize(comp): + return None + + hooks = _memoize_only_hooks() + memoized_children = [ + hooks.compile_component( + child, + page_context=page_context, + compile_context=compile_context, + ) + for child in comp.children + ] + if any( + memoized is not original + for memoized, original in zip(memoized_children, comp.children, strict=True) + ): + comp = page_context.own(comp) + comp.children = memoized_children + + wrapper = self._build_wrapper(comp, page_context, compile_context) + if wrapper is None: + return None + # Var-declared app wraps still have to reach the page registry; the + # collector that normally surfaces them never walks this subtree. + collect_var_app_wraps_in_subtree(page_context.app_wrap_components, comp) + return (wrapper, ()) + @staticmethod def _build_wrapper( comp: Component, @@ -393,6 +469,10 @@ def _build_wrapper( compile_context.auto_memo_components[tag, definition.source_module] = definition wrapper = wrapper_factory() + # The wrapper takes the wrapped component's place in the tree, so it has + # to take its key too: the key belongs to the element the parent renders, + # and a key left behind on the memo body does nothing. + wrapper.key = comp.key # The wrapper has no structural children at the page level, but parents # walking ``_get_all_refs`` (e.g. ``Form._get_form_refs`` collecting # ref_ mappings into ``handleSubmit``) need to see refs from the diff --git a/tests/integration/tests_playwright/test_memo.py b/tests/integration/tests_playwright/test_memo.py index 9aadeda53bc..8c1d5dd624d 100644 --- a/tests/integration/tests_playwright/test_memo.py +++ b/tests/integration/tests_playwright/test_memo.py @@ -55,6 +55,10 @@ def replace_tree(self): def reverse_order(self): self.order = list(reversed(self.order)) + @rx.event + def record_submit(self, item: str, position: int): + self.last_value = f"{item}@{position}" + @rx.memo def my_memoed_component( some_value: rx.Var[str], @@ -87,6 +91,30 @@ def unwrapped_label(value: rx.Var[str]) -> rx.Component: # component that must still render and follow its prop. return rx.text(value, id="unwrapped-label") + def scoped_row(item: rx.Var[str], position: rx.Var[int]) -> rx.Component: + # No ``rx.memo``: an inline foreach body, which is where loop vars used + # to fall out of scope. Every consumer here compiles into its own + # module -- the submit handler into a ``useCallback``, the client state + # read into its own memo -- so each one only works if it can reach the + # loop item from the scope the loop provides around the item. + opened = rx.client_state(False, prefix="opened") + return rx.hstack( + rx.form( + rx.el.button("submit", type="submit"), + on_submit=lambda _form_data: MemoState.record_submit(item, position), + id=f"scoped-form-{position}", + ), + rx.el.button( + "toggle", + id=f"scoped-toggle-{position}", + on_click=opened.set(~opened.value), + ), + rx.text( + rx.cond(opened.value, f"open:{item}", f"closed:{item}"), + id=f"scoped-status-{position}", + ), + ) + def index() -> rx.Component: return rx.vstack( rx.input( @@ -112,6 +140,10 @@ def index() -> rx.Component: id="keyed-rows", ), unwrapped_label(value=MemoState.last_value), + rx.box( + rx.foreach(MemoState.order, scoped_row), + id="scoped-rows", + ), ) app = rx.App() @@ -263,3 +295,51 @@ def test_memo_wrapper_none_renders_and_updates( expect(page.locator("#unwrapped-label")).to_have_text("") page.locator("#memo-input").fill("unwrapped_update") expect(page.locator("#unwrapped-label")).to_have_text("unwrapped_update") + + +def test_foreach_item_handler_receives_its_own_loop_vars( + memo_app: AppHarness, page: Page +) -> None: + """A submit handler inside an inline foreach body sees its item and index. + + Regression for reflex-dev/reflex#3210: the handler compiles into a + ``useCallback`` that the compiler lifts out of the ``.map`` body, so the + loop vars it referenced were not in scope and the page threw + ``ReferenceError``. Submitting each row must report that row's own values. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + _load_page(page, memo_app) + + for position, item in enumerate(("row-a", "row-b", "row-c")): + page.locator(f"#scoped-form-{position} button").click() + expect(page.locator("#memo-last-value")).to_have_text(f"{item}@{position}") + + +def test_foreach_item_client_state_is_per_item( + memo_app: AppHarness, page: Page +) -> None: + """An unnamed client state var in an inline foreach body is per item. + + The var is constructed once at compile time, so all three rows resolve the + same generated name -- against the scope the loop opens around each item, + which is what makes them independent. The rendered text also interpolates + the loop item, so this covers the item reaching a memoized reader. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + _load_page(page, memo_app) + + expect(page.locator("#scoped-status-0")).to_have_text("closed:row-a") + expect(page.locator("#scoped-status-1")).to_have_text("closed:row-b") + + page.locator("#scoped-toggle-1").click() + + expect(page.locator("#scoped-status-1")).to_have_text("open:row-b") + # The other rows are untouched: each item owns its own slot. + expect(page.locator("#scoped-status-0")).to_have_text("closed:row-a") + expect(page.locator("#scoped-status-2")).to_have_text("closed:row-c") diff --git a/tests/js/client_state.test.js b/tests/js/client_state.test.js index 881dc33f359..d096579ecff 100644 --- a/tests/js/client_state.test.js +++ b/tests/js/client_state.test.js @@ -16,11 +16,13 @@ import { CLIENT_STATE_REF, ClientStateProvider, ClientStateScope, + ScopedValues, createClientStateStore, getClientState, getClientStore, setClientState, useClientState, + useScopedValue, withClientStateScope, } from "$/utils/client_state"; @@ -528,6 +530,171 @@ describe("withClientStateScope", () => { }); }); +describe("scoped values", () => { + /** Render a component that reads one scoped value by name. */ + const reader = (name) => { + const seen = []; + const Reader = () => { + seen.push(useScopedValue(name)); + return null; + }; + return { seen, element: createElement(Reader) }; + }; + + test("a descendant component reads a value it never received as a prop", () => { + // The shape a loop emits: the value lives in context, so a descendant that + // compiled into its own component can still see it. + const item = reader("item0"); + const { unmount } = mount( + createElement(ScopedValues, { values: { item0: "a" } }, item.element), + ); + + expect(item.seen.at(-1)).toBe("a"); + + unmount(); + }); + + test("a nested provider still exposes the outer values", () => { + const outer = reader("outer0"); + const inner = reader("inner0"); + const { unmount } = mount( + createElement( + ScopedValues, + { values: { outer0: "out" } }, + createElement( + ScopedValues, + { values: { inner0: "in" } }, + outer.element, + inner.element, + ), + ), + ); + + expect(outer.seen.at(-1)).toBe("out"); + expect(inner.seen.at(-1)).toBe("in"); + + unmount(); + }); + + test("a nearer provider shadows the same name", () => { + const item = reader("item0"); + const { unmount } = mount( + createElement( + ScopedValues, + { values: { item0: "outer" } }, + createElement( + ScopedValues, + { values: { item0: "inner" } }, + item.element, + ), + ), + ); + + expect(item.seen.at(-1)).toBe("inner"); + + unmount(); + }); + + test("an unprovided name reads undefined rather than throwing", () => { + const item = reader("missing"); + const { unmount } = mount( + createElement(ScopedValues, { values: {} }, item.element), + ); + + expect(item.seen.at(-1)).toBeUndefined(); + + unmount(); + }); + + test("reading outside any provider is undefined", () => { + const item = reader("orphan"); + const { unmount } = mount(item.element); + + expect(item.seen.at(-1)).toBeUndefined(); + + unmount(); + }); + + test("a re-render with new values is seen by descendants", () => { + // A loop re-renders with a new item on every list change, so the provided + // value must not be frozen at first render. + const item = reader("item0"); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const render = (value) => + act(() => { + root.render( + createElement( + ScopedValues, + { values: { item0: value } }, + item.element, + ), + ); + }); + + render("first"); + expect(item.seen.at(-1)).toBe("first"); + + render("second"); + expect(item.seen.at(-1)).toBe("second"); + + act(() => root.unmount()); + container.remove(); + }); + + test("each provided subtree owns its unnamed client state", () => { + // One rendered item is one component instance, so an unnamed var used in a + // loop body must not be shared between items. + const stateProbe = () => { + const renders = { value: undefined, set: undefined }; + const Probe = () => { + const [value, set] = useClientState("", "cs0"); + renders.value = value; + renders.set = set; + return null; + }; + return { renders, element: createElement(Probe) }; + }; + const first = stateProbe(); + const second = stateProbe(); + const { unmount } = mount( + createElement( + ClientStateProvider, + { registry }, + createElement(ScopedValues, { values: { item0: "a" } }, first.element), + createElement(ScopedValues, { values: { item0: "b" } }, second.element), + ), + ); + + act(() => first.renders.set("typed into the first")); + + expect(first.renders.value).toBe("typed into the first"); + expect(second.renders.value).toBe(""); + + unmount(); + }); + + test("sibling providers give each subtree its own value", () => { + // One loop, two items: each item's subtree sees only its own value. + const first = reader("item0"); + const second = reader("item0"); + const { unmount } = mount( + createElement( + "div", + null, + createElement(ScopedValues, { values: { item0: "a" } }, first.element), + createElement(ScopedValues, { values: { item0: "b" } }, second.element), + ), + ); + + expect(first.seen.at(-1)).toBe("a"); + expect(second.seen.at(-1)).toBe("b"); + + unmount(); + }); +}); + test("CLIENT_STATE_REF matches the key state.js reads", () => { // The runtime reaches the store through the object it is handed, so the key // is duplicated on the reading side and has to stay in sync. diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 24fe0f53102..c2a9b1d1bb0 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -20,6 +20,7 @@ get_memoization_strategy, ) from reflex_base.constants.compiler import MemoizationDisposition, MemoizationMode +from reflex_base.constants.state import FIELD_MARKER from reflex_base.plugins import CompileContext, CompilerHooks, PageContext from reflex_base.utils import memo_paths from reflex_base.vars import VarData @@ -126,6 +127,16 @@ class SpecialFormMemoState(BaseState): flag: Field[bool] = field(default=True) value: Field[str] = field(default="a") + @rx.event + def record(self, index: int, form_data: dict): + """Record a submission from a loop item. + + Args: + index: The loop index the form was rendered for. + form_data: The submitted form data. + """ + self.value = f"{index}:{form_data}" + @dataclasses.dataclass(slots=True) class FakePage: @@ -437,6 +448,9 @@ def test_foreach_parent_does_not_absorb_sibling_into_snapshot() -> None: reactive content into the same wide memo body. The parent should now render on the page side, with Foreach and any reactive sibling each getting their own independent wrapper. + + The Foreach snapshot is not opaque: the walker descends into the item body, + so the item's own loop-var consumer gets a third, independent wrapper. """ ctx, _page_ctx = _compile_single_page( lambda: rx.box( @@ -455,7 +469,7 @@ def test_foreach_parent_does_not_absorb_sibling_into_snapshot() -> None: ] wrapped_types = {type(definition.component) for definition in wrapped_definitions} - assert len(wrapped_definitions) == 2 + assert len(wrapped_definitions) == 3 assert Box not in wrapped_types foreach_definition = next( @@ -468,16 +482,25 @@ def test_foreach_parent_does_not_absorb_sibling_into_snapshot() -> None: is MemoizationStrategy.SNAPSHOT ) - bare_definition = next( + bare_definitions = [ definition for definition in wrapped_definitions if isinstance(definition.component, Bare) - ) - assert ( - get_memoization_strategy(bare_definition.component) + ] + assert len(bare_definitions) == 2 + assert all( + get_memoization_strategy(definition.component) is MemoizationStrategy.PASSTHROUGH + for definition in bare_definitions ) - assert bare_definition is not foreach_definition + # One reads app state on the page side, the other reads the loop item from + # the scope the Foreach provides around each rendered item. + bare_contents = { + str(cast(Bare, definition.component).contents) + for definition in bare_definitions + } + assert any("items_rx_state_.length" in contents for contents in bare_contents) + assert any(contents == f"item{FIELD_MARKER}" for contents in bare_contents) def test_common_memoization_snapshot_helper_classifies_snapshot_cases() -> None: @@ -2474,3 +2497,88 @@ def page() -> Component: assert "withClientStateScope" not in code, ( f"auto-memo wrapper {path} must not open a client state scope" ) + + +def test_foreach_item_event_handler_reaches_the_loop_index() -> None: + """A hoisted item handler reads the loop index from the item's scope. + + Regression for reflex-dev/reflex#3210: the ``on_submit`` callback of a form + rendered inside an ``rx.foreach`` compiles into a ``useCallback`` that the + compiler lifts out of the ``.map`` body, so the callback parameter the loop + index used to render as was not in scope and the page threw + ``ReferenceError: index is not defined``. The index now renders as a + ``useScopedValue`` read inside the handler's own memo module. + """ + from reflex.compiler.compiler import compile_memo_components + + ctx, page_ctx = _compile_single_page( + lambda: rx.vstack( + rx.foreach( + Var.range(3), + lambda index: rx.form( + rx.input(name="input"), + on_submit=lambda form_data: SpecialFormMemoState.record( + index, form_data + ), + ), + ) + ) + ) + + files, _imports = compile_memo_components( + memos=tuple(ctx.auto_memo_components.values()), + ) + code = "\n".join(memo_code for _path, memo_code in files) + + handler = next( + block for block in code.split("export const ") if "handleSubmit" in block + ) + index_read = re.search(r'const (\w+) = useScopedValue\("(index\w*)"\)', handler) + assert index_read is not None, f"no scoped index read in the handler\n{handler}" + local, provided = index_read.groups() + # The handler sends the scoped read, not the map callback parameter. + assert f'["index"] : {local}' in handler + + # ... and the loop provides that exact name around each rendered item. + foreach_block = next( + block for block in code.split("export const ") if "Array.prototype.map" in block + ) + assert f"{provided}:{provided}" in foreach_block + # The read has to happen below the provider, which means in a module of its + # own: hooks are hoisted to the top of whichever component they land in, so + # a read in the module that *renders* the provider would sit above it. + assert handler is not foreach_block, "the handler must be its own memo module" + # Inside the loop body the callback parameter of the same name shadows any + # hoisted read, so inline uses see the real per-item value. + assert f"(({provided}," in foreach_block + + # The page itself stays free of the loop scope. + assert "useScopedValue" not in (page_ctx.output_code or "") + + +def test_memo_wrapper_carries_the_wrapped_component_key() -> None: + """An auto-memo wrapper takes the key of the component it replaces. + + The key belongs to the element the parent renders. Left on the memo body it + does nothing, so a keyed item inside a ``rx.foreach`` would silently fall + back to positional identity once its root became a wrapper. + """ + from reflex.compiler.compiler import compile_memo_components + + ctx, _page_ctx = _compile_single_page( + lambda: rx.box( + rx.foreach( + SpecialFormMemoState.items, + lambda item: rx.el.div( + Bare.create(SpecialFormMemoState.value), key=item + ), + ) + ) + ) + + files, _imports = compile_memo_components( + memos=tuple(ctx.auto_memo_components.values()), + ) + code = "\n".join(memo_code for _path, memo_code in files) + + assert f"jsx(ScopedValues,{{key:item{FIELD_MARKER}," in code diff --git a/tests/units/components/core/test_foreach.py b/tests/units/components/core/test_foreach.py index cec2db1c95a..aecc3a04aff 100644 --- a/tests/units/components/core/test_foreach.py +++ b/tests/units/components/core/test_foreach.py @@ -325,3 +325,79 @@ def test_optional_list(): ForEachState.optional_dict_value, lambda color: text(color[0], color[1]), ) + + +def test_foreach_wraps_each_item_in_a_scope_provider(): + """Each rendered item is wrapped in ``ScopedValues``, keyed by index. + + The provider is the element the ``.map`` yields, so it -- not the item root + -- carries the React key, and it publishes the item and index by name for + descendants that compile into their own components. + """ + component = foreach(ForEachState.colors_list, lambda color: text(color)) + rendered = str(component) + + arg_name = f"color{FIELD_MARKER}" + assert "jsx(ScopedValues,{key:index_" in rendered + assert f"values:{{{arg_name}:{arg_name},index_" in rendered + # The provider sits between the map callback and the item's subtree. + assert rendered.index("jsx(ScopedValues,") < rendered.index("RadixThemesText") + + +def test_foreach_loop_vars_read_from_the_enclosing_scope(): + """Loop vars render as a context read, not as the map callback parameter. + + Regression for reflex-dev/reflex#3210: a loop var used to compile to the + ``.map`` callback's parameter name, which went out of scope the moment + anything referencing it was hoisted into its own function -- a + ``useCallback``'d event handler, or a subtree lifted into its own memo + module. Reading by name from context works wherever the compiler puts the + consumer. + """ + tag = foreach( + ForEachState.colors_list, lambda color, index: text(color, index) + )._render() + + arg_var = tag.get_arg_var() + index_var = tag.get_index_var() + + # The hook declares the same identifier the map callback binds, so the + # parameter shadows it inside the loop body and the context read applies + # everywhere else. + for var, name in ( + (arg_var, f"color{FIELD_MARKER}"), + (index_var, f"index{FIELD_MARKER}"), + ): + assert str(var) == name + var_data = var._get_all_var_data() + assert var_data is not None + assert list(var_data.hooks) == [f'const {name} = useScopedValue("{name}")'] + assert dict(var_data.imports).keys() == {"$/utils/client_state"} + + +@pytest.mark.parametrize( + ("key", "expected"), + [ + (lambda color: color, f"color{FIELD_MARKER}"), + (lambda _color: "literal", '"literal"'), + (lambda _color: 7, "7"), + ], + ids=["var", "string", "int"], +) +def test_foreach_lifts_an_explicit_item_key_to_the_provider(key, expected): + """An explicit ``key`` on the item becomes the provider's key. + + The provider is what React reconciles the list by, so a key left on the + item root would give the list positional identity and the explicit key + would do nothing. It is rendered as a JS value, not pasted in as source: a + plain string key emitted bare would be a reference to an undefined name. + + Args: + key: Builds the key to pass, from the loop var. + expected: The JS the provider's key must render as. + """ + component = foreach( + ForEachState.colors_list, lambda color: text(color, key=key(color)) + ) + + assert f"jsx(ScopedValues,{{key:{expected}," in str(component) From fca7ce7e9c19f45cf17c9ae483eaeb3a1b5e9725 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 20:49:48 +0000 Subject: [PATCH 9/9] fix(client_state): carry a Var default's hooks and imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- docs/library/dynamic-rendering/foreach.md | 32 ++++- docs/wrapping-react/overview.md | 9 +- .../src/reflex_base/client_state.py | 6 +- .../integration/tests_playwright/test_memo.py | 109 ++++++++++++++++++ tests/units/compiler/test_memoize_plugin.py | 44 +++++++ tests/units/reflex_base/test_client_state.py | 55 +++++++++ 6 files changed, 251 insertions(+), 4 deletions(-) diff --git a/docs/library/dynamic-rendering/foreach.md b/docs/library/dynamic-rendering/foreach.md index e8d39adc65d..6e28f7bd830 100644 --- a/docs/library/dynamic-rendering/foreach.md +++ b/docs/library/dynamic-rendering/foreach.md @@ -249,11 +249,41 @@ Client state works the same way: an unnamed `rx.client_state` var in a def expandable_row(item: rx.Var[str]) -> rx.Component: expanded = rx.client_state(False) # one per rendered row return rx.vstack( - rx.button(item, on_click=expanded.set(~expanded.value)), + rx.button(item, on_click=expanded.set(lambda prev: ~prev)), rx.cond(expanded.value, rx.text(f"details for {item}")), ) ``` +The default can be the loop item or index, which seeds each row from its own +value: + +```python +def counter_row(item: rx.Var[str], index: rx.Var[int]) -> rx.Component: + count = rx.client_state(index) # row N starts at N + return rx.hstack( + rx.text(item), + rx.heading(count.value), + rx.button("+", on_click=count.set(lambda prev: prev + 1)), + ) +``` + +A default is a *seed*: it is read once, when the row first claims the slot, so a +later change to the var does not reset a row that has already been edited. To +push a new value in, set it explicitly -- `on_mount=count.set(index)`, or on a +`rx.fragment(key=..., on_mount=...)` when you want the reset keyed to something. + +Loops nest, and each level gets its own scope. A nested body can read an +*enclosing* loop's item and index, as long as it does not reuse their names -- +the same rule Python already imposes, since an inner argument of the same name +shadows the outer one: + +```python +rx.foreach( + State.rows, + lambda row: rx.foreach(row, lambda cell: rx.text(f"{row[0]}/{cell}")), +) +``` + By default each item is keyed by its position in the list. Pass `key=` on the item to key by identity instead, which is what preserves a row's DOM state (a typed-in value, focus, an in-flight animation) when the list is reordered: diff --git a/docs/wrapping-react/overview.md b/docs/wrapping-react/overview.md index 0f78a83483e..3fd90248507 100644 --- a/docs/wrapping-react/overview.md +++ b/docs/wrapping-react/overview.md @@ -171,17 +171,22 @@ rx.vstack(counter(), counter(), counter()) # three independent counters ``` Each item rendered by `rx.foreach` is its own scope too, so an unnamed var used in a -loop body is per item: +loop body is per item, and can be seeded from the loop item or index: ```python -def row(item: rx.Var[str]) -> rx.Component: +def row(item: rx.Var[str], index: rx.Var[int]) -> rx.Component: expanded = rx.client_state(False) # one per rendered row + count = rx.client_state(index) # row N starts at N ... rx.foreach(State.items, row) ``` +A default is read once, when the scope first claims the name, so it seeds the state +rather than tracking the var. Set the value explicitly (`on_mount=count.set(index)`) +when you need it to follow. + Pass `prefix=` to make generated names readable in the compiled output: `rx.client_state(0, prefix="counter")`. diff --git a/packages/reflex-base/src/reflex_base/client_state.py b/packages/reflex-base/src/reflex_base/client_state.py index 6400fbcde5d..2f01d7f5cb3 100644 --- a/packages/reflex-base/src/reflex_base/client_state.py +++ b/packages/reflex-base/src/reflex_base/client_state.py @@ -347,7 +347,11 @@ def create( _is_global=is_global, _var_type=default_var._var_type, _var_data=VarData.merge( - default_var._var_data, + # ``_get_all_var_data``, not ``._var_data``: a derived or cast + # default (a loop var, a state var read) keeps its hooks and + # imports on the var it wraps, and dropping them compiles the + # default's identifier into a dangling reference. + default_var._get_all_var_data(), VarData( hooks=hooks, imports=_CLIENT_STATE_IMPORT, diff --git a/tests/integration/tests_playwright/test_memo.py b/tests/integration/tests_playwright/test_memo.py index 8c1d5dd624d..14d0a601d18 100644 --- a/tests/integration/tests_playwright/test_memo.py +++ b/tests/integration/tests_playwright/test_memo.py @@ -29,6 +29,7 @@ class TreeNode(TypedDict): class MemoState(rx.State): last_value: str = "" order: list[str] = ["row-a", "row-b", "row-c"] + grid: list[list[str]] = [["a0", "a1"], ["b0", "b1"]] tree: TreeNode = TreeNode( name="root", children=[ @@ -98,6 +99,9 @@ def scoped_row(item: rx.Var[str], position: rx.Var[int]) -> rx.Component: # read into its own memo -- so each one only works if it can reach the # loop item from the scope the loop provides around the item. opened = rx.client_state(False, prefix="opened") + # Seeded from a loop var: the default is a cast Var whose declaration + # has to travel into every module that reads the slot. + count = rx.client_state(position, prefix="seeded") return rx.hstack( rx.form( rx.el.button("submit", type="submit"), @@ -113,6 +117,41 @@ def scoped_row(item: rx.Var[str], position: rx.Var[int]) -> rx.Component: rx.cond(opened.value, f"open:{item}", f"closed:{item}"), id=f"scoped-status-{position}", ), + rx.text(count.value, id=f"scoped-count-{position}"), + rx.el.button( + "bump", + id=f"scoped-bump-{position}", + on_click=count.set(lambda prev: prev + 1), + ), + ) + + def nested_grid() -> rx.Component: + # Distinct names, so the leaf reads the outer item by walking out past + # the inner loop's provider. + return rx.box( + rx.foreach( + MemoState.grid, + lambda row: rx.foreach( + row, + lambda cell: rx.text(f"{row[0]}/{cell}", class_name="nested-cell"), + ), + ), + id="nested-grid", + ) + + def shadowed_grid() -> rx.Component: + # Both loops name their arg the same. Python shadows the outer binding + # inside the inner lambda, and the compiled output has to shadow it the + # same way: each level renders its own value. + return rx.box( + rx.foreach( + MemoState.grid, + lambda v: rx.box( + rx.text(v[0], class_name="shadowed-head"), + rx.foreach(v, lambda v: rx.text(v, class_name="shadowed-cell")), + ), + ), + id="shadowed-grid", ) def index() -> rx.Component: @@ -144,6 +183,8 @@ def index() -> rx.Component: rx.foreach(MemoState.order, scoped_row), id="scoped-rows", ), + nested_grid(), + shadowed_grid(), ) app = rx.App() @@ -343,3 +384,71 @@ def test_foreach_item_client_state_is_per_item( # The other rows are untouched: each item owns its own slot. expect(page.locator("#scoped-status-0")).to_have_text("closed:row-a") expect(page.locator("#scoped-status-2")).to_have_text("closed:row-c") + + +def test_foreach_item_client_state_seeded_from_the_loop_index( + memo_app: AppHarness, page: Page +) -> None: + """A client state var defaulting to a loop var is seeded per item. + + The default is a cast ``Var`` whose ``useScopedValue`` declaration lives on + the var it wraps; dropping it compiled every consumer to + ``useClientState(, …)`` with nothing declaring ````, so each row + seeded from ``undefined``. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + _load_page(page, memo_app) + + for position in range(3): + expect(page.locator(f"#scoped-count-{position}")).to_have_text(str(position)) + + page.locator("#scoped-bump-1").click() + + expect(page.locator("#scoped-count-1")).to_have_text("2") + # Seeded per item, and independent of each other. + expect(page.locator("#scoped-count-0")).to_have_text("0") + expect(page.locator("#scoped-count-2")).to_have_text("2") + + +def test_nested_foreach_leaf_reads_both_loop_scopes( + memo_app: AppHarness, page: Page +) -> None: + """A leaf in a nested loop reaches the outer item by walking outward. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + _load_page(page, memo_app) + + cells = page.locator("#nested-grid .nested-cell") + expect(cells).to_have_count(4) + expect(cells).to_have_text(["a0/a0", "a0/a1", "b0/b0", "b0/b1"]) + + +def test_nested_foreach_with_shadowed_names_renders_each_level( + memo_app: AppHarness, page: Page +) -> None: + """Nested loops reusing one parameter name each render their own values. + + Reusing the name is only expressible in Python by shadowing the outer + binding, and the scope chain has to resolve it the same way: a read inside + the inner loop binds to the inner provider, and the outer row head -- which + sits above that provider -- keeps binding to the outer one. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + _load_page(page, memo_app) + + expect(page.locator("#shadowed-grid .shadowed-head")).to_have_text(["a0", "b0"]) + expect(page.locator("#shadowed-grid .shadowed-cell")).to_have_text([ + "a0", + "a1", + "b0", + "b1", + ]) diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index c2a9b1d1bb0..d4ce5f3ced4 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -2582,3 +2582,47 @@ def test_memo_wrapper_carries_the_wrapped_component_key() -> None: code = "\n".join(memo_code for _path, memo_code in files) assert f"jsx(ScopedValues,{{key:item{FIELD_MARKER}," in code + + +def test_client_state_seeded_from_a_loop_var_declares_it_in_every_consumer() -> None: + """A ``Var`` client state default reaches each consumer module it seeds. + + A loop var is a cast wrapper whose hooks live on the var it wraps, so the + default used to compile into a bare identifier with nothing declaring it -- + ``useClientState(ix_rx_state_, "cs0")`` above no ``useScopedValue`` line. + """ + from reflex.compiler.compiler import compile_memo_components + + def counter(initial: Any) -> Component: + count = rx.client_state(initial, prefix="seeded") + return rx.hstack( + rx.el.button("-", on_click=count.set(lambda v: v - 1)), + Bare.create(count.value), + ) + + ctx, page_ctx = _compile_single_page( + lambda: rx.box( + rx.foreach(SpecialFormMemoState.items, lambda _x, ix: counter(ix)) + ) + ) + + files, _imports = compile_memo_components( + memos=tuple(ctx.auto_memo_components.values()), + ) + consumers = [ + block + for _path, code in files + for block in code.split("export const ") + if "useClientState(" in block + ] + assert consumers, "no memo module read the client state var" + for block in consumers: + read = re.search(r'const (\w+) = useScopedValue\("(\w+)"\)', block) + assert read is not None, f"nothing declares the seed\n{block}" + local, provided = read.groups() + assert local == provided + assert f"useClientState({local}," in block + # Declared before it is read, since hooks emit in order. + assert block.index(local) < block.index("useClientState(") + + assert "useScopedValue" not in (page_ctx.output_code or "") diff --git a/tests/units/reflex_base/test_client_state.py b/tests/units/reflex_base/test_client_state.py index f125fc63ed5..8fc2e8465bf 100644 --- a/tests/units/reflex_base/test_client_state.py +++ b/tests/units/reflex_base/test_client_state.py @@ -29,6 +29,27 @@ def _hook(cs: ClientStateVar) -> str: return hooks[0] +def _hooks_ending_with_client_state(cs: ClientStateVar) -> list[str]: + """Get every hook a client state var contributes, its own hook last. + + A var whose default is itself a Var contributes that default's hooks too; + they have to be declared before the ``useClientState`` line that reads them. + + Args: + cs: The client state var. + + Returns: + The hook source lines, in emission order. + """ + var_data = cs._get_all_var_data() + assert var_data is not None + hooks = list(var_data.hooks) + assert "useClientState(" in hooks[-1], ( + f"expected the client state hook to come last, got {hooks}" + ) + return hooks + + def _app_wraps(var_data: VarData | None) -> list[tuple[int, str]]: """Summarize the app wraps a VarData carries. @@ -470,6 +491,40 @@ def test_var_default_is_used_directly() -> None: assert cs._var_type is int +def test_derived_var_default_brings_its_hook_and_import() -> None: + """A default whose var data is only reachable through the operation graph. + + ``scoped_loop_var(...).guess_type()`` returns a cast wrapper whose own + ``_var_data`` is ``None`` -- the hook and import live on the var it wraps. + Reading the field directly dropped them, compiling the default's identifier + into a dangling reference. + """ + from reflex_base.components.tags.iter_tag import scoped_loop_var + + cs = client_state(scoped_loop_var("ix_rx_state_", int), name="seeded") + + # The declaration comes first; ``useClientState`` reading it comes last. + assert _hooks_ending_with_client_state(cs) == [ + 'const ix_rx_state_ = useScopedValue("ix_rx_state_")', + 'const [seededRxClientState, setSeeded] = useClientState(ix_rx_state_, "seeded", true)', + ] + var_data = cs._get_all_var_data() + assert var_data is not None + assert "useScopedValue" in str(dict(var_data.imports)) + + +def test_state_var_default_brings_its_state_wiring() -> None: + """A state var default carries its own hook too, not just loop vars.""" + + class ClientStateDefaultState(rx.State): + seed: str = "from state" + + cs = client_state(ClientStateDefaultState.seed, name="seeded_from_state") + + hooks = _hooks_ending_with_client_state(cs) + assert any("useContext(StateContexts" in hook for hook in hooks[:-1]) + + def test_retrieve_with_callback_serializes_the_handler() -> None: """``retrieve(callback)`` embeds the queued-events callback in the payload."""