diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index a749a263e21..12ef19d3d0f 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -128,6 +128,28 @@ 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. + needs: changes + if: needs.changes.outputs.run == 'true' + 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' @@ -162,7 +184,7 @@ jobs: # jobs above directly does not survive: check names follow the matrix and the # display names, and a job that a filter skips must not block the merge. unit-tests-gate: - needs: [changes, unit-tests, unit-tests-macos] + needs: [changes, unit-tests, js-unit-tests, unit-tests-macos] if: always() runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 42fe41dcc0d..278fac13dcd 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,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 @@ -40,3 +42,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 7a472e5f7f9..7e60dc56558 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ uv run python scripts/check_min_deps.py --check-dev-pins [pkg] # fail if pkg uv run reflex-release sync # regenerate the release workflows after editing [tool.reflex-release] or the reflex-release templates 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 @@ -32,6 +33,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 @@ -59,6 +61,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/docs/app/reflex_docs/pages/integrations/integration_gallery.py b/docs/app/reflex_docs/pages/integrations/integration_gallery.py index 4c8fe4a40ff..ea1e0ba08e5 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("All", name="selected_filter") FilterOptions = [ {"name": "AI", "icon": "BotIcon"}, @@ -32,7 +31,7 @@ def integration_filter_button(data: dict): aria_pressed=active, class_name="docs-integration-filter inline-flex min-h-10 items-center justify-center gap-2 rounded-full border px-4 py-2 text-sm font-normal leading-5 transition-colors motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring " + rx.cond(active, active_pill, inactive_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 6b8dd0563ae..c49b06ce461 100644 --- a/docs/app/reflex_docs/templates/docpage/docpage.py +++ b/docs/app/reflex_docs/templates/docpage/docpage.py @@ -10,7 +10,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 * @@ -121,7 +120,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(False) return marketing_button( rx.cond( copied.value, @@ -136,10 +135,10 @@ def copy_to_markdown(text: rx.Var[str]) -> rx.Component: variant="ghost", class_name="justify-start pl-0 text-muted-foreground", 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 42fd21e5c51..5d53d97c9e2 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("", name="icon_search") @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-border-subtle focus:border-border-strong focus:border-1 bg-muted p-[0.5rem_0.75rem] border rounded-xl font-base text-muted-foreground placeholder:text-subtle-foreground 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 d39f5c180c2..69c5df12e30 100644 --- a/docs/wrapping-react/overview.md +++ b/docs/wrapping-react/overview.md @@ -60,7 +60,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 @@ -73,7 +72,7 @@ class ColorPicker(NoSSRComponent): color_picker = ColorPicker.create -ColorPickerState = ClientStateVar.create(default="#db114b", var_name="color") +ColorPickerState = rx.client_state("#db114b", name="color") ``` ```python eval @@ -81,7 +80,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", @@ -124,6 +123,109 @@ 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 outermost page or `@rx.memo` +whose body uses it owns it, so each instance of that component gets its own state, the way +React's `useState` does. Nothing outside the tree can address it, and a page's unnamed vars +start again from their defaults each time the page mounts. Name a var to keep its value +across pages. + +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 page or `@rx.memo` that uses it. This is the same as lifting state +up 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. The owner follows from where the +var is used, never from which component renders first. + +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 +``` + +Inside `rx.foreach`, give the item its own state by making the item function an +`@rx.memo` component; each rendered instance then owns its unnamed vars: + +```python +@rx.memo +def row(item: rx.Var[str]) -> rx.Component: + expanded = rx.client_state(False) # one per rendered row + ... + + +rx.foreach(State.items, lambda item: row(item=item)) +``` + +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 +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("#db114b", name="picker_color") + + +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** 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/news/6936.feature.md b/news/6936.feature.md new file mode 100644 index 00000000000..8be8a0b1755 --- /dev/null +++ b/news/6936.feature.md @@ -0,0 +1 @@ +Add `rx.client_state` for client-only state. A named var (`rx.client_state("", name="query")`) is global: any component can read and write it, and the backend can `push` and `retrieve` it. An unnamed var is owned by the outermost page or `@rx.memo` that uses it, so each `@rx.memo` instance gets its own value and a page's values reset when it unmounts. Setting a var re-renders only the components that read it. `.set` takes a value, a `FunctionVar`, or a lambda (`count.set(lambda v: v + 1)`) and replaces `.set_value`. `rx._x.client_state` remains available with a deprecation warning. diff --git a/packages/reflex-base/news/6936.feature.md b/packages/reflex-base/news/6936.feature.md new file mode 100644 index 00000000000..8be8a0b1755 --- /dev/null +++ b/packages/reflex-base/news/6936.feature.md @@ -0,0 +1 @@ +Add `rx.client_state` for client-only state. A named var (`rx.client_state("", name="query")`) is global: any component can read and write it, and the backend can `push` and `retrieve` it. An unnamed var is owned by the outermost page or `@rx.memo` that uses it, so each `@rx.memo` instance gets its own value and a page's values reset when it unmounts. Setting a var re-renders only the components that read it. `.set` takes a value, a `FunctionVar`, or a lambda (`count.set(lambda v: v + 1)`) and replaces `.set_value`. `rx._x.client_state` remains available with a deprecation warning. 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..30e3d1e5568 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js @@ -0,0 +1,307 @@ +/** + * 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: 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 + * compiler wraps each page and each `@rx.memo` body in a `ClientStateScope` that + * lists the local names the body uses. A scope claims each listed name unless an + * ancestor already owns it, before any descendant renders, so the outermost + * boundary that uses a name owns it whatever the render order. Separate instances + * of a boundary get separate state while everything under one boundary shares. + * Boundaries that only exist as a compiler optimization are not scopes, 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. + */ +import { + createContext, + createElement, + useContext, + useRef, + useSyncExternalStore, +} from "react"; + +/** + * Key under which `state.js` publishes the store on its `refs` object, for + * backend-evaluated code and devtools introspection. + */ +export const CLIENT_STATE_REF = "__client_state"; + +/** + * Create a slot: one piece of client state, with its own subscribers. + * @param value The initial value. + * @returns The slot. + */ +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 scope: a node in the ownership chain. + * @param parent The enclosing scope, or null for a root. + * @returns The scope. + */ +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, + /** + * 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) => root.owned.get(name)?.value, + /** + * 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) => { + root.own(name, undefined).set(value); + }, + }; +}; + +// A slot a boundary claimed before any reader supplied its default. +const UNSET = Symbol("unset"); + +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. 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(); + } + return _clientStore; +}; + +/** The nearest owning scope. Null outside any provider. */ +export const ClientStateScopeContext = createContext(null); + +/** + * 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. 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 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. + * @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 root scope 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) { + // 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(); + } + + return createElement( + ClientStateScopeContext.Provider, + { value: storeRef.current.root }, + children, + ); +} + +/** + * Open a client state scope around a subtree. + * + * Emitted by the compiler around each page and `@rx.memo` body. The names it + * lists are owned here unless an ancestor owns them, 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. + * @param props.owns The local names the boundary's own body uses. + * @returns The provider element. + */ +export function ClientStateScope({ children, owns }) { + const parent = useContext(ClientStateScopeContext); + const scopeRef = useRef(null); + if (scopeRef.current === null || scopeRef.current.parent !== parent) { + const scope = createScope(parent ?? getClientStore().root); + // Claim the names this body uses before any descendant renders, unless an + // enclosing boundary already owns them: the outermost user owns the slot. + for (const name of owns ?? []) { + if (scope.parent.find(name) === undefined) { + scope.own(name, UNSET); + } + } + scopeRef.current = scope; + } + 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. + * @param owns The local names the component's own body uses. + * @returns The wrapped component. + */ +export const withClientStateScope = (Component, owns) => { + const Wrapped = (props) => + createElement(ClientStateScope, { owns }, 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 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, 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 } = bindingRef.current; + if (slot.value === UNSET) { + slot.value = defaultValue; + } + + 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 c4b4481db9b..e929fc02c10 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 @@ -11,6 +11,12 @@ import { useParams, } from "react-router"; import { app, eventLoop } from "$/utils/context-registry"; +import { + CLIENT_STATE_REF, + getClientState, + getClientStore, + setClientState, +} from "$/utils/client_state"; import debounce from "$/utils/helpers/debounce"; import { parseJson } from "$/utils/helpers/json"; import throttle from "$/utils/helpers/throttle"; @@ -55,6 +61,13 @@ const cookies = new Cookies(); // Dictionary holding component references. export const refs = {}; +// The client state store, for code evaluated outside this module's scope: a +// `run_script` body pushing a value, or the devtools console. Client only, so +// a server render never shares a store between requests. +if (typeof document !== "undefined") { + refs[CLIENT_STATE_REF] = getClientStore(); +} + // Array holding pending events to be processed. const event_queue = []; @@ -468,6 +481,46 @@ 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; + } + // Eval'd user code dispatches through addEvents like compiled event triggers + // do; late-bound so a remounted EventLoopProvider is picked up. Declared in + // every scope holding an eval, since that is the only scope chain the eval'd + // code can resolve it from. + const addEvents = (...args) => eventLoop.addEvents(...args); + 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); +}; + /** * Build the update_vars_internal event for a synced localStorage var. * @@ -491,9 +544,10 @@ const syncLocalStorageEvent = ({ key, state_keys }) => { * @param params The params object from useParams */ export const applyEvent = async (event, socket, navigate, params) => { - // Eval'd callback strings (format_queue_events) dispatch through addEvents - // like compiled event triggers do; late-bound so a remounted - // EventLoopProvider is picked up. + // Eval'd user code dispatches through addEvents like compiled event triggers + // do; late-bound so a remounted EventLoopProvider is picked up. Declared in + // every scope holding an eval, since that is the only scope chain the eval'd + // code can resolve it from. const addEvents = (...args) => eventLoop.addEvents(...args); // Handle special events if (event.name == "_redirect") { @@ -613,23 +667,43 @@ export const applyEvent = async (event, socket, navigate, params) => { return; } + // Backend reads and writes go to the shared store directly, so a var that no + // component renders is still reachable. + if (event.name == "_client_state_set") { + setClientState(event.payload.var_name, event.payload.value); + return; + } + + if (event.name == "_client_state_get") { + try { + await applyResultCallback( + event, + getClientState(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) { @@ -646,17 +720,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..34f785f7c9c --- /dev/null +++ b/packages/reflex-base/src/reflex_base/client_state.py @@ -0,0 +1,538 @@ +"""Handle client side state with `useClientState`.""" + +from __future__ import annotations + +import dataclasses +import inspect +import itertools +import json +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, CLIENT_STATE_REF +from reflex_base.event import ( + EventChain, + EventHandler, + EventSpec, + event_args_signature, + 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 +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() + +# 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_$]*$") + + +def _generate_name(prefix: str = _DEFAULT_NAME_PREFIX) -> str: + """Build the name of an unnamed var from a prefix and the shared counter. + + ``$`` separates the two and is not allowed in the prefix, so two generated + names are equal only when both their prefixes and their counters are. + + Args: + prefix: The readable part of the name. + + Returns: + The generated name, a valid javascript identifier. + + Raises: + ValueError: If the prefix contains ``$`` or is not an identifier. + """ + if "$" in prefix: + msg = f"prefix {prefix!r} must not contain '$', which separates it from the counter." + raise ValueError(msg) + 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) + return f"{prefix}${next(_name_counter)}" + + +# 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. +_client_state_store_ref = Var( + _js_expr=f"refs[{json.dumps(CLIENT_STATE_REF)}]", + _var_data=VarData( + imports={f"$/{Dirs.STATE_PATH}": [ImportVar(tag="refs")]}, + ), +) + + +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) + + # The value may read the arguments of the trigger it is attached to, + # which the wrapper then has to declare. + return ArgsFunctionOperationBuilder.create( + args_names=event_args_signature(value_var), + 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 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) + + 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, + self._state_name, + self._is_global, + )) + + @classmethod + def create( + cls, + default: Any = NoValue, + *, + name: str | None = None, + prefix: str = _DEFAULT_NAME_PREFIX, + ) -> ClientStateVar: + """Create a client state Var that can be accessed and updated on the client. + + 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 is owned by the outermost page or ``@rx.memo`` body that uses + it, so each instance of that component gets its own state -- like + React's ``useState`` -- and nothing outside the tree can address it. + A page owns its vars only while it is mounted. + + 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: + default: The default value of the variable. + 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. Must not contain ``$``. + Ignored when ``name`` is given. + + Returns: + ClientStateVar + + Raises: + ValueError: If name or prefix is not a valid identifier string. + """ + # Named -> global, unnamed -> tree-scoped. + is_global = name is not None + var_name = _generate_name(prefix) if name is None else name + if isinstance(var_name, Var): + msg = ( + "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 = "name must be a string." + raise ValueError(msg) + if not _VALID_NAME.match(var_name): + msg = ( + f"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: + # 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: + 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:]}" + # 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({args})": 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, + _is_global=is_global, + _var_type=default_var._var_type, + _var_data=VarData.merge( + # ``_get_all_var_data``, not ``._var_data``: a derived or cast + # default (a state var read, a cast var) 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, + app_wraps=app_wraps, + ), + ), + _escape_var_data=VarData( + imports=_CLIENT_STATE_ESCAPE_IMPORT, + app_wraps=app_wraps, + ), + ) + + @property + def value(self) -> Var: + """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.10.0", + 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._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})", + _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._is_global: + msg = _NOT_GLOBAL_MSG.format(action="set the value from outside the tree") + 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._is_global: + msg = _NOT_GLOBAL_MSG.format(action="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._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 + # 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), + 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..69b88b4b09a --- /dev/null +++ b/packages/reflex-base/src/reflex_base/components/client_state_context.py @@ -0,0 +1,128 @@ +"""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 + +import re +from collections.abc import Iterable + +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 LiteralVar, 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 +# EventLoopProvider. +CLIENT_STATE_APP_WRAP_PRIORITY = 50 + + +# The hook ``ClientStateVar.create`` emits for a local var ends with its quoted +# name; a global one ends with ``, true)`` instead. +_LOCAL_HOOK = re.compile(r'= useClientState\(.*, "([A-Za-z_$][A-Za-z0-9_$]*)"\)$') + + +def local_client_state_names(hooks: Iterable[str]) -> list[str]: + """Get the local client state names a body's hooks use. + + Args: + hooks: The hook strings of one compiled body. + + Returns: + The names, sorted so the emitted code is stable. + """ + return sorted({ + match.group(1) + for hook in hooks + if hook.endswith('")') and (match := _LOCAL_HOOK.search(hook)) + }) + + +class ClientStateScope(Component): + """A client state scope that claims the local names its body uses.""" + + library = f"$/{Dirs.CLIENT_STATE_PATH}" + tag = "ClientStateScope" + + # The local names used by this boundary's own body. + owns: Var[list[str]] + + +def scope_page(component: Component) -> Component: + """Make a page the owner of the local client state names its body uses. + + The page is the outermost boundary, so memos below it that use the same + names share its slots, and the slots reset when the page unmounts. + + Args: + component: The evaluated page component. + + Returns: + The page wrapped in a scope, or unchanged when it uses no local names. + """ + if owns := local_client_state_names(component._get_all_hooks()): + return ClientStateScope.create(component, owns=owns) + return component + + +def scoped_memo_wrapper(inner: Var | None, owns: list[str]) -> 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. + owns: The local names used by the memo body. + + Returns: + A function Var suitable for ``MemoComponentDefinition.wrapper``. + """ + scope_import = VarData( + imports={f"$/{Dirs.CLIENT_STATE_PATH}": [ImportVar(tag="withClientStateScope")]} + ) + owns_js = LiteralVar.create(owns) + if inner is None: + return Var( + _js_expr=f"((Component) => withClientStateScope(Component, {owns_js!s}))", + _var_data=scope_import, + ).to(FunctionVar) + return Var( + _js_expr=f"((Component) => withClientStateScope({inner!s}(Component), {owns_js!s}))", + _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.""" + + 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/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index e447bb6e400..eb5e74999fe 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -346,6 +346,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 # Set for definitions the compiler's auto-memoize pass creates (see # ``create_passthrough_component_memo``). Instances of such a definition # are the auto-memo boundary itself, so the pass must not wrap them again. @@ -2358,6 +2363,7 @@ def _memo_impl( _rest_target_fields=rest_target_fields, _runtime_inferred_params=frozenset(missing_params), wrapper=wrapper, + is_instance_boundary=True, ) memo_callable = _create_component_wrapper(definition) else: diff --git a/packages/reflex-base/src/reflex_base/constants/base.py b/packages/reflex-base/src/reflex_base/constants/base.py index c56e0c2be8e..56fc57049f5 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..36ac2ec8629 100644 --- a/packages/reflex-base/src/reflex_base/constants/state.py +++ b/packages/reflex-base/src/reflex_base/constants/state.py @@ -14,3 +14,12 @@ 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" +# Key on the frontend `refs` object that the mounted ClientStateProvider +# publishes its store on. This is the one Python-side definition; the frontend's +# is `CLIENT_STATE_REF` in `.templates/web/utils/client_state.js`, which every +# other frontend reader imports. The two are asserted equal by +# `tests/js/client_state.test.js`, so a rename on either side fails loudly. +CLIENT_STATE_REF = "__client_state" diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 7f6c6ffe43e..ae45e1dce89 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -57,7 +57,7 @@ value_inside_optional, ) from reflex_base.vars import VarData -from reflex_base.vars.base import LiteralVar, Var, _owner_state +from reflex_base.vars.base import LiteralVar, ToOperation, Var, _operands, _owner_state from reflex_base.vars.function import ( ENCODE_URI_COMPONENT, ArgsFunctionOperation, @@ -2292,6 +2292,90 @@ def resolve_annotation(annotations: dict[str, Any], arg_name: str, spec: ArgsSpe return annotation +@dataclasses.dataclass( + eq=False, + frozen=True, + slots=True, +) +class EventArgVar(Var): + """A placeholder for one argument of an event trigger. + + ``parse_args_spec`` passes these to the args spec, and through it to a + lambda event function, so a value built from them can be taken apart later + to find which of the trigger's arguments it reads. + """ + + # The JS names of every argument of the trigger, in order; this one's + # position is its index in it. + _signature: tuple[str, ...] = dataclasses.field(default=()) + + +def event_arg_placeholder(var: Var) -> EventArgVar | None: + """Get the trigger-argument placeholder ``var`` is, through any casts. + + Args: + var: The var to look at. + + Returns: + The placeholder, or None when ``var`` is not one. + """ + while isinstance(var, ToOperation): + var = var._original + return var if isinstance(var, EventArgVar) else None + + +def _trigger_args_read(value: Var) -> list[EventArgVar]: + """Find every trigger-argument placeholder a value is built from. + + Walks the value's operations like ``get_leaf_vars`` does, except that a + function is always searched through its body: a placeholder closed over by + a nested lambda still has to be declared, whatever else the body reads. + + Args: + value: The value to take apart. + + Returns: + The placeholders, in order of first appearance. + """ + found: dict[str, EventArgVar] = {} + stack = [value] + while stack: + current = stack.pop() + while isinstance(current, ToOperation): + current = current._original + if isinstance(current, EventArgVar): + found.setdefault(current._js_expr, current) + elif isinstance( + current, (ArgsFunctionOperation, ArgsFunctionOperationBuilder) + ) and isinstance(current._return_expr, Var): + stack.append(current._return_expr) + elif (operands := _operands(current)) is not None: + stack.extend(LiteralVar.create(operand) for operand in reversed(operands)) + return list(found.values()) + + +def event_args_signature(value: Var) -> tuple[str, ...]: + """Get the trigger arguments a function built around ``value`` must declare. + + An event chain calls such a function with every argument of the trigger, + positionally, so the declaration has to cover each argument up to the last + one ``value`` reads, in the trigger's order. + + Args: + value: The value a trigger argument may have been built into. + + Returns: + The JS parameter names to declare, possibly empty. + """ + last = -1 + signature: tuple[str, ...] = () + for arg in _trigger_args_read(value): + position = arg._signature.index(arg._js_expr) + if position > last: + last, signature = position, arg._signature + return signature[: last + 1] + + @lru_cache def parse_args_spec(arg_spec: ArgsSpec | Sequence[ArgsSpec]): """Parse the args provided in the ArgsSpec of an event trigger. @@ -2310,15 +2394,16 @@ def parse_args_spec(arg_spec: ArgsSpec | Sequence[ArgsSpec]): annotations = [get_type_hints(arg_spec)] spec = inspect.getfullargspec(arg_spec) + signature = tuple(f"_{l_arg}" for l_arg in spec.args) return list( arg_spec(*[ - Var(f"_{l_arg}").to( + EventArgVar(_js_expr=name, _signature=signature).to( unwrap_var_annotation( resolve_annotation(annotations[0], l_arg, spec=arg_spec) ) ) - for l_arg in spec.args + for l_arg, name in zip(spec.args, signature, strict=True) ]) ), annotations @@ -3340,6 +3425,9 @@ def wrapper( check_fn_match_arg_spec = staticmethod(check_fn_match_arg_spec) resolve_annotation = staticmethod(resolve_annotation) parse_args_spec = staticmethod(parse_args_spec) + EventArgVar = EventArgVar + event_arg_placeholder = staticmethod(event_arg_placeholder) + event_args_signature = staticmethod(event_args_signature) args_specs_from_fields = staticmethod(args_specs_from_fields) unwrap_var_annotation = staticmethod(unwrap_var_annotation) diff --git a/packages/reflex-base/src/reflex_base/utils/format.py b/packages/reflex-base/src/reflex_base/utils/format.py index ce9a940e972..0fc6fdbb376 100644 --- a/packages/reflex-base/src/reflex_base/utils/format.py +++ b/packages/reflex-base/src/reflex_base/utils/format.py @@ -525,7 +525,7 @@ def format_queue_events( The resulting code can be passed to interfaces that expect a callback function and when triggered it dispatches the events through `addEvents`. The name resolves via the attached imports in compiled modules and via a - local alias in the `applyEvent` eval context. + local alias in the `applyResultCallback` eval context. Args: events: The events to queue. diff --git a/packages/reflex-components-internal/news/6936.misc.md b/packages/reflex-components-internal/news/6936.misc.md new file mode 100644 index 00000000000..08266bc5894 --- /dev/null +++ b/packages/reflex-components-internal/news/6936.misc.md @@ -0,0 +1 @@ +Use `rx.client_state` in the form blocks. diff --git a/packages/reflex-components-internal/pyproject.toml b/packages/reflex-components-internal/pyproject.toml index 900e055e89c..4f84d5eb6df 100644 --- a/packages/reflex-components-internal/pyproject.toml +++ b/packages/reflex-components-internal/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ "Programming Language :: Python :: 3.15", ] dependencies = [ - "reflex >= 0.10.1.dev0", + "reflex >= 0.10.2.dev0", ] [tool.hatch.version] 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 809ca911cc0..fe13e60908a 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, event_sequence -from reflex.experimental.client_state import ClientStateVar from reflex.vars.base import get_unique_variable_name from reflex_components_internal.blocks.telemetry.posthog import ( PHONE_NUMBER_KEY, @@ -23,8 +22,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("", 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$).*$" @@ -411,7 +410,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", @@ -470,10 +469,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 f26c6eeeb8f..7ff7e84473e 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, event_sequence -from reflex.experimental.client_state import ClientStateVar from reflex.vars.base import get_unique_variable_name from reflex_components_internal.blocks.telemetry.posthog import ( PHONE_NUMBER_KEY, @@ -21,9 +20,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("", 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$).*$" @@ -417,7 +416,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", ), ), @@ -435,8 +434,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 e5a9a57582d..99003b2297a 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": "f2a9cf6db58d169289fbc76de6813300", "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f170ac685b6ba5892370166c80684db3", - "reflex/__init__.pyi": "23c090b0294d3a91e59f447f36a7a37f", + "reflex/__init__.pyi": "067a60e22fb825072f2f0b690662da5a", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", - "reflex/experimental/memo.pyi": "87e485038dc79f66b2ab15144845ef1e" + "reflex/experimental/memo.pyi": "71e62a97412c73225fac44658ca0e06c" } diff --git a/pyproject.toml b/pyproject.toml index 1eef5b8d253..f10560bbfe4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -328,7 +328,7 @@ markers = [ ] [tool.codespell] -skip = "*.html, examples, *.pyi, */nba.csv, */olympic-winners.json, packages/reflex-build-sdk/openapi.json, uv.lock, */bun.lock, node_modules" +skip = "*.html, examples, *.pyi, */nba.csv, */olympic-winners.json, packages/reflex-build-sdk/openapi.json, uv.lock, */bun.lock, */package-lock.json, node_modules" ignore-words-list = "te, TreeE, selectin" diff --git a/reflex/__init__.py b/reflex/__init__.py index ea1775c275f..75cb5a1328b 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -149,6 +149,11 @@ ], "reflex_components_sonner.toast": ["toast"], "reflex_base.components.props": ["PropsBase"], + "reflex_base.client_state": [ + "ClientStateSetter", + "ClientStateVar", + "client_state", + ], "reflex_components_core.datadisplay.logo": ["logo"], "reflex_components_gridjs": ["data_table"], "reflex_components_moment": ["MomentDelta", "moment", "moment_provider"], diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index dab3bfb578f..c89c5c0fffb 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -13,6 +13,7 @@ from reflex_base import constants, otel from reflex_base.components.app_wraps import collect_var_app_wraps_in_subtree +from reflex_base.components.client_state_context import scope_page from reflex_base.components.component import ( BaseComponent, Component, @@ -1037,6 +1038,7 @@ def compile_unevaluated_page( try: # Generate the component if it is a callable. component = into_component(page.component) + component = scope_page(component) component._add_style_recursive(style or {}, theme) diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py index f8855b8a7b1..8564f7c1c73 100644 --- a/reflex/compiler/plugins/builtin.py +++ b/reflex/compiler/plugins/builtin.py @@ -7,6 +7,7 @@ from typing import Any from reflex_base.components.app_wraps import _ingest_component_var_app_wraps +from reflex_base.components.client_state_context import scope_page from reflex_base.components.component import BaseComponent, Component, ComponentStyle from reflex_base.config import get_config from reflex_base.plugins import CompileContext, PageContext, PageDefinition, Plugin @@ -40,6 +41,7 @@ def eval_page( try: component = compiler.into_component(page_fn) + component = scope_page(component) component = Fragment.create(component) title = getattr(page, "title", None) diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 8490845954c..7bc58713544 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -18,6 +18,10 @@ from urllib.parse import urlparse from reflex_base import constants +from reflex_base.components.client_state_context import ( + local_client_state_names, + scoped_memo_wrapper, +) from reflex_base.components.component import BaseComponent, Component, ComponentStyle from reflex_base.components.dynamic import _bundle_imports from reflex_base.components.memo import ( @@ -627,6 +631,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, local_client_state_names(hooks)) 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 1d0b69c1bc1..a458f634d12 100644 --- a/reflex/experimental/client_state.py +++ b/reflex/experimental/client_state.py @@ -1,332 +1,110 @@ -"""Handle client side state with `useState`.""" +"""Deprecated `ClientStateVar` entry point. + +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 signatures working and is where the deprecation +notices live, so the new API carries none of them. +""" 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_key(var_name: str) -> str: - """Get the key of a ClientStateVar's slot in the global ``refs`` object. - - Args: - var_name: The name of the variable. +from reflex_base.client_state import ClientStateVar as _ClientStateVar +from reflex_base.client_state import NoValue as NoValue +from reflex_base.client_state import _generate_name +from reflex_base.utils import console - Returns: - The key under which ``refs`` holds the ClientStateVar ref slot. - """ - return f"_client_state_{var_name}" +__all__ = ["ClientStateVar", "NoValue", "client_state"] -def _client_state_ref(var_name: str) -> Var: - """Get the ref accessor Var for a ClientStateVar. +def _deprecate(feature_name: str) -> None: + """Warn that a legacy client state entry point was used. Args: - var_name: The name of the variable. - - Returns: - A Var that accesses the ClientStateVar ref slot, carrying the - ``refs`` import from ``$/utils/state``. + feature_name: The legacy entry point. """ - return Var( - _js_expr=f"refs[{_client_state_key(var_name)!r}]", - _var_data=VarData(imports=_refs_import), + console.deprecate( + feature_name=feature_name, + reason=( + "Use rx.client_state(default, name=...) instead. Naming a var makes " + "it global; an unnamed var is owned by the outermost page or " + "`@rx.memo` that uses it, so `global_ref` is no longer needed." + ), + deprecation_version="0.10.0", + removal_version="1.0", ) -def _client_state_ref_dict(var_name: str) -> Var: - """Get the per-instance ref-dict accessor Var for a ClientStateVar. +def _legacy_name(var_name: str | None, global_ref: bool | Any) -> str | None: + """Map the legacy ``(var_name, global_ref)`` pair onto the naming rules. + + ``global_ref`` defaults to true and an omitted ``var_name`` is generated, so + a legacy var is global unless ``global_ref=False`` is passed, in which case + its name is dropped and the var is scoped. Args: - var_name: The name of the variable. + var_name: The legacy name. + global_ref: The legacy global flag, or ``NoValue`` when not given. Returns: - A Var that accesses the ClientStateVar's per-instance ref dict, - carrying the ``refs`` import from ``$/utils/state``. + The name to create the var with: ``None`` scopes it, anything else + makes it global. """ - 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.""" + if global_ref is not NoValue and not global_ref: + return None + if var_name is None: + # Drawn from the same generator as scoped names, so the two never collide. + return _generate_name() + return var_name - # 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) +class ClientStateVar(_ClientStateVar): + """The promoted class, with ``create`` taking the original argument order.""" - 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, - )) + # No new fields, so no dataclass decorator: ``slots=True`` would rebuild + # the class and leave ``super()`` bound to the discarded one. + __slots__ = () @classmethod - def create( + def create( # pyright: ignore [reportIncompatibleMethodOverride] 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. + global_ref: bool | Any = NoValue, + ) -> _ClientStateVar: + """Create a client state var using the original argument order. Args: - var_name: The name of the variable. + var_name: The name of the variable. Naming it makes the var global. 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 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()}" - var_ref = _client_state_ref(var_name) - initial_value = str(default_var) - if global_ref: - # A component mounting after a set renders the shared value, and React - # skips a setter call equal to the current state, so starting from the - # default would swallow a later set back to the default. A set or pushed - # null is a value too, so only a missing slot falls back to the default. - initial_value = ( - f"{_client_state_key(var_name)!r} in refs ? {var_ref!s} : ({initial_value})" - if initial_value - else str(var_ref) - ) - hooks: dict[str, VarData | None] = { - f"const {id_name} = useId()": None, - f"const [{var_name}, {setter_name}] = useState({initial_value})": 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_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._get_all_var_data(), - VarData( - hooks=hooks, - imports=imports, - ), - ), - ) - - @property - def value(self) -> Var: - """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`. + global_ref: Whether the state is 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: - an accessor for the client state variable. + The client state var. """ - 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) + _deprecate("ClientStateVar.create") + return super().create(default, name=_legacy_name(var_name, global_ref)) - 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. +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. - 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. - """ - # A global setter carries the useState/refs hooks so a component that only - # sets the value (e.g. a sibling of the one rendering it) still initializes - # the shared state. A local setter must not: it would silently update a - # private copy of the state that no reader sees. - setter = ( - _client_state_ref(self._setter_name)._replace(merge_var_data=self._var_data) - 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. - Pushing before any component using the value has mounted keeps the value - for the first one that mounts. - - Args: - value: The value to update. - - Returns: - An EventSpec which will push the value when triggered. + Args: + var_name: The name of the variable. Naming it makes the var global. + default: The default value of the variable. + global_ref: Whether the state is app-wide. Scoping now + follows from whether the var is named, so this is only honored to + keep existing callers behaving as they did. - 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) - setter = _client_state_ref(self._setter_name) - shared_value = _client_state_ref(self._getter_name) - # Only a mounted component defines the setter, so without one keep the value - # in the shared slot that the first component to mount starts from. - return run_script( - f"({setter} ?? ((pushed) => {{ {shared_value} = pushed; }}))({value})" - ) + Returns: + The client state var. + """ + _deprecate("rx._x.client_state") + return _ClientStateVar.create(default, name=_legacy_name(var_name, global_ref)) diff --git a/tests/integration/tests_playwright/test_client_state.py b/tests/integration/tests_playwright/test_client_state.py index 706a95635a0..def1769d832 100644 --- a/tests/integration/tests_playwright/test_client_state.py +++ b/tests/integration/tests_playwright/test_client_state.py @@ -1,124 +1,437 @@ -"""Integration tests for ``rx._x.client_state`` shared between sibling components.""" +"""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 ConsoleMessage, Page, expect +from playwright.sync_api import Page, expect from reflex.testing import AppHarness def ClientStateApp(): - """App sharing a global client state var between sibling components.""" + """App exercising ``rx.client_state`` runtime behavior.""" + from reflex_base.vars.function import ArgsFunctionOperationBuilder, FunctionVar + import reflex as rx - pushed = rx._x.client_state(default="initial") + shared = rx.client_state("initial", name="shared") + counter = rx.client_state(0, name="counter") + other = rx.client_state("untouched", name="other") + suffixed = rx.client_state("", name="suffixed") + # Never rendered: only backend handlers touch it. + hidden = rx.client_state("", name="hidden") + # Unnamed, used by a memo and by the page after it: the page owns it. + lifted = rx.client_state(0) + # Unnamed, used at page level on two pages. + page_local = rx.client_state(0) class ClientStateAppState(rx.State): - show: bool = False - default_text: str = "from backend" + retrieved: str = "" + retrieved_hidden: str = "" @rx.event - def reveal(self): - self.show = True + def push_hidden(self): + return hidden.push("secret") @rx.event - def push_value(self): - return pushed.push("pushed") + def retrieve_hidden(self): + return hidden.retrieve(ClientStateAppState.got_hidden) @rx.event - def push_none(self): - return pushed.push(None) + def got_hidden(self, value: str): + self.retrieved_hidden = value - shared = rx._x.client_state(default="initial") - backend_default = rx._x.client_state(default=ClientStateAppState.default_text) - - def index(): - return rx.box( - rx.button("set", on_click=shared.set_value("clicked"), id="setter"), - rx.button("reset", on_click=shared.set_value("initial"), id="resetter"), - rx.button("reveal", on_click=ClientStateAppState.reveal, id="reveal"), - rx.cond( - ClientStateAppState.show, - rx.text(shared.value, id="late-reader"), + @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: + # 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, + on_change=local.set, + id=f"local-input-{label}", ), - rx.text(ClientStateAppState.router.session.client_token, id="token"), + rx.text(local.value, id=f"local-echo-{label}"), ) - def siblings(): - return rx.box( - rx.text(shared.value, id="reader"), - rx.button("set", on_click=shared.set_value("clicked"), id="setter"), - ) + @rx.memo + def lifted_card() -> rx.Component: + return rx.text(lifted.value, class_name="lifted-card") - def backend_default_page(): - return rx.box( + def index() -> rx.Component: + return rx.vstack( rx.input( - value=backend_default.value, - placeholder=ClientStateAppState.default_text, + value=ClientStateAppState.router.session.client_token, read_only=True, - id="backend-reader", + 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( - "set", on_click=backend_default.set_value("changed"), id="setter" + "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"), + # A trigger argument used inside a compound value. + rx.input( + on_change=lambda value: suffixed.set(value.to(str) + "!"), + id="suffix-input", + ), + rx.text(suffixed.value, id="suffix-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"), + rx.button( + "push-hidden", + id="push-hidden", + on_click=ClientStateAppState.push_hidden, ), - ) - - def push_before_mount(): - return rx.box( - rx.button("push", on_click=ClientStateAppState.push_value, id="pusher"), rx.button( - "push none", on_click=ClientStateAppState.push_none, id="none-pusher" + "retrieve-hidden", + id="retrieve-hidden", + on_click=ClientStateAppState.retrieve_hidden, ), - rx.button("reveal", on_click=ClientStateAppState.reveal, id="reveal"), - rx.cond( - ClientStateAppState.show, - rx.text(pushed.value, id="pushed-reader"), + rx.text(ClientStateAppState.retrieved_hidden, id="retrieved-hidden"), + # 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")' + ), ), - rx.text(ClientStateAppState.router.session.client_token, id="token"), + local_input(label="one"), + local_input(label="two"), + # The cards render before the page-level reader of the same var. + lifted_card(), + lifted_card(), + rx.text(lifted.value, id="lifted-page"), + rx.button("lift", id="lift", on_click=lifted.set(100)), + rx.text(page_local.value, id="page-local"), + rx.button( + "page-local-inc", + id="page-local-inc", + on_click=page_local.set(lambda v: v + 1), + ), + rx.link("other", href="/other", id="to-other"), + ) + + def other_page() -> rx.Component: + return rx.vstack( + rx.text(page_local.value, id="other-page-local"), + rx.link("home", href="/", id="to-home"), ) app = rx.App() app.add_page(index) - app.add_page(siblings) - app.add_page(backend_default_page, route="/backend-default") - app.add_page(push_before_mount, route="/push-before-mount") + app.add_page(other_page, route="/other") @pytest.fixture(scope="module") -def client_state_app( - tmp_path_factory: pytest.TempPathFactory, -) -> Generator[AppHarness, None, None]: - """Run the client state app under an AppHarness. +def client_state_app(tmp_path_factory) -> Generator[AppHarness, None, None]: + """Start the client state app. Args: - tmp_path_factory: Pytest fixture for creating temporary directories. + tmp_path_factory: pytest tmp_path_factory fixture. Yields: - The running harness. + The running AppHarness. """ with AppHarness.create( - root=tmp_path_factory.mktemp("client_state"), + root=tmp_path_factory.mktemp("client_state_app"), app_source=ClientStateApp, ) as harness: yield harness -def _collect_page_errors(page: Page) -> list[str]: - """Record uncaught frontend errors raised on the page. +@pytest.fixture +def page(client_state_app: AppHarness, page: Page) -> Page: + """Navigate to the app and wait for hydration. Args: - page: Playwright page. + client_state_app: The running harness. + page: The playwright page. Returns: - A list that is appended to as errors occur. + The page, loaded and hydrated. """ - errors: list[str] = [] - page.on("pageerror", lambda exc: errors.append(str(exc))) - return errors + 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_a_value_computed_from_the_trigger_arg(page: Page) -> None: + """``set(value + "!")`` reads the trigger's argument, not a closed-over one.""" + page.locator("#suffix-input").fill("typed") + expect(page.locator("#suffix-value")).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_backend_only_var_round_trips(page: Page) -> None: + """A var no component renders still holds what the backend pushes.""" + page.locator("#push-hidden").click() + page.locator("#retrieve-hidden").click() + expect(page.locator("#retrieved-hidden")).to_have_text("secret") + + +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_page_owns_a_var_its_memos_also_use(page: Page) -> None: + """The page body uses ``lifted``, so the memo instances below it share its slot. + + The cards render before the page-level reader, so the owner must not depend + on render order. + """ + page.locator("#lift").click() + + expect(page.locator("#lifted-page")).to_have_text("100") + expect(page.locator(".lifted-card")).to_have_text(["100", "100"]) + + +def test_local_page_var_does_not_cross_pages(page: Page) -> None: + """An unnamed var used at page level belongs to that page, like ``useState``.""" + page.locator("#page-local-inc").click() + page.locator("#page-local-inc").click() + expect(page.locator("#page-local")).to_have_text("2") + + page.locator("#to-other").click() + expect(page.locator("#other-page-local")).to_have_text("0") + + page.locator("#to-home").click() + expect(page.locator("#page-local")).to_have_text("0") + + +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") + + +def ClientStateLateMountApp(): + """App exercising a consumer that mounts after the value has moved.""" + import asyncio + + import reflex as rx + + flag = rx.client_state("", name="flag") + # Only ever written from the frontend; the reader mounts afterwards. + shared = rx.client_state("initial", name="shared") + # Only ever written by backend pushes, before any component reads it. + pushed = rx.client_state("initial", name="pushed") + + class LateMountState(rx.State): + mounted: bool = False + show: bool = False + default_text: str = "from backend" + + @rx.event + def reveal(self): + self.show = True + + @rx.event + def push_value(self): + return pushed.push("pushed") + + @rx.event + def push_none(self): + return pushed.push(None) + + @rx.event(background=True) + async def go(self): + async with self: + self.mounted = False + yield flag.push("busy") + await asyncio.sleep(0.2) + async with self: + self.mounted = True + await asyncio.sleep(0.2) + yield flag.push("") + + def index() -> rx.Component: + return rx.el.div( + rx.input( + value=LateMountState.router.session.client_token, + read_only=True, + id="token", + ), + rx.el.button("go", on_click=LateMountState.go, id="go"), + rx.el.div(flag.value, id="always"), + rx.cond(LateMountState.mounted, rx.el.div(flag.value, id="late")), + ) + + # Seeded from backend state, with a sibling that only ever sets it. + backend_default = rx.client_state(LateMountState.default_text, name="seeded") + + def setter_first() -> rx.Component: + return rx.el.div( + rx.input( + value=LateMountState.router.session.client_token, + read_only=True, + id="token", + ), + rx.el.button("set", on_click=shared.set("clicked"), id="setter"), + rx.el.button("reset", on_click=shared.set("initial"), id="resetter"), + rx.el.button("reveal", on_click=LateMountState.reveal, id="reveal"), + rx.cond(LateMountState.show, rx.el.div(shared.value, id="late-reader")), + ) + + def push_before_mount() -> rx.Component: + return rx.el.div( + rx.input( + value=LateMountState.router.session.client_token, + read_only=True, + id="token", + ), + rx.el.button("push", on_click=LateMountState.push_value, id="pusher"), + rx.el.button( + "push none", on_click=LateMountState.push_none, id="none-pusher" + ), + rx.el.button("reveal", on_click=LateMountState.reveal, id="reveal"), + rx.cond(LateMountState.show, rx.el.div(pushed.value, id="pushed-reader")), + ) + def backend_default_page() -> rx.Component: + return rx.el.div( + rx.input( + value=LateMountState.router.session.client_token, + read_only=True, + id="token", + ), + rx.input(value=backend_default.value, read_only=True, id="backend-reader"), + rx.el.button("set", on_click=backend_default.set("changed"), id="setter"), + ) -def _collect_script_errors(page: Page) -> list[str]: + app = rx.App() + app.add_page(index, route="/") + app.add_page(setter_first, route="/setter-first") + app.add_page(backend_default_page, route="/backend-default") + app.add_page(push_before_mount, route="/push-before-mount") + + +def _script_errors(page: Page) -> list[str]: """Record errors raised by scripts the backend runs on the page. The frontend catches these and only logs them, so they never reach @@ -131,138 +444,201 @@ def _collect_script_errors(page: Page) -> list[str]: A list that is appended to as errors are logged. """ errors: list[str] = [] - - def on_console(message: ConsoleMessage) -> None: - if message.text.startswith(("_call_script", "_call_function")): + page.on( + "console", + lambda message: ( errors.append(message.text) - - page.on("console", on_console) + if message.text.startswith(( + "_call_script", + "_call_function", + "_client_state", + )) + else None + ), + ) return errors -def test_sibling_setter_updates_reader( - client_state_app: AppHarness, page: Page -) -> None: - """A sibling button setting the client state updates the sibling reader. +def _page_errors(page: Page) -> list[str]: + """Record uncaught frontend errors raised on the page. Args: - client_state_app: Running app harness. page: Playwright page. + + Returns: + A list that is appended to as errors occur. """ - assert client_state_app.frontend_url is not None - errors = _collect_page_errors(page) - page.goto(client_state_app.frontend_url.removesuffix("/") + "/siblings") + errors: list[str] = [] + page.on("pageerror", lambda exc: errors.append(str(exc))) + return errors - expect(page.locator("#reader")).to_have_text("initial") - page.click("#setter") - expect(page.locator("#reader")).to_have_text("clicked") - assert errors == [] + +@pytest.fixture(scope="module") +def client_state_late_mount_app( + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[AppHarness, None, None]: + """Run the late-mount app. + + Args: + tmp_path_factory: Pytest fixture for creating temporary directories. + + Yields: + The running harness. + """ + with AppHarness.create( + root=tmp_path_factory.mktemp("client_state_late_mount_app"), + app_source=ClientStateLateMountApp, + ) as harness: + yield harness -def test_setter_works_before_reader_mounts( - client_state_app: AppHarness, page: Page +def test_late_mounted_consumer_reads_the_current_value( + client_state_late_mount_app: AppHarness, page: Page ) -> None: - """The setter works even when no component reading the value is mounted. + """A consumer mounting after a push reads the live value, not the default. + + Ported from reflex-dev/reflex#6824 (issue #6823). Under the old design every + consumer held its own ``useState(default)``, so one that mounted after a + push initialized to the default and then *stayed* there: pushing the value + back to the default was a no-op for its setter, leaving it permanently out + of sync. There is now one slot per name, and a late consumer binds to it + rather than seeding a copy, so this holds by construction. Args: - client_state_app: Running app harness. + client_state_late_mount_app: Running app harness. page: Playwright page. """ - assert client_state_app.frontend_url is not None - errors = _collect_page_errors(page) - page.goto(client_state_app.frontend_url) - expect(page.locator("#token")).not_to_be_empty() + assert client_state_late_mount_app.frontend_url is not None + page.goto(client_state_late_mount_app.frontend_url) + expect(page.locator("#token")).not_to_have_value("") - page.click("#setter") - page.click("#reveal") - expect(page.locator("#late-reader")).to_have_text("clicked") - assert errors == [] + expect(page.locator("#always")).to_have_text("") + expect(page.locator("#late")).to_have_count(0) + + page.click("#go") + + # The late consumer appears already holding the pushed value ... + expect(page.locator("#always")).to_have_text("busy") + expect(page.locator("#late")).to_have_text("busy") + # ... and follows the push back to the default. + expect(page.locator("#always")).to_have_text("") + expect(page.locator("#late")).to_have_text("") -def test_late_reader_follows_set_back_to_default( - client_state_app: AppHarness, page: Page +def test_setter_works_before_any_reader_mounts( + client_state_late_mount_app: AppHarness, page: Page ) -> None: - """A reader that mounts after a set still follows setting the default again. + """A setter-only component owns its slot binding; no reader needs to exist. - React skips a state update equal to the current state, so the late reader's - ``useState`` must start from the shared value rather than the default, or - setting the value back to the default leaves it showing the stale value. + Ported from reflex-dev/reflex#7286. The old setter referenced a function a + *reader's* ``useState`` hook defined, so with the reader behind a cond the + click threw. The setter now carries its own ``useClientState`` hook. Args: - client_state_app: Running app harness. + client_state_late_mount_app: Running app harness. page: Playwright page. """ - assert client_state_app.frontend_url is not None - errors = _collect_page_errors(page) - page.goto(client_state_app.frontend_url) - expect(page.locator("#token")).not_to_be_empty() + assert client_state_late_mount_app.frontend_url is not None + errors = _page_errors(page) + page.goto( + client_state_late_mount_app.frontend_url.removesuffix("/") + "/setter-first" + ) + expect(page.locator("#token")).not_to_have_value("") + expect(page.locator("#late-reader")).to_have_count(0) page.click("#setter") page.click("#reveal") expect(page.locator("#late-reader")).to_have_text("clicked") - page.click("#resetter") - expect(page.locator("#late-reader")).to_have_text("initial") assert errors == [] -def test_push_before_reader_mounts(client_state_app: AppHarness, page: Page) -> None: - """A backend push lands even when no component using the value is mounted. +def test_setter_only_sibling_with_backend_default( + client_state_late_mount_app: AppHarness, page: Page +) -> None: + """A var seeded from backend state works with a setter-only sibling. - Nothing defines the setter until such a component mounts, so the push has to - keep the value for the first one to mount instead of failing with - ``refs._client_state_set... is not a function``. + Ported from reflex-dev/reflex#7286: the default's state context hook has to + travel with the setter, or its memo body references the state without + declaring it. Args: - client_state_app: Running app harness. + client_state_late_mount_app: Running app harness. page: Playwright page. """ - assert client_state_app.frontend_url is not None - errors = _collect_page_errors(page) - script_errors = _collect_script_errors(page) - page.goto(client_state_app.frontend_url.removesuffix("/") + "/push-before-mount") - expect(page.locator("#token")).not_to_be_empty() + assert client_state_late_mount_app.frontend_url is not None + errors = _page_errors(page) + page.goto( + client_state_late_mount_app.frontend_url.removesuffix("/") + "/backend-default" + ) + expect(page.locator("#token")).not_to_have_value("") - page.click("#pusher") - page.click("#reveal") - expect(page.locator("#pushed-reader")).to_have_text("pushed") + expect(page.locator("#backend-reader")).to_have_value("from backend") + page.click("#setter") + expect(page.locator("#backend-reader")).to_have_value("changed") assert errors == [] - assert script_errors == [] -def test_push_none_before_reader_mounts( - client_state_app: AppHarness, page: Page +def test_late_reader_follows_a_set_back_to_the_default( + client_state_late_mount_app: AppHarness, page: Page ) -> None: - """A ``None`` pushed before the reader mounts is kept, not replaced by the default. + """A reader that mounts after a frontend set still follows a set back to the default. + + Ported from reflex-dev/reflex#7407. Under the old design the late reader's + ``useState`` started from the default, and React skips a state update equal to + the current state, so setting the default again left it showing the stale + value. A late consumer here binds to the one existing slot instead. Args: - client_state_app: Running app harness. + client_state_late_mount_app: Running app harness. page: Playwright page. """ - assert client_state_app.frontend_url is not None - errors = _collect_page_errors(page) - script_errors = _collect_script_errors(page) - page.goto(client_state_app.frontend_url.removesuffix("/") + "/push-before-mount") - expect(page.locator("#token")).not_to_be_empty() + assert client_state_late_mount_app.frontend_url is not None + errors = _page_errors(page) + page.goto( + client_state_late_mount_app.frontend_url.removesuffix("/") + "/setter-first" + ) + expect(page.locator("#token")).not_to_have_value("") - page.click("#none-pusher") + page.click("#setter") page.click("#reveal") - expect(page.locator("#pushed-reader")).to_have_text("") + expect(page.locator("#late-reader")).to_have_text("clicked") + page.click("#resetter") + expect(page.locator("#late-reader")).to_have_text("initial") assert errors == [] - assert script_errors == [] -def test_setter_with_backend_default(client_state_app: AppHarness, page: Page) -> None: - """A setter-only sibling works when the default comes from backend state. +@pytest.mark.parametrize( + ("pusher", "expected"), + [("#pusher", "pushed"), ("#none-pusher", "")], + ids=["value", "none"], +) +def test_push_before_any_reader_mounts( + client_state_late_mount_app: AppHarness, page: Page, pusher: str, expected: str +) -> None: + """A backend push lands before any component using the var has mounted. + + Ported from reflex-dev/reflex#7407. The store claims the slot on write, so + the first consumer to mount binds to the pushed value rather than seeding the + default -- and a pushed ``None`` is a value, not a missing slot. Args: - client_state_app: Running app harness. + client_state_late_mount_app: Running app harness. page: Playwright page. + pusher: The button that pushes. + expected: The text the late reader shows. """ - assert client_state_app.frontend_url is not None - errors = _collect_page_errors(page) - page.goto(client_state_app.frontend_url.removesuffix("/") + "/backend-default") - - expect(page.locator("#backend-reader")).to_have_value("from backend") - page.click("#setter") - expect(page.locator("#backend-reader")).to_have_value("changed") + assert client_state_late_mount_app.frontend_url is not None + errors = _page_errors(page) + script_errors = _script_errors(page) + page.goto( + client_state_late_mount_app.frontend_url.removesuffix("/") + + "/push-before-mount" + ) + expect(page.locator("#token")).not_to_have_value("") + expect(page.locator("#pushed-reader")).to_have_count(0) + + page.click(pusher) + page.click("#reveal") + expect(page.locator("#pushed-reader")).to_have_text(expected) assert errors == [] + assert script_errors == [] diff --git a/tests/integration/tests_playwright/test_memo.py b/tests/integration/tests_playwright/test_memo.py index 824eb2dda3b..4af2d3844df 100644 --- a/tests/integration/tests_playwright/test_memo.py +++ b/tests/integration/tests_playwright/test_memo.py @@ -104,6 +104,65 @@ 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") + @rx.memo + def memo_row( + item: rx.Var[str], + position: rx.Var[int], + label: rx.Var[str], + on_pick: rx.EventHandler[rx.event.passthrough_event_spec(str)], + ) -> rx.Component: + """One row of ``memo_rows``, with client state of its own. + + Args: + item: The loop item. + position: The loop index. + label: A prefix the row renders. + on_pick: Called with the row's item. + + Returns: + The row. + """ + opened = rx.client_state(False, prefix="memoopened") + return rx.hstack( + rx.el.button( + "toggle", + id=f"memo-toggle-{position}", + on_click=opened.set(~opened.value), + ), + rx.el.button("pick", id=f"memo-pick-{position}", on_click=on_pick(item)), + rx.text( + rx.cond(opened.value, f"{label}:open:{item}", f"{label}:closed:{item}"), + id=f"memo-status-{position}", + ), + ) + + @rx.memo + def memo_rows( + label: rx.Var[str], + on_pick: rx.EventHandler[rx.event.passthrough_event_spec(str)], + ) -> rx.Component: + """Loop over the order inside an explicitly memoized container. + + Per-item client state inside the loop lives in an ``@rx.memo`` item + function, whose instances each own their state. + + Args: + label: A prefix every row renders. + on_pick: Called with the picked row's item. + + Returns: + The rows. + """ + return rx.box( + rx.foreach( + MemoState.order, + lambda item, position: memo_row( + item=item, position=position, label=label, on_pick=on_pick + ), + ), + id="memo-rows", + ) + @rx.memo def derived_count(divisor: rx.Var[int]) -> rx.Component: """Compute a filtered count from state in the generated component. @@ -154,6 +213,7 @@ def index() -> rx.Component: rx.text(MemoState.last_value, id="framed-child"), title=MemoState.last_value, ), + memo_rows(label=MemoState.last_value, on_pick=MemoState.set_last_value), ) app = rx.App() @@ -356,6 +416,40 @@ def test_memo_wrapper_none_renders_and_updates( expect(page.locator("#unwrapped-label")).to_have_text("unwrapped_update") +def test_foreach_inside_an_explicit_memo_keeps_client_state_per_item( + memo_app: AppHarness, page: Page +) -> None: + """An ``@rx.memo`` item function scopes client state per loop item. + + Each rendered row is its own memo instance, so its unnamed client state is + its own. The rows also use the outer memo's parameters, passed down as + ordinary props. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + _load_page(page, memo_app) + + expect(page.locator("#memo-status-0")).to_have_text(":closed:row-a") + expect(page.locator("#memo-status-1")).to_have_text(":closed:row-b") + + page.locator("#memo-toggle-1").click() + + expect(page.locator("#memo-status-1")).to_have_text(":open:row-b") + expect(page.locator("#memo-status-0")).to_have_text(":closed:row-a") + expect(page.locator("#memo-status-2")).to_have_text(":closed:row-c") + + # The outer memo's parameters reach every row: the label as a value, the + # handler as a callback the row invokes with its own item. + page.locator("#memo-input").fill("tag") + expect(page.locator("#memo-status-0")).to_have_text("tag:closed:row-a") + expect(page.locator("#memo-status-1")).to_have_text("tag:open:row-b") + page.locator("#memo-pick-2").click() + expect(page.locator("#memo-last-value")).to_have_text("row-c") + expect(page.locator("#memo-status-1")).to_have_text("row-c:open:row-b") + + def test_memo_derived_array_updates(memo_app: AppHarness, page: Page) -> None: """Derived values stay current after unrelated writes and array replacement. diff --git a/tests/js/client_state.test.js b/tests/js/client_state.test.js new file mode 100644 index 00000000000..c4e6c993245 --- /dev/null +++ b/tests/js/client_state.test.js @@ -0,0 +1,629 @@ +/** + * 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, + ClientStateScope, + createClientStateStore, + getClientState, + getClientStore, + setClientState, + useClientState, + withClientStateScope, +} from "$/utils/client_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, + // Re-render into the same root, so React reconciles rather than remounting + // -- which is what the tests about list churn need to observe. + rerender: (next) => { + act(() => { + root.render(strict ? createElement(StrictMode, null, next) : next); + }); + }, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +}; + +/** Values every rendered `ItemState` has reported, oldest first. */ +const itemValues = []; + +/** Setter belonging to the most recently rendered `ItemState`. */ +let lastItemSetter; + +/** An unnamed client state var, as a loop body would declare one. */ +const ItemState = () => { + const [value, set] = useClientState("default", "item_cs0"); + itemValues.push(value); + lastItemSetter = set; + return null; +}; + +beforeEach(() => { + itemValues.length = 0; +}); + +describe("store slots", () => { + test("a name is claimed once and seeded by the first default", () => { + const store = createClientStateStore(); + 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("a child scope shadows nothing it does not own", () => { + const store = createClientStateStore(); + 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.root.own("a", 0).subscribe(watched); + store.root.own("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.root.own("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.root.own("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.root.own("later", "default ignored").getSnapshot()).toBe( + "pushed early", + ); + }); + + test("unsubscribing detaches the listener", () => { + const store = createClientStateStore(); + const listener = vi.fn(); + const unsubscribe = store.root.own("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("useClientState", () => { + /** Render `useClientState(default, name)` and report renders and value. */ + const probe = (defaultValue, name, id, 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 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("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, null, a.element, b.element), + ); + + act(() => a.renders.set("shared")); + + expect(a.renders.value).toBe("shared"); + expect(b.renders.value).toBe("shared"); + + 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(); + }); +}); + +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, + null, + 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, + null, + 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, + null, + 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 boundary owns a listed name before any descendant renders", () => { + // The page-level consumer renders after the nested boundary, but the outer + // boundary lists the name, so both resolve to its slot. + const inner = probe("initial", "listed"); + const outer = probe("initial", "listed"); + const { unmount } = mount( + createElement( + ClientStateProvider, + null, + createElement( + ClientStateScope, + { owns: ["listed"] }, + createElement(ClientStateScope, { owns: ["listed"] }, inner.element), + outer.element, + ), + ), + ); + + act(() => inner.renders.set("shared")); + + expect(outer.renders.value).toBe("shared"); + + unmount(); + }); + + test("sibling boundaries listing a name own separate slots", () => { + const first = probe("initial", "listedPerInstance"); + const second = probe("initial", "listedPerInstance"); + const { unmount } = mount( + createElement( + ClientStateProvider, + null, + createElement( + ClientStateScope, + { owns: ["listedPerInstance"] }, + first.element, + ), + createElement( + ClientStateScope, + { owns: ["listedPerInstance"] }, + second.element, + ), + ), + ); + + act(() => first.renders.set("only mine")); + + expect(first.renders.value).toBe("only mine"); + expect(second.renders.value).toBe("initial"); + + unmount(); + }); + + test("a listed slot starts at its first reader's default", () => { + const reader = probe(7, "listedDefault"); + const { unmount } = mount( + createElement( + ClientStateProvider, + null, + createElement( + ClientStateScope, + { owns: ["listedDefault"] }, + reader.element, + ), + ), + ); + + expect(reader.renders.value).toBe(7); + + 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, + null, + 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, + null, + 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, + null, + 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, + null, + 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, null, createElement(Scoped)), + ); + + act(() => parentSeen[0].set("from parent")); + + expect(childSeen.at(-1)).toBe("from parent"); + + unmount(); + }); +}); + +describe("slot lifetime", () => { + test("a consumer unsubscribes from its slot when it unmounts", () => { + // Root-scope slots live for the page, so a listener that outlived its + // component would pin that component's React internals forever. + const slot = getClientStore().root.own("lifetime_global", "seed"); + let live = 0; + const realSubscribe = slot.subscribe; + slot.subscribe = (onStoreChange) => { + live += 1; + const off = realSubscribe(onStoreChange); + return () => { + live -= 1; + off(); + }; + }; + + const Reader = () => { + useClientState("seed", "lifetime_global", true); + return null; + }; + const { unmount } = mount( + createElement(ClientStateProvider, null, createElement(Reader)), + ); + + expect(live).toBe(1); + + unmount(); + + expect(live).toBe(0); + slot.subscribe = realSubscribe; + }); +}); + +test("CLIENT_STATE_REF matches the Python constant", () => { + // The key has exactly two definitions -- this one and the Python constant the + // compiler emits `refs[...]` from -- because neither language can import the + // other's. Everything else on either side references its own, so this is the + // only seam left where a rename can drift. + const constantsPy = readFileSync(__PY_CONSTANTS__, "utf8"); + const declared = constantsPy.match(/^CLIENT_STATE_REF = "(.*)"$/m); + + expect(declared, "no CLIENT_STATE_REF in constants/state.py").not.toBeNull(); + expect(declared[1]).toBe(CLIENT_STATE_REF); +}); + +test("state.js publishes the store through the shared constant", () => { + // Guards a regression back to a hardcoded copy: the publisher outside the + // React tree has to go through the constant, not re-spell the key. + const stateJs = readFileSync(`${__WEB_ROOT__}utils/state.js`, "utf8"); + + expect(stateJs).toContain("refs[CLIENT_STATE_REF] = getClientStore()"); + expect(stateJs).not.toContain(`"${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/vitest.config.js b/tests/js/vitest.config.js new file mode 100644 index 00000000000..3c4254d6ce3 --- /dev/null +++ b/tests/js/vitest.config.js @@ -0,0 +1,47 @@ +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, + ), +); + +// The Python side of the client-state registry key. Asserted equal to the +// frontend constant, since these are the two definitions that can drift. +const pyConstants = fileURLToPath( + new URL( + "../../packages/reflex-base/src/reflex_base/constants/state.py", + 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), + __PY_CONSTANTS__: JSON.stringify(pyConstants), + }, + resolve: { + alias: [ + { find: /^\$\//, replacement: webRoot }, + { find: /^react$/, replacement: require.resolve("react") }, + ], + }, +}); diff --git a/tests/units/compiler/state_js.test.mjs b/tests/units/compiler/state_js.test.mjs index e3e6dd1486b..7fc50fb8b2f 100644 --- a/tests/units/compiler/state_js.test.mjs +++ b/tests/units/compiler/state_js.test.mjs @@ -212,6 +212,13 @@ async function setup({ }, eventLoop: { addEvents() {}, connectErrors: [] }, }, + "$/utils/client_state": { + CLIENT_STATE_REF: "__client_state", + getClientState: () => undefined, + // Published on `refs` when the module loads, so it has to answer. + getClientStore: () => ({}), + setClientState: () => {}, + }, "$/utils/helpers/json": { parseJson: JSON.parse }, "$/utils/helpers/debounce": { default() {} }, "$/utils/helpers/throttle": { default() {} }, diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index ffea8c7ca2d..c484e10d804 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -21,6 +21,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.utils.imports import ImportVar @@ -128,6 +129,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}" + class MemoTriggerState(BaseState): @rx.event @@ -722,30 +733,6 @@ def slot_card(label: rx.Var[str], children: rx.Var[Component]) -> Component: assert "static child" not in wrapper_code -def test_user_memo_inside_foreach_is_not_independently_memoized() -> None: - """Foreach owns its snapshot, so a memo inside it renders in that body.""" - from reflex.compiler.compiler import compile_memo_components - - @rx.memo - def row(label: rx.Var[str]) -> Component: - return WithProp.create(label=label) - - ctx, _page_ctx = _compile_single_page( - lambda: rx.box( - rx.foreach(SpecialFormMemoState.items, lambda item: row(label=item)) - ) - ) - - (definition,) = ctx.auto_memo_components.values() - assert isinstance(definition, MemoComponentDefinition) - assert isinstance(definition.component, Foreach) - memo_files, _memo_imports = compile_memo_components( - memos=tuple(ctx.auto_memo_components.values()), - ) - memo_code = "\n".join(code for _, code in memo_files) - assert f"jsx({row(label='x').tag}," in memo_code - - def test_passthrough_memo_skips_hole_for_childless_component() -> None: """Childless components own their JSX output, so the wrapper must not inject a ``{children}`` hole. @@ -1572,27 +1559,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``. +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``. - 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. + 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(0, name="counter") 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) @@ -1610,45 +1594,59 @@ 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", 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]}" + ) - 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]}" ) -def test_client_state_setter_only_sibling_memo_initializes_state() -> None: - """A memoized sibling that only sets a global ``ClientStateVar`` owns its hooks. +@pytest.mark.parametrize("is_global", [True, False]) +def test_client_state_setter_only_sibling_memo_initializes_state( + is_global: bool, +) -> None: + """A memoized sibling that only sets a client state var owns its hook. - Regression: the setter did not carry the ``useState``/``refs`` hooks, so a - button memo calling ``refs['_client_state_set']`` relied on a sibling - rendering ``.value`` to define it. If that sibling was not mounted, clicking - raised ``TypeError: refs._client_state_set is not a function``. - """ - from reflex.experimental.client_state import ClientStateVar + Regression (reflex-dev/reflex#7286): the setter used to reference the + setter function without the hook defining it, so a button memo relied on a + sibling rendering ``.value`` to initialize the state and threw when that + sibling was not mounted. The setter now carries the ``useClientState`` hook, + so a setter-only memo registers its own slot binding. That holds for scoped + vars too: the slot lives in the shared scope store, not in a private copy, + so a setter-only writer is seen by every reader in the scope. - shared = ClientStateVar.create("sibling", default="a") + Args: + is_global: Whether the var is named (global) or scoped. + """ + shared = rx.client_state("a", name="sibling" if is_global else None) def page() -> Component: return rx.box( rx.text(shared.value), - rx.el.button("set", on_click=shared.set_value("b")), + rx.el.button("set", on_click=shared.set("b")), ) ctx, _page_ctx = _compile_single_page(page) @@ -1658,12 +1656,16 @@ def page() -> Component: for chunk in memo_code.split("export const ") if chunk.startswith("Button_") ) - assert "refs['_client_state_setSibling'](\"b\")" in button_memo - assert ( - "const [sibling, setSibling] = useState('_client_state_sibling' in refs" - " ? refs['_client_state_sibling'] : (\"a\"))" in button_memo + # A global var passes ``true`` as the third argument; a scoped var omits it. + hook = re.search( + r'const \[[\w$]+, (set[\w$]+)\] = useClientState\("a", "([\w$]+)"(, true)?\)', + button_memo, ) - assert "refs['_client_state_setSibling'] = " in button_memo + assert hook is not None, button_memo + setter, name, global_flag = hook.groups() + assert (global_flag is not None) is is_global + assert name == shared._state_name + assert f'{setter}("b")' in button_memo def test_debounce_input_memo_renders_react_debounce_wrapper() -> None: @@ -2461,21 +2463,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 ``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. + inside a snapshot boundary lands in the memo body. Both a named (global) + and an unnamed (tree-scoped) var 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("hi", name=name) title = Title.create(cs_var.value) ctx, page_ctx = _compile_single_page(lambda: title) assert len(ctx.memoize_wrappers) == 1, ( @@ -2484,7 +2484,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]}" ) @@ -2717,6 +2717,105 @@ def test_each_memo_wrapper_emits_one_component_module_file() -> None: ) +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") + + # The wrapper lists the local name the body uses, so the scope owns it. + assert re.search( + r'withClientStateScope\(memo\(Component\), \["cs\$\d+"\]\)', 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=(MEMOS["UnscopedLabel", __name__],)) + code = "\n".join(code for _path, code in files) + + assert "= memo(" in code + assert "withClientStateScope" not in code + + +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" + ) + + def test_passthrough_memo_forwards_ref_and_props_to_root() -> None: """Passthrough wrappers are transparent to their parent. @@ -3058,3 +3157,78 @@ def page() -> Component: assert len(ctx.memoize_wrappers) == 1 assert len(re.findall(r"= useId_\w+\(\);", memo_code)) == 1 assert not any("useId" in hook for hook in page_ctx.hooks) + + +def _module_by_export(files: object) -> dict[str, str]: + """Index compiled memo modules by the export names they declare. + + Args: + files: The compiled (path, code) pairs. + + Returns: + Each ``export const`` block keyed by its exported symbol. + """ + modules: dict[str, str] = {} + for _path, code in files: # pyright: ignore [reportGeneralTypeIssues] + for block in code.split("export const ")[1:]: + modules[block.split(" ", 1)[0]] = block + return modules + + +def _module_named(modules: dict[str, str], python_name: str) -> str: + """Find a user memo's module by its Python name. + + Args: + modules: Compiled memo modules by export name. + python_name: The ``@rx.memo`` function's title-cased name. + + Returns: + The module whose export is that name plus the hash suffix. + """ + (block,) = ( + block + for name, block in modules.items() + if name == python_name or name.startswith(f"{python_name}_") + ) + return block + + +def test_memo_item_function_inside_an_explicit_memo_scopes_client_state_per_item() -> ( + None +): + """An ``@rx.memo`` item function keeps client state per loop item. + + Each rendered instance of the item memo owns its unnamed client state, so + the slot binding lives in the item module and the loop body only mounts + the item with its props. + """ + from reflex.compiler.compiler import compile_memo_components + + @rx.memo + def memo_row(item: rx.Var[str], label: rx.Var[str]) -> Component: + return rx.text(label, rx.client_state(item).value) + + @rx.memo + def rows(label: rx.Var[str]) -> Component: + return rx.foreach( + SpecialFormMemoState.items, + lambda item: memo_row(item=item, label=label), + ) + + files, _imports = compile_memo_components( + memos=(rows._definition, memo_row._definition) + ) + modules = _module_by_export(files) + + body = _module_named(modules, "Rows") + row = _module_named(modules, "MemoRow") + row_export = next(name for name, block in modules.items() if block is row) + # The slot binding lives in the per-item instance, not above the loop. + assert "useClientState(" not in body + assert f"useClientState(item{FIELD_MARKER}," not in body + assert "useClientState(itemRxMemo," in row + # The loop mounts the row with its item and the memo's own parameter. + mount = re.search(rf"jsx\({row_export},\{{([^}}]*)\}}", body) + assert mount is not None, body + assert f"item:item{FIELD_MARKER}" in mount.group(1) + assert "label:labelRxMemo" in mount.group(1) diff --git a/tests/units/compiler/test_state_js_template.py b/tests/units/compiler/test_state_js_template.py index a93103bf769..ede4ecfe974 100644 --- a/tests/units/compiler/test_state_js_template.py +++ b/tests/units/compiler/test_state_js_template.py @@ -1,5 +1,6 @@ """Regression tests for the state.js frontend template.""" +import re import shutil import subprocess from pathlib import Path @@ -11,6 +12,72 @@ / "packages/reflex-base/src/reflex_base/.templates/web/utils/state.js" ) +ADD_EVENTS_ALIAS = "const addEvents = (...args) => eventLoop.addEvents(...args);" + +USER_CODE_EVALS = ( + "eval(event.payload.callback)", + "eval(event.payload.javascript_code)", + "eval(event.payload.function)", +) + +TOP_LEVEL_FUNCTION = re.compile( + r"^(?:export )?const (\w+) = (?:async )?\(", re.MULTILINE +) + + +def _enclosing_function(content: str, index: int) -> tuple[str, str]: + """Find the top-level function containing the given offset. + + Args: + content: The state.js source. + index: An offset inside one of its top-level functions. + + Returns: + The enclosing function's name and source text. + """ + declaration = [ + m for m in TOP_LEVEL_FUNCTION.finditer(content) if m.start() < index + ][-1] + return declaration.group(1), content[ + declaration.start() : content.index("\n};\n", index) + ] + + +def test_result_callback_dispatches_through_current_event_loop() -> None: + """Serialized callbacks can dispatch after extraction into their own helper.""" + node = shutil.which("node") + if node is None: + pytest.skip("Node.js is needed to execute the callback") + content = STATE_JS_TEMPLATE.read_text() + helper = content[ + content.index("const applyResultCallback =") : content.index( + "export const applyEvent =" + ) + ] + subprocess.run( + [ + node, + "--input-type=module", + "--eval", + """ +import assert from 'node:assert/strict'; +const calls = []; +const eventLoop = {addEvents: (events) => calls.push(events)}; +""" + + helper + + """ +const event = {payload: {callback: '(value) => addEvents([value])'}}; +await applyResultCallback(event, Promise.resolve('first'), null, null, null); +eventLoop.addEvents = (events) => calls.push(['remounted', ...events]); +await applyResultCallback(event, 'second', null, null, null); +assert.deepEqual(calls, [['first'], ['remounted', 'second']]); +""", + ], + check=True, + capture_output=True, + text=True, + ) + def test_socket_startup_lifecycle() -> None: """Execute the frontend startup and reconnect tests against the real template.""" @@ -67,6 +134,29 @@ def test_state_js_still_handles_page_lifecycle_disconnect() -> None: ) +def test_every_user_code_eval_can_reach_add_events() -> None: + """Every scope that evals user code must declare the `addEvents` alias. + + A direct `eval` resolves names from its own scope chain, so the alias has to + be declared in each function holding one. Moving it, or extracting an `eval` + into a new helper without it, leaves the eval'd code -- serialized callbacks, + `_call_script`, and the handlers `_call_function` returns -- referencing an + undefined `addEvents`. + """ + content = STATE_JS_TEMPLATE.read_text() + + for snippet in USER_CODE_EVALS: + eval_at = content.index(snippet) + name, body = _enclosing_function(content, eval_at) + assert ADD_EVENTS_ALIAS in body, ( + f"`{name}` evals user code via `{snippet}` but does not declare the " + "addEvents alias, so the eval'd code cannot resolve it." + ) + assert body.index(ADD_EVENTS_ALIAS) < body.index(snippet), ( + f"`{name}` declares the addEvents alias after `{snippet}`." + ) + + @pytest.mark.skipif(shutil.which("node") is None, reason="node missing") def test_get_ref_value_preserves_empty_and_unset_controls() -> None: """Form fields retain falsy values and serialize unset refs as null.""" diff --git a/tests/units/experimental/test_client_state.py b/tests/units/experimental/test_client_state.py index 460bbad7dd8..cf74f7d7425 100644 --- a/tests/units/experimental/test_client_state.py +++ b/tests/units/experimental/test_client_state.py @@ -1,145 +1,96 @@ -"""Tests for reflex.experimental.client_state.""" - -import re -from typing import cast +"""The deprecated reflex.experimental.client_state path still resolves.""" import pytest -from reflex_base.vars.base import Var - -from reflex.experimental.client_state import ClientStateVar, NoValue -from reflex.state import BaseState +import reflex as rx -@pytest.mark.parametrize("use_set_property", [True, False]) -def test_global_setter_carries_client_state_hooks(use_set_property: bool) -> None: - """A global setter Var must carry the hooks that initialize the client state. - A component that only sets the value (e.g. a sibling button of the - component rendering ``.value``) is compiled into its own memo body, so - the setter must bring the ``useState`` and ``refs`` wiring with it. - - Args: - use_set_property: Whether to use ``.set`` or ``.set_value(...)``. - """ - cs = ClientStateVar.create("setter_hooks", default=0) - setter = cs.set if use_set_property else cs.set_value(1) +def test_experimental_import_is_the_promoted_class() -> None: + """``reflex.experimental.client_state`` builds the reflex-base class.""" + from reflex.experimental.client_state import ClientStateVar - cs_var_data = cs._get_all_var_data() - setter_var_data = setter._get_all_var_data() - assert cs_var_data is not None - assert setter_var_data is not None - assert set(cs_var_data.hooks) <= set(setter_var_data.hooks) - assert any("useState" in hook for hook in setter_var_data.hooks) + assert issubclass(ClientStateVar, rx.ClientStateVar) + assert isinstance(ClientStateVar.create("legacy_cls", default=0), rx.ClientStateVar) -@pytest.mark.parametrize("use_set_property", [True, False]) -def test_local_setter_does_not_carry_client_state_hooks(use_set_property: bool) -> None: - """A local setter Var must not bring its own ``useState``. - - A local setter-only component with its own state copy would silently update - state that no reader sees; leaving the hooks off keeps that a loud error. - - Args: - use_set_property: Whether to use ``.set`` or ``.set_value(...)``. - """ - cs = ClientStateVar.create("local_setter", default=0, global_ref=False) - setter = cs.set if use_set_property else cs.set_value(1) +def test_legacy_create_keeps_the_original_argument_order( + caplog: pytest.LogCaptureFixture, +) -> None: + """``ClientStateVar.create(var_name, default=...)`` still works and warns.""" + from reflex.experimental.client_state import ClientStateVar - setter_var_data = setter._get_all_var_data() - assert setter_var_data is None or not any( - "useState" in hook for hook in setter_var_data.hooks - ) + cs = ClientStateVar.create("sibling", default="a") + assert cs._state_name == "sibling" + assert cs._is_global + assert "ClientStateVar.create" in caplog.text + scoped = ClientStateVar.create("scoped", default="a", global_ref=False) + assert scoped._state_name != "scoped" + assert not scoped._is_global -def test_setter_carries_backend_default_hooks() -> None: - """A backend-derived default brings its state context hook to the setter. - Regression: ``create`` merged only the default's own ``_var_data``, so a - setter-only component compiled ``useState(.field)`` without the - ``useContext`` hook that defines ````, raising a ReferenceError. - """ +def test_experimental_namespace_factory_still_works() -> None: + """``rx._x.client_state`` keeps building the same vars.""" + # The shim keeps the original positional signature. + assert rx._x.client_state("legacy", 0)._state_name == "legacy" - class ClientStateDefaultState(BaseState): - default_text: str = "hi" - - default = cast("Var", ClientStateDefaultState.default_text) - default_var_data = default._get_all_var_data() - assert default_var_data is not None - - cs = ClientStateVar.create("backend_default", default=default) - for var in (cs, cs.value, cs.set, cs.set_value("changed")): - var_data = var._get_all_var_data() - assert var_data is not None - assert var_data.state == default_var_data.state - assert set(default_var_data.hooks) <= set(var_data.hooks) - - -@pytest.mark.parametrize( - ("default", "initial_value"), - [ - (0, "'_client_state_seeded' in refs ? refs['_client_state_seeded'] : (0)"), - ( - "a", - "'_client_state_seeded' in refs ? refs['_client_state_seeded'] : (\"a\")", - ), - (NoValue, "refs['_client_state_seeded']"), - ], -) -def test_global_use_state_starts_from_shared_value( - default: object, initial_value: str -) -> None: - """A global client state's ``useState`` starts from the shared value. - A component that mounts after the value was set renders the shared value, so - its own state must match it: React skips a setter call equal to the current - state, which would otherwise swallow setting the value back to the default. +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(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(0, name="setter").set, rx.ClientStateSetter) - Args: - default: The default passed to ``create``. - initial_value: The expected ``useState`` argument. - """ - cs = ClientStateVar.create("seeded", default=default) - var_data = cs._get_all_var_data() - assert var_data is not None - assert f"const [seeded, setSeeded] = useState({initial_value})" in var_data.hooks +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_global_use_state_checks_the_slot_it_reads() -> None: - """The ``in refs`` check names the same ``refs`` slot that the value is read from. +def test_legacy_global_ref_false_drops_the_name() -> None: + """``global_ref=False`` meant anonymous, which is now a dropped name. - If the two drifted apart, the check would always be false and every reader - mounting after a set would silently start from the default again. + The name was never a store key in that mode, so discarding it reproduces the + old behavior exactly under the new scoping rules. """ - cs = ClientStateVar.create("keyed", default=0) - - var_data = cs._get_all_var_data() - assert var_data is not None - use_state = next(hook for hook in var_data.hooks if "useState(" in hook) - match = re.search(r"useState\((.+) in refs \? refs\[(.+)\] : \(0\)\)$", use_state) - assert match is not None, use_state - assert match.group(1) == match.group(2) + cs = rx._x.client_state("is_copied", False, False) + assert cs._state_name != "is_copied" + assert not cs._is_global -def test_local_use_state_starts_from_default() -> None: - """A local client state has no shared value, so ``useState`` takes the default.""" - cs = ClientStateVar.create("local_seed", default=0, global_ref=False) +def test_legacy_path_warns(caplog: pytest.LogCaptureFixture) -> 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 caplog.text - var_data = cs._get_all_var_data() - assert var_data is not None - assert "const [local_seed, setLocal_seed] = useState(0)" in var_data.hooks + caplog.clear() + rx.client_state(0, name="quiet") + assert "deprecat" not in caplog.text.lower() -def test_push_keeps_the_value_without_a_mounted_component() -> None: - """Before a component using the value mounts there is no setter to call. +@pytest.mark.parametrize("legacy_call", ["factory", "create"]) +def test_legacy_unnamed_var_stays_global(legacy_call: str) -> None: + """An unnamed legacy var without ``global_ref=False`` is still global. - The push then stores the value in the shared slot, which the first component - to mount starts its ``useState`` from. + The old API generated a name and defaulted ``global_ref`` to true, so + ``rx._x.client_state(default=...)`` was reachable from the backend. Scoping + it would make every existing ``push``/``retrieve`` on it raise. """ - cs = ClientStateVar.create("pushed", default="a") + from reflex.experimental.client_state import ClientStateVar - script = str(cs.push("b").args[0][1]) - assert ( - "(refs['_client_state_setPushed'] ?? ((pushed) => { " - "refs['_client_state_pushed'] = pushed; }))(\"b\")" - ) in script + cs = ( + rx._x.client_state(default="initial") + if legacy_call == "factory" + else ClientStateVar.create(default="initial") + ) + other = rx._x.client_state(default="initial") + + assert cs._is_global + assert cs._state_name != other._state_name + # The backend-facing accessors require a global var; these must not raise. + cs.push("x") + cs.retrieve() + assert cs.global_value is not None diff --git a/tests/units/reflex_base/client_event_queue_runtime.mjs b/tests/units/reflex_base/client_event_queue_runtime.mjs index 02b77e991f0..d12ed0c4fe4 100644 --- a/tests/units/reflex_base/client_event_queue_runtime.mjs +++ b/tests/units/reflex_base/client_event_queue_runtime.mjs @@ -45,6 +45,13 @@ export async function createQueueRuntime( useParams: () => ({}), }, "$/utils/context": app, + "$/utils/client_state": { + CLIENT_STATE_REF: "__client_state", + getClientState: unused, + // Published on `refs` when the module loads, so it has to answer. + getClientStore: () => ({}), + setClientState: unused, + }, "$/utils/context-registry": { app, eventLoop: { addEvents: unused }, diff --git a/tests/units/reflex_base/components/test_client_state_context.py b/tests/units/reflex_base/components/test_client_state_context.py new file mode 100644 index 00000000000..e84001c0b0e --- /dev/null +++ b/tests/units/reflex_base/components/test_client_state_context.py @@ -0,0 +1,66 @@ +"""Tests for the compile-time wiring of client state scopes.""" + +from reflex_base.components.client_state_context import ( + ClientStateScope, + local_client_state_names, + scope_page, +) + +import reflex as rx + + +def test_local_names_come_from_local_hooks_only() -> None: + """Global and unrelated hooks contribute no names.""" + local = rx.client_state(0) + named = rx.client_state(0, name="named_global") + hooks = rx.vstack( + rx.text(local.value), rx.text(named.value), rx.text(local.value) + )._get_all_hooks() + + assert local_client_state_names(hooks) == [local._state_name] + + +def test_local_names_are_sorted_and_unique() -> None: + """The emitted list is stable across runs.""" + first = rx.client_state(0, prefix="b") + second = rx.client_state(0, prefix="a") + hooks = rx.vstack( + rx.text(first.value), rx.text(second.value), rx.text(first.value) + )._get_all_hooks() + + assert local_client_state_names(hooks) == sorted([ + first._state_name, + second._state_name, + ]) + + +def test_scope_page_wraps_a_page_using_local_state() -> None: + """The page owns the local names its body uses.""" + local = rx.client_state(0) + page = rx.box(rx.text(local.value)) + + scoped = scope_page(page) + + assert isinstance(scoped, ClientStateScope) + assert scoped.children == [page] + assert str(scoped.owns) == f'["{local._state_name}"]' + + +def test_scope_page_leaves_other_pages_unchanged() -> None: + """A page with only global or no client state needs no scope.""" + named = rx.client_state(0, name="page_global") + page = rx.box(rx.text(named.value)) + + assert scope_page(page) is page + + +def test_scope_page_ignores_names_used_only_inside_a_memo() -> None: + """A memo body's names belong to each memo instance, not to the page.""" + + @rx.memo + def own_counter() -> rx.Component: + return rx.text(rx.client_state(0).value) + + page = rx.box(own_counter()) + + assert scope_page(page) is page diff --git a/tests/units/reflex_base/event/test_init.py b/tests/units/reflex_base/event/test_init.py new file mode 100644 index 00000000000..675231af795 --- /dev/null +++ b/tests/units/reflex_base/event/test_init.py @@ -0,0 +1,61 @@ +"""Tests for the trigger-argument placeholders ``parse_args_spec`` hands out.""" + +from typing import cast + +from reflex_base.event import ( + EventArgVar, + event_arg_placeholder, + event_args_signature, + parse_args_spec, + passthrough_event_spec, +) +from reflex_base.vars.base import Var +from reflex_base.vars.number import NumberVar + + +def test_parse_args_spec_hands_out_positioned_placeholders() -> None: + """Each argument is a placeholder that knows the trigger's full signature.""" + placeholders, _annotations = parse_args_spec(passthrough_event_spec(int, str)) + args = [event_arg_placeholder(placeholder) for placeholder in placeholders] + assert all(isinstance(arg, EventArgVar) for arg in args) + assert [arg._js_expr for arg in args] == ["_ev_0", "_ev_1"] # pyright: ignore [reportOptionalMemberAccess] + assert all(arg._signature == ("_ev_0", "_ev_1") for arg in args) # pyright: ignore [reportOptionalMemberAccess] + # The caller holds the typed cast, which is still recognized as the placeholder. + assert placeholders[0]._var_type is int + + +def test_event_args_signature_covers_every_argument_up_to_the_last_read() -> None: + """The declaration follows the trigger's order and includes skipped leading args.""" + placeholders, _annotations = parse_args_spec(passthrough_event_spec(int, int)) + first, second = (cast("NumberVar", placeholder) for placeholder in placeholders) + assert event_args_signature(second - first) == ("_ev_0", "_ev_1") + assert event_args_signature(second) == ("_ev_0", "_ev_1") + assert event_args_signature(first + 1) == ("_ev_0",) + assert event_args_signature(Var.create(42)) == () + assert event_args_signature(Var("_ev_0")) == () + + +def test_event_args_signature_looks_through_a_function_body() -> None: + """A function value is searched through its body, not treated as a leaf.""" + from reflex_base.vars.function import ArgsFunctionOperationBuilder + + (arg,), _annotations = parse_args_spec(passthrough_event_spec(int)) + prev = cast("NumberVar", Var("prev", _var_type=int).guess_type()) + updater = ArgsFunctionOperationBuilder.create( + args_names=("prev",), return_expr=prev + cast("NumberVar", arg) + ) + assert event_args_signature(updater) == ("_ev_0",) + + +def test_event_args_signature_reaches_into_a_nested_lambda() -> None: + """A placeholder closed over by a nested function is found whatever its body reads.""" + from reflex_base.vars.function import ArgsFunctionOperationBuilder + + (arg,), _annotations = parse_args_spec(passthrough_event_spec(int)) + # One leaf of the body reads the parameter by text, which makes + # ``get_leaf_vars`` keep the whole function as one opaque leaf. + field = cast("NumberVar", Var("item.count", _var_type=int).guess_type()) + nested = ArgsFunctionOperationBuilder.create( + args_names=("item",), return_expr=field + cast("NumberVar", arg) + ) + assert event_args_signature(nested) == ("_ev_0",) 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..100e29d629d --- /dev/null +++ b/tests/units/reflex_base/test_client_state.py @@ -0,0 +1,752 @@ +"""Tests for reflex_base.client_state.""" + +import re +from typing import Any, cast + +import pytest +from reflex_base.client_state import ClientStateVar, 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 _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. + + 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(0, name="counter") + hook = _hook(cs) + assert hook == ( + 'const [counterRxClientState, setCounter] = useClientState(0, "counter", true)' + ) + assert "useState(" not in hook + assert "useId" not in hook + assert "refs[" not in hook + + +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(name="counter") + hook = _hook(cs) + assert "(," not in hook + assert hook == ( + "const [counterRxClientState, setCounter] = " + 'useClientState(undefined, "counter", true)' + ) + + +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(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("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") + ] + + +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(0, name=name) + insert_app_wraps(target, cs._var_data.app_wraps) # pyright: ignore [reportOptionalMemberAccess] + assert list(target) == [(CLIENT_STATE_APP_WRAP_PRIORITY, "ClientStateProvider")] + + +def test_value_is_marked_identifier() -> None: + """``value`` renders the marked local binding.""" + cs = client_state(0, name="counter") + 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(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(0, name="counter") + assert str(cs.set(42)) == "(() => (setCounter(42)))" + + +@pytest.mark.parametrize("name", ["counter", None]) +def test_set_carries_hook_import_and_app_wrap(name: str | None) -> None: + """The setter must drag in its own hook, import and provider. + + A component that only sets the value (a sibling of the one rendering it) + compiles into its own memo body, so the setter has to bring the hook that + binds the slot. Unlike the ``useState`` implementation this replaced, that + is right for a scoped var too: the slot lives in the shared scope store, + not in a private per-component copy, so a setter-only writer is seen by + every reader in the scope. + + Args: + name: A name makes the var global; ``None`` scopes it. + """ + cs = client_state(0, name=name) + 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(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] + 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(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(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(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 built from a trigger argument makes the wrapper declare it.""" + from reflex_base.event import EventChain, input_event + + cs = client_state("", name="x") + # ``input_event`` hands the lambda ``_e.target.value``, built on the + # trigger's ``_e`` placeholder. + fn: Any = lambda value: cs.set(value) # noqa: E731 + chain = str(Var.create(EventChain.create(fn, args_spec=cast("Any", input_event)))) + assert '((_e) => (setX(_e?.["target"]?.["value"])))' in chain + + +def _bound(fn: Any, *arg_types: type) -> str: + """Render ``fn`` bound to a trigger whose arguments have ``arg_types``. + + Args: + fn: The event lambda. + *arg_types: The trigger's argument types, in order. + + Returns: + The compiled event chain. + """ + from reflex_base.event import EventChain, passthrough_event_spec + + return str( + Var.create(EventChain.create(fn, args_spec=passthrough_event_spec(*arg_types))) + ) + + +def test_set_declares_event_arg_read_through_an_operation() -> None: + """A trigger argument used in a compound value is declared by the wrapper.""" + cs = client_state(0, name="x") + assert "((_ev_0) => (setX((_ev_0 + 42))))" in _bound(lambda x: cs.set(x + 42), int) + + +def test_set_declares_every_trigger_arg_up_to_the_last_one_read() -> None: + """Arguments arrive positionally, so the declaration keeps the trigger's order.""" + cs = client_state(0, name="x") + assert "((_ev_0, _ev_1) => (setX((_ev_1 - _ev_0))))" in _bound( + lambda a, b: cs.set(b - a), int, int + ) + # A skipped leading argument is still declared, or the one read would bind + # to the wrong position. + assert "((_ev_0, _ev_1) => (setX(_ev_1)))" in _bound( + lambda a, b: cs.set(b), int, int + ) + assert "((_ev_0) => (setX(_ev_0)))" in _bound(lambda a, b: cs.set(a), int, int) + + +def test_trigger_arg_inside_a_nested_lambda_is_declared() -> None: + """A value mapping over a list with a lambda that reads the arg declares it.""" + cs = client_state(0, name="x") + items = Var.create([1, 2]).to(list[int]) + chain = _bound(lambda e: cs.set(items.map(lambda item: item + e).length()), int) + assert re.search( + r"\(\(_ev_0\) => \(setX\(\[1, 2\]\.map\(\(\(\w+\) => \(\w+ \+ _ev_0\)\)\)\.length\)\)\)", + chain, + ), chain + + +def test_updater_closing_over_a_trigger_arg_declares_it() -> None: + """A functional updater reading a trigger argument gets it from the wrapper.""" + cs = client_state(0, name="x") + chain = _bound(lambda e: cs.set(lambda prev: prev + e), int) + assert re.search( + r"\(\(_ev_0\) => \(setX\(\(\((prev\d+RxClientState)\) => \(\1 \+ _ev_0\)\)\)\)\)", + chain, + ), chain + + +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("", name="_private") + other = client_state("", name="other") + assert str(other.set(private.value)) == "(() => (setOther(_privateRxClientState)))" + + +@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(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. + 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(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(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(name=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(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} == { + "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(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"' + 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(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(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 + 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(default=0) + with pytest.raises(ValueError, match="scoped to the component tree"): + if accessor == "push": + cs.push(1) + elif accessor == "retrieve": + cs.retrieve() + else: + getattr(cs, accessor) + + +def test_set_value_delegates_and_deprecates(caplog: pytest.LogCaptureFixture) -> None: + """``set_value`` still works, and says to use ``set``.""" + cs = client_state(0, name="counter") + assert str(cs.set_value(42)) == str(cs.set(42)) + assert "set_value" in caplog.text + + +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(0, name="x")) == "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() + lc_last_var = rx.client_state() + 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 escapes to the root scope (a named var + # would pass a trailing `true`). + assert len(set(declarations)) == 2 + assert all(not line.rstrip(";").endswith("true)") 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("", 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(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(0, name="a") + b = client_state(0, name="b") + 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(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(Var("someExpr").to(int), name="x") + assert "useClientState(someExpr" in _hook(cs) + 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. + + ``Var.guess_type()`` returns a cast wrapper whose own ``_var_data`` is + ``None``; the hook and import live on the var it wraps and must still be + collected, or the default compiles to a dangling identifier. + """ + from reflex_base.utils.imports import ImportVar + from reflex_base.vars.base import Var, VarData + + seed = Var( + _js_expr="seed", + _var_type=int, + _var_data=VarData( + hooks={"const seed = useSeed()": None}, + imports={"$/utils/seed": [ImportVar(tag="useSeed")]}, + ), + ).guess_type() + cs = client_state(seed, name="seeded") + + # The declaration comes first; ``useClientState`` reading it comes last. + assert _hooks_ending_with_client_state(cs) == [ + "const seed = useSeed()", + 'const [seededRxClientState, setSeeded] = useClientState(seed, "seeded", true)', + ] + var_data = cs._get_all_var_data() + assert var_data is not None + assert "useSeed" 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) + # The state's context declaration precedes the ``useClientState`` line that + # reads the field off it, whatever hook the state layer renders it with. + declared = [hook.split(" = ")[0].removeprefix("const ") for hook in hooks[:-1]] + assert any(f"useClientState({name}.seed" in hooks[-1] for name in declared), hooks + + # Every accessor compiled into its own memo body needs the state context + # too, or a setter-only sibling emits ``useClientState(.seed)`` with + # nothing declaring ````. + default_var_data = cast("Var", ClientStateDefaultState.seed)._get_all_var_data() + assert default_var_data is not None + for var in (cs, cs.value, cs.set, cs.set("changed")): + var_data = var._get_all_var_data() + assert var_data is not None + assert var_data.state == default_var_data.state + assert set(default_var_data.hooks) <= set(var_data.hooks) + + +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(0, name="counter") + args = {str(k): str(v) for k, v in cs.retrieve(RetrieveState.got).args} + assert args["var_name"] == '"counter"' + assert "addEvents" 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(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}} + + +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(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( + event.payload["function"] + ) + + +def test_backend_events_use_the_shared_store() -> None: + """``push`` and ``retrieve`` work for a var no component renders. + + Asserted against the shipped ``state.js``: the backend event branches go to + the store singleton, and the store is published on ``refs`` at import for + ``run_script`` code, so neither depends on a provider being mounted. + """ + from pathlib import Path + + import reflex_base + + state_js = ( + Path(reflex_base.__file__).parent / ".templates" / "web" / "utils" / "state.js" + ).read_text() + assert "refs[CLIENT_STATE_REF] = getClientStore()" in state_js + set_branch = state_js.split('event.name == "_client_state_set"')[1].split( + "return;" + )[0] + assert "setClientState(event.payload.var_name, event.payload.value)" in set_branch + get_branch = state_js.split('event.name == "_client_state_get"')[1].split( + "return;" + )[0] + assert "applyResultCallback" in get_branch + assert "getClientState(event.payload.var_name)" in get_branch + assert "ClientStateProvider" not in set_branch + get_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 + + +def test_generated_name_separates_prefix_from_counter() -> None: + """``$`` sits between prefix and counter, so ``row1`` + 0 is never ``row`` + 10.""" + assert re.fullmatch(r"row1\$\d+", client_state(0, prefix="row1")._state_name) + assert re.fullmatch(r"row\$\d+", client_state(0, prefix="row")._state_name) + # The identifiers derived from it stay valid javascript. + cs = client_state(0, prefix="row") + assert _hook(cs).startswith(f"const [{cs._state_name}RxClientState, setRow$") + with pytest.raises(ValueError, match=r"must not contain '\$'"): + client_state(0, prefix="row$1") + + +@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) + + +def test_client_state_inside_a_memo_body_reaches_the_app_wraps() -> None: + """A var used only inside an ``@rx.memo`` body still mounts its provider. + + The body compiles into its own module, so nothing in the page tree ever + sees the ``useClientState`` hook; the wrapper has to surface the provider + its ``VarData`` requests. + """ + + @rx.memo + def toggle() -> rx.Component: + local = client_state(False) + return rx.el.button("toggle", on_click=local.set(True)) + + assert ( + CLIENT_STATE_APP_WRAP_PRIORITY, + "ClientStateProvider", + ) in toggle()._get_app_wrap_components()