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