From e6fe6fad2881c7460a3ef9111626a2e02b76dfeb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 01:02:12 +0000 Subject: [PATCH 01/15] feat(client_state): promote to rx.client_state on a React context ClientStateVar expanded into eight lines of generated hook code per var and kept its state in four `refs` keys, alongside DOM refs, upload controllers and the toaster. Those writes happened during render rather than in an effect, the per-instance setter dicts were never cleaned up on unmount, and every write fanned out to every registered setter, so writing one var re-rendered components reading a different one. Replace it with a single `useClientState` hook over a store of independently subscribable slots, delivered by a React context provider injected through the existing `VarData.app_wraps` pipeline. The store keeps one debuggable `refs["__client_state"]` entry, and per-slot subscriptions via `useSyncExternalStore` mean a write only re-renders that var's subscribers. Also: - Promote the API out of `experimental`: it lives in reflex-base and is exposed as `rx.client_state`; `reflex.experimental.client_state` re-exports it, so existing imports keep working. - Collapse `.set` and `.set_value` into `.set`, which is now callable. `.set` attaches bare to a trigger, `.set(value)` binds a value, and `.set(lambda v: ...)` traces a functional updater against a placeholder typed from the var, so ordinary var operations work inside it. `.set_value` remains as a deprecated alias. - Add `.global_value` / `.global_set`, a supported escape hatch for driving a client state var from JS outside the React tree. - Replace the eval'd `run_script` used by `push`/`retrieve` with first-class `_client_state_set` / `_client_state_get` events, and reuse one extracted callback helper across the `applyEvent` result-callback sites. - Suffix the emitted JS identifier with a marker so a name can never collide with a reserved word (`rx.client_state("class")` was a syntax error), and fix `.set`'s arg-name recovery to key on Reflex's marker convention instead of a `_` prefix -- so any valid identifier is a legal name, and event args are recovered from compound expressions too. - Generate omitted names from a dedicated counter, so a name no longer shifts when unrelated code draws from the process-wide name generator. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- .../pages/integrations/integration_gallery.py | 5 +- .../reflex_docs/templates/docpage/docpage.py | 7 +- docs/library/data-display/icon.md | 5 +- docs/wrapping-react/overview.md | 40 +- .../.templates/web/utils/client_state.js | 197 +++++++ .../reflex_base/.templates/web/utils/state.js | 106 +++- .../src/reflex_base/client_state.py | 485 ++++++++++++++++++ .../components/client_state_context.py | 39 ++ .../src/reflex_base/constants/base.py | 2 + .../src/reflex_base/constants/state.py | 3 + .../blocks/demo_form.py | 13 +- .../blocks/intro_form.py | 13 +- pyi_hashes.json | 2 +- reflex/__init__.py | 1 + reflex/experimental/client_state.py | 303 +---------- .../tests_playwright/test_client_state.py | 231 +++++++++ tests/units/compiler/test_memoize_plugin.py | 64 +-- tests/units/experimental/__init__.py | 0 tests/units/experimental/test_client_state.py | 21 + tests/units/reflex_base/test_client_state.py | 460 +++++++++++++++++ 20 files changed, 1621 insertions(+), 376 deletions(-) create mode 100644 packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js create mode 100644 packages/reflex-base/src/reflex_base/client_state.py create mode 100644 packages/reflex-base/src/reflex_base/components/client_state_context.py create mode 100644 tests/integration/tests_playwright/test_client_state.py create mode 100644 tests/units/experimental/__init__.py create mode 100644 tests/units/experimental/test_client_state.py create mode 100644 tests/units/reflex_base/test_client_state.py diff --git a/docs/app/reflex_docs/pages/integrations/integration_gallery.py b/docs/app/reflex_docs/pages/integrations/integration_gallery.py index 8bf4c41c2c6..2fdaab3eaf3 100644 --- a/docs/app/reflex_docs/pages/integrations/integration_gallery.py +++ b/docs/app/reflex_docs/pages/integrations/integration_gallery.py @@ -1,12 +1,11 @@ import reflex as rx import reflex_components_internal as ui -from reflex.experimental import ClientStateVar from reflex_site_shared.integrations import get_integration_logo_url from .integration_list import get_integration_path from .integration_request import request_integration_dialog -selected_filter = ClientStateVar.create("selected_filter", "All") +selected_filter = rx.client_state("selected_filter", "All") FilterOptions = [ {"name": "AI", "icon": "BotIcon"}, @@ -29,7 +28,7 @@ def integration_filter_button(data: dict): variant="outline", class_name="flex flex-row items-center " + rx.cond(selected_filter.value == data["name"], active_pill, "").to(str), - on_click=selected_filter.set_value(data["name"]), + on_click=selected_filter.set(data["name"]), ) diff --git a/docs/app/reflex_docs/templates/docpage/docpage.py b/docs/app/reflex_docs/templates/docpage/docpage.py index 30f93a9ccab..de0398a5f7d 100644 --- a/docs/app/reflex_docs/templates/docpage/docpage.py +++ b/docs/app/reflex_docs/templates/docpage/docpage.py @@ -6,7 +6,6 @@ import reflex as rx import reflex_components_internal as ui from reflex.components.radix.themes.base import LiteralAccentColor -from reflex.experimental.client_state import ClientStateVar from reflex.utils.format import to_snake_case, to_title_case from reflex_site_shared.components.blocks.code import * from reflex_site_shared.components.blocks.demo import * @@ -86,7 +85,7 @@ def feedback_button_toc() -> rx.Component: @rx.memo def copy_to_markdown(text: rx.Var[str]) -> rx.Component: - copied = ClientStateVar.create("is_copied", default=False, global_ref=False) + copied = rx.client_state("is_copied", default=False, global_ref=False) return marketing_button( rx.cond( copied.value, @@ -101,10 +100,10 @@ def copy_to_markdown(text: rx.Var[str]) -> rx.Component: variant="ghost", class_name="justify-start pl-0 text-secondary-11", on_click=[ - rx.call_function(copied.set_value(True)), + rx.call_function(copied.set(True)), rx.set_clipboard(text), ], - on_mouse_down=rx.call_function(copied.set_value(False)).debounce(1500), + on_mouse_down=rx.call_function(copied.set(False)).debounce(1500), ) diff --git a/docs/library/data-display/icon.md b/docs/library/data-display/icon.md index 24038fcf8df..cd4efa6e678 100644 --- a/docs/library/data-display/icon.md +++ b/docs/library/data-display/icon.md @@ -7,9 +7,8 @@ components: import reflex as rx from reflex_components_lucide.icon import LUCIDE_ICON_LIST -from reflex.experimental.client_state import ClientStateVar -icon_search_cs = ClientStateVar.create("icon_search", default="") +icon_search_cs = rx.client_state("icon_search", default="") @rx.memo @@ -26,7 +25,7 @@ def lucide_icons() -> rx.Component: ), rx.el.input( placeholder="Search icons...", - on_change=icon_search_cs.set_value, + on_change=icon_search_cs.set, class_name="relative box-border border-secondary-4 focus:border-violet-9 focus:border-1 bg-secondary-2 p-[0.5rem_0.75rem] border rounded-xl font-base text-secondary-11 placeholder:text-secondary-9 outline-none focus:outline-none w-full mb-2 pl-10", ), class_name="relative flex items-center", diff --git a/docs/wrapping-react/overview.md b/docs/wrapping-react/overview.md index 453c38d47fe..c23f7b99b44 100644 --- a/docs/wrapping-react/overview.md +++ b/docs/wrapping-react/overview.md @@ -58,7 +58,6 @@ We also have a var `color` which is the current color of the color picker. Since this component has interaction we must specify any event triggers that the component takes. The color picker has a single trigger `on_change` to specify when the color changes. This trigger takes in a single argument `color` which is the new color. ```python exec -from reflex.experimental.client_state import ClientStateVar from reflex.components.component import NoSSRComponent @@ -71,7 +70,7 @@ class ColorPicker(NoSSRComponent): color_picker = ColorPicker.create -ColorPickerState = ClientStateVar.create(default="#db114b", var_name="color") +ColorPickerState = rx.client_state(default="#db114b", var_name="color") ``` ```python eval @@ -79,7 +78,7 @@ rx.box( ColorPickerState, rx.vstack( rx.heading(ColorPickerState.value, as_="h2", color="white"), - color_picker(on_change=ColorPickerState.set_value), + color_picker(on_change=ColorPickerState.set), ), background_color=ColorPickerState.value, padding="5em", @@ -122,6 +121,41 @@ def index(): ) ``` +## Setting Client State From Plain JavaScript + +`value` and `set` are the normal way to use a client state var, but they resolve to a +hook, so they only work inside a component that Reflex renders. When you are wrapping a +library that hands you a plain JavaScript callback -- or you are writing your own JS in +`add_custom_code` -- use `global_value` and `global_set` instead. They need no hook, so +they work anywhere in your compiled page: + +```python +picker_color = rx.client_state("picker_color", default="#db114b") + + +class MyPicker(rx.Component): + library = "some-non-react-picker" + tag = "Picker" + + def add_custom_code(self) -> list[str]: + # `global_set` is a plain function, so a non-React callback can call it. + return [f"const onPickerChange = {picker_color.global_set};"] +``` + +Reads through `global_value` are a point-in-time snapshot with no reactivity, so prefer +`value` inside components. Writes through `global_set` re-render every component +subscribed to that var, exactly like `set` does. Both require a named (non-local) +client state var, since the name is what identifies the value. + +`rx.call_script` is the one place these do not work: its code is evaluated inside the +Reflex runtime module, so your page's imports are not in scope there. Reach the store +through the `refs` object instead, which is also how you inspect client state from the +browser devtools console: + +```python +rx.call_script('refs["__client_state"].set("picker_color", "#ffffff")') +``` + ## What Not To Wrap There are some libraries on npm that are not do not expose React components and therefore are very hard to wrap with Reflex. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js new file mode 100644 index 00000000000..ea8465c4a3b --- /dev/null +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js @@ -0,0 +1,197 @@ +/** + * Client-only state, shared by name across components without a backend rx.State. + * + * `useClientState` is the only thing compiled components call. Everything else + * here is the bookkeeping it needs: a store of independently-subscribable slots, + * the context that delivers it, and a module-level door for JS that runs outside + * the React tree (see `getClientState` / `setClientState`). + * + * Each slot owns its own listener set, so writing one var only re-renders the + * components subscribed to *that* var. The context value is the store object + * itself and never changes identity, so mounting the provider never cascades. + */ +import { + createContext, + createElement, + useContext, + useEffect, + useRef, + useSyncExternalStore, +} from "react"; + +import { refs } from "$/utils/state"; + +/** The single `refs` key holding the live store, for devtools introspection. */ +export const CLIENT_STATE_REF = "__client_state"; + +/** + * Create a slot: one named (or anonymous) piece of client state. + * @param value The initial value. + * @returns A slot with its own listener set. + */ +const createSlot = (value) => { + const listeners = new Set(); + const slot = { + value, + // Stable identities: useSyncExternalStore requires them. + subscribe: (onStoreChange) => { + listeners.add(onStoreChange); + return () => listeners.delete(onStoreChange); + }, + getSnapshot: () => slot.value, + set: (next) => { + // Match the useState contract: a function is an updater, not a value. + const resolved = typeof next === "function" ? next(slot.value) : next; + if (Object.is(resolved, slot.value)) { + return; + } + slot.value = resolved; + listeners.forEach((listener) => listener()); + }, + }; + return slot; +}; + +/** + * Create a store of client state slots. + * @returns The store. + */ +export const createClientStateStore = () => { + const slots = new Map(); + + /** + * Get the slot for `name`, creating it if absent. + * @param name The slot name. + * @param defaultValue Initial value, used only when creating the slot. + * @returns The named slot. + */ + const namedSlot = (name, defaultValue) => { + let slot = slots.get(name); + if (slot === undefined) { + slot = createSlot(defaultValue); + slots.set(name, slot); + } + return slot; + }; + + return { + /** + * Resolve the slot a `useClientState` call should bind to. + * @param name The shared name, or a falsy value for a private slot. + * @param defaultValue The initial value. + * @returns A shared slot when named, else a fresh anonymous one. + */ + slot: (name, defaultValue) => + name ? namedSlot(name, defaultValue) : createSlot(defaultValue), + /** + * Read a named slot's current value. + * @param name The slot name. + * @returns The value, or undefined if the slot does not exist yet. + */ + get: (name) => slots.get(name)?.value, + /** + * Write a named slot, creating it if it does not exist yet, so a value + * pushed before any component mounts is picked up on mount. + * @param name The slot name. + * @param value The value, or an updater function. + */ + set: (name, value) => { + namedSlot(name, undefined).set(value); + }, + }; +}; + +let _clientStore = null; + +/** + * The client-side store singleton. + * + * Shared so that non-React callers and the hooks operate on the same slots + * regardless of mount order. Never used during SSR — `ClientStateProvider` + * builds a per-render store on the server so requests stay isolated. + * @returns The store. + */ +export const getClientStore = () => { + if (_clientStore === null) { + _clientStore = createClientStateStore(); + } + return _clientStore; +}; + +export const ClientStateContext = createContext(null); + +/** + * Read a named client state var from outside the React tree. + * + * A point-in-time snapshot with no reactivity; prefer the value returned by + * `useClientState` inside components. + * @param name The client state var name. + * @returns The current value. + */ +export const getClientState = (name) => getClientStore().get(name); + +/** + * Write a named client state var from outside the React tree. + * + * Every subscribed component re-renders. Use this to drive client state from + * third-party library callbacks or other non-React JS. + * @param name The client state var name. + * @param value The value, or an updater function. + */ +export const setClientState = (name, value) => { + getClientStore().set(name, value); +}; + +/** + * Provide the client state store to the tree. + * @param props The component props. + * @param props.children The children to render. + * @returns The provider element. + */ +export function ClientStateProvider({ children }) { + const storeRef = useRef(null); + if (storeRef.current === null) { + // A per-render store on the server keeps requests isolated; on the client, + // share the singleton so `setClientState` reaches these same slots. + storeRef.current = + typeof document === "undefined" + ? createClientStateStore() + : getClientStore(); + } + const store = storeRef.current; + + useEffect(() => { + // Client-only, so the server's module-scope `refs` is never written. + refs[CLIENT_STATE_REF] = store; + return () => { + if (refs[CLIENT_STATE_REF] === store) { + delete refs[CLIENT_STATE_REF]; + } + }; + }, [store]); + + return createElement(ClientStateContext.Provider, { value: store }, children); +} + +/** + * Subscribe to a piece of client state. + * @param defaultValue The initial value. + * @param name Shared name, or omitted for state private to this component. + * @returns A `[value, setValue]` pair, like `useState`. + */ +export function useClientState(defaultValue, name) { + const store = useContext(ClientStateContext) ?? getClientStore(); + const slotRef = useRef(null); + if (slotRef.current === null) { + // `name` is a compile-time constant per call site, so the slot a mounted + // hook is bound to can never change. + slotRef.current = store.slot(name, defaultValue); + } + const slot = slotRef.current; + const value = useSyncExternalStore( + slot.subscribe, + slot.getSnapshot, + slot.getSnapshot, + ); + return [value, slot.set]; +} diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index 8ba6d00509c..c11a8a58601 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -216,6 +216,41 @@ function urlFrom(string) { return undefined; } +/** + * Invoke an event's result callback, if it declared one. + * + * The callback arrives as a string built by ``format_queue_events``, which + * references ``queueEvents``/``processEvent`` (module-level here) plus ``socket``, + * ``navigate`` and ``params``. Those three MUST stay the parameter names below: + * the ``eval`` resolves them from this function's scope, so renaming them breaks + * every callback. + * @param event The event whose callback to run. + * @param eval_result The value to pass to the callback, awaited if thenable. + * @param socket The socket object to send events on. + * @param navigate The navigate function from useNavigate. + * @param params The params object from useParams. + */ +const applyResultCallback = async ( + event, + eval_result, + socket, + navigate, + params, +) => { + if (!event.payload.callback) { + return; + } + const final_result = + !!eval_result && typeof eval_result.then === "function" + ? await eval_result + : eval_result; + const callback = + typeof event.payload.callback === "string" + ? eval(event.payload.callback) + : event.payload.callback; + callback(final_result); +}; + /** * Handle frontend event or send the event to the backend via Websocket. * @param event The event to send. @@ -342,23 +377,58 @@ export const applyEvent = async (event, socket, navigate, params) => { return; } + // Client state is reached through `refs` rather than an import: `client_state.js` + // imports `refs` from here, so importing it back would be a cycle. The key must + // stay in sync with CLIENT_STATE_REF in `$/utils/client_state`. + if (event.name == "_client_state_set") { + const store = refs["__client_state"]; + if (store === undefined) { + console.error( + `Cannot set client state "${event.payload.var_name}": no ClientStateProvider is mounted.`, + ); + } else { + store.set(event.payload.var_name, event.payload.value); + } + return; + } + + if (event.name == "_client_state_get") { + const store = refs["__client_state"]; + if (store === undefined) { + console.error( + `Cannot read client state "${event.payload.var_name}": no ClientStateProvider is mounted.`, + ); + return; + } + try { + await applyResultCallback( + event, + store.get(event.payload.var_name), + socket, + navigate, + params, + ); + } catch (e) { + console.log("_client_state_get", e); + if (window && window?.onerror) { + window.onerror(e.message, null, null, null, e); + } + } + return; + } + if ( event.name == "_call_function" && typeof event.payload.function !== "string" ) { try { - const eval_result = event.payload.function(); - if (event.payload.callback) { - const final_result = - !!eval_result && typeof eval_result.then === "function" - ? await eval_result - : eval_result; - const callback = - typeof event.payload.callback === "string" - ? eval(event.payload.callback) - : event.payload.callback; - callback(final_result); - } + await applyResultCallback( + event, + event.payload.function(), + socket, + navigate, + params, + ); } catch (e) { console.log("_call_function", e); if (window && window?.onerror) { @@ -375,17 +445,7 @@ export const applyEvent = async (event, socket, navigate, params) => { ? eval(event.payload.javascript_code) : eval(event.payload.function)(); - if (event.payload.callback) { - const final_result = - !!eval_result && typeof eval_result.then === "function" - ? await eval_result - : eval_result; - const callback = - typeof event.payload.callback === "string" - ? eval(event.payload.callback) - : event.payload.callback; - callback(final_result); - } + await applyResultCallback(event, eval_result, socket, navigate, params); } catch (e) { console.log("_call_script", e); if (window && window?.onerror) { diff --git a/packages/reflex-base/src/reflex_base/client_state.py b/packages/reflex-base/src/reflex_base/client_state.py new file mode 100644 index 00000000000..d6ea6a5ce16 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/client_state.py @@ -0,0 +1,485 @@ +"""Handle client side state with `useClientState`.""" + +from __future__ import annotations + +import dataclasses +import inspect +import itertools +import re +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from reflex_base.components.client_state_context import get_client_state_app_wraps +from reflex_base.constants import Dirs +from reflex_base.constants.state import ( + CAMEL_CASE_CLIENT_STATE_MARKER, + CAMEL_CASE_MEMO_MARKER, + FIELD_MARKER, +) +from reflex_base.event import EventChain, EventHandler, EventSpec, server_side +from reflex_base.utils import console, format +from reflex_base.utils.exceptions import VarTypeError +from reflex_base.utils.imports import ImportVar +from reflex_base.vars import VarData +from reflex_base.vars.base import LiteralVar, Var +from reflex_base.vars.function import ArgsFunctionOperationBuilder, FunctionVar + +if TYPE_CHECKING: + from typing_extensions import deprecated + +NoValue = object() + +_CLIENT_STATE_IMPORT = { + f"$/{Dirs.CLIENT_STATE_PATH}": [ImportVar(tag="useClientState")], +} +_CLIENT_STATE_ESCAPE_IMPORT = { + f"$/{Dirs.CLIENT_STATE_PATH}": [ + ImportVar(tag="getClientState"), + ImportVar(tag="setClientState"), + ], +} + +# Generated names come from a dedicated counter rather than +# `get_unique_variable_name`, which draws from a process-wide generator shared +# with every other consumer -- so an unrelated `ArrayVar.map` would shift every +# subsequent client state name. This keeps a name dependent only on how many +# client state vars were created before it. +_name_counter = itertools.count() + +# Separate from _name_counter so tracing a lambda updater never shifts the +# generated var-name sequence. +_placeholder_counter = itertools.count() + +_VALID_NAME = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*$") + +# Reflex marks every identifier it puts in scope; an unmarked `_`-leading name is +# an event-arg placeholder from `parse_args_spec`. +_IN_SCOPE_MARKERS = ( + CAMEL_CASE_CLIENT_STATE_MARKER, + CAMEL_CASE_MEMO_MARKER, + FIELD_MARKER, +) +_LEADING_EVENT_ARG = re.compile(r"^_[A-Za-z0-9_$]*") + + +def _recovered_event_arg(value_str: str) -> str | None: + """Get the event-arg parameter an emitted setter wrapper must declare. + + A value bound into a setter may reference the event args of the trigger it is + attached to, in which case the wrapper has to declare them or they are + unbound when it fires. + + Args: + value_str: The rendered value expression. + + Returns: + The parameter name to declare, or None if the value references no event arg. + """ + match = _LEADING_EVENT_ARG.match(value_str) + if match is None: + return None + name = match.group() + if name.endswith(_IN_SCOPE_MARKERS): + return None + return name + + +def _client_state_set(var_name: str, value: Any): + """Signature holder for the ``_client_state_set`` event. + + Args: + var_name: The client state var name. + value: The value to set. + """ + + +def _client_state_get(var_name: str): + """Signature holder for the ``_client_state_get`` event. + + Args: + var_name: The client state var name. + """ + + +@dataclasses.dataclass( + eq=False, + frozen=True, + slots=True, +) +class ClientStateSetter(FunctionVar[Any]): + """The setter for a ClientStateVar. + + Attach it to an event trigger directly to forward the trigger's argument, or + call it to bind a specific value or a functional updater. + """ + + # The type of the value being set, used to type lambda updater placeholders. + _value_type: Any = dataclasses.field(default=Any) + + def __call__(self, value: Any = NoValue) -> Var: # pyright: ignore [reportIncompatibleMethodOverride] + """Bind a value to this setter. + + Args: + value: The value to set. A ``Var`` or literal is set directly; a + callable is traced at compile time and receives the current + value, so ``cs.set(lambda v: v + 1)`` becomes an updater. + + Returns: + A Var which sets the value when triggered. + """ + if value is NoValue: + return self + + # Check Var before callable: FunctionVars are themselves callable, and a + # Var is always passed through (the store treats a function value as an + # updater at runtime). + if isinstance(value, Var): + value_var = value + elif callable(value): + value_var = self._trace_updater(value) + else: + value_var = LiteralVar.create(value) + + value_str = str(value_var) + event_arg = _recovered_event_arg(value_str) + return ArgsFunctionOperationBuilder.create( + args_names=(event_arg,) if event_arg is not None else (), + return_expr=self.to(FunctionVar).call(value_var), + ).to(FunctionVar, EventChain) + + def _trace_updater(self, fn: Callable) -> Var: + """Trace a Python callable into a functional-updater Var. + + Args: + fn: The callable, taking at most one argument (the current value). + + Returns: + The traced updater, or the plain value for a zero-argument callable. + + Raises: + VarTypeError: If fn takes more than one argument. + """ + num_args = len(inspect.signature(fn).parameters) + if num_args > 1: + msg = "The function passed to ClientStateVar.set should take at most one argument." + raise VarTypeError(msg) + if num_args == 0: + return Var.create(fn()) + placeholder = Var( + _js_expr=f"prev{next(_placeholder_counter)}{CAMEL_CASE_CLIENT_STATE_MARKER}", + _var_type=self._value_type, + ).guess_type() + return ArgsFunctionOperationBuilder.create( + args_names=(placeholder._js_expr,), + return_expr=Var.create(fn(placeholder)), + ) + + +@dataclasses.dataclass( + eq=False, + frozen=True, + slots=True, +) +class ClientStateVar(Var): + """A Var that exists on the client via useClientState.""" + + # Track the names of the getters and setters + _setter_name: str = dataclasses.field(default="") + _getter_name: str = dataclasses.field(default="") + # The bare name keying this var in the client state store. + _state_name: str = dataclasses.field(default="") + + # Whether the state is shared by name (and reachable from the backend). + _global_ref: bool = dataclasses.field(default=True) + + # VarData without the hook, for accessors that work in any JS scope. + _escape_var_data: VarData | None = dataclasses.field(default=None) + + def __hash__(self) -> int: + """Define a hash function for a var. + + Returns: + The hash of the var. + """ + return hash(( + self._js_expr, + str(self._var_type), + self._getter_name, + self._setter_name, + )) + + @classmethod + def create( + cls, + var_name: str | None = None, + default: Any = NoValue, + global_ref: bool = True, + ) -> ClientStateVar: + """Create a local_state Var that can be accessed and updated on the client. + + The `ClientStateVar` should be included in the highest parent component + that contains the components which will access and manipulate the client + state. It has no visual rendering, including it ensures that the + `useClientState` hook is called in the correct scope. + + To render the var in a component, use the `value` property. + + To update the var in a component, use the `set` property: attach it to a + trigger to forward the trigger's argument, or call it with a value or a + function of the current value. + + To access the var in an event handler, use the `retrieve` method with + `callback` set to the event handler which should receive the value. + + To update the var in an event handler, use the `push` method with the + value to update. + + To read or write the var from JS outside a React component, use the + `global_value` and `global_set` properties. + + Args: + var_name: The name of the variable. + default: The default value of the variable. + global_ref: Whether the state should be accessible in any Component and on the backend. + + Returns: + ClientStateVar + + Raises: + ValueError: If var_name is not a valid identifier string. + """ + if var_name is None: + var_name = f"cs{next(_name_counter)}" + if isinstance(var_name, Var): + msg = ( + "var_name must be a string, not a Var. The name keys the client " + "state store and is embedded in the events that `push`, " + "`retrieve` and `global_set` send, so it has to be known at " + "compile time." + ) + raise ValueError(msg) + if not isinstance(var_name, str): + msg = "var_name must be a string." + raise ValueError(msg) + if not _VALID_NAME.match(var_name): + msg = ( + f"var_name {var_name!r} is not a valid javascript identifier; it " + "is emitted as one in the compiled app." + ) + raise ValueError(msg) + if default is NoValue: + default_var = Var(_js_expr="") + elif not isinstance(default, Var): + default_var = LiteralVar.create(default) + else: + default_var = default + # The marker keeps a user-chosen name from colliding with a JS reserved + # word; the store key stays the bare name. + getter_name = f"{var_name}{CAMEL_CASE_CLIENT_STATE_MARKER}" + setter_name = f"set{var_name[0].upper()}{var_name[1:]}" + name_arg = f", {LiteralVar.create(var_name)!s}" if global_ref else "" + hooks: dict[str, VarData | None] = { + f"const [{getter_name}, {setter_name}] = useClientState({default_var!s}{name_arg})": None, + } + app_wraps = get_client_state_app_wraps() + return cls( + _js_expr="null", + _setter_name=setter_name, + _getter_name=getter_name, + _state_name=var_name, + _global_ref=global_ref, + _var_type=default_var._var_type, + _var_data=VarData.merge( + default_var._var_data, + VarData( + hooks=hooks, + imports=_CLIENT_STATE_IMPORT, + app_wraps=app_wraps, + ), + ), + _escape_var_data=VarData( + imports=_CLIENT_STATE_ESCAPE_IMPORT, + app_wraps=app_wraps, + ), + ) + + @property + def value(self) -> Var: + """Get a placeholder for the Var. + + This property can only be rendered on the frontend. + + To access the value in a backend event handler, see `retrieve`. To read + it from JS outside a React component, see `global_value`. + + Returns: + an accessor for the client state variable. + """ + return Var(_js_expr=self._getter_name, _var_data=self._var_data).to( + self._var_type + ) + + @property + def set(self) -> ClientStateSetter: + """Set the value of the client state variable. + + Attach this to a frontend event trigger to forward the trigger's + argument, or call it with a value (``cs.set(True)``) or a function of the + current value (``cs.set(lambda v: v + 1)``). + + To set a value from a backend event handler, see `push`. To set it from + JS outside a React component, see `global_set`. + + Returns: + A special EventChain Var which will set the value when triggered. + """ + return ClientStateSetter( + _js_expr=self._setter_name, + _var_type=EventChain, + _var_data=self._var_data, + _value_type=self._var_type, + ) + + if TYPE_CHECKING: + + @deprecated("Use `set` instead.") + def set_value(self, value: Any = NoValue) -> Var: + """Set the value of the client state variable. + + Args: + value: The value to set. + + Returns: + A special EventChain Var which will set the value when triggered. + """ + ... + + else: + + def set_value(self, value: Any = NoValue) -> Var: + """Set the value of the client state variable. + + Args: + value: The value to set. + + Returns: + A special EventChain Var which will set the value when triggered. + """ + console.deprecate( + feature_name="ClientStateVar.set_value", + reason=( + "Use .set instead -- `cs.set` for the bare setter, " + "`cs.set(value)` to bind a value." + ), + deprecation_version="0.9.9", + removal_version="1.0", + ) + return self.set(value) + + @property + def global_value(self) -> Var: + """Read the client state variable from JS outside a React component. + + Unlike `value` this needs no hook, so it can be used in any javascript + scope -- a wrapped library's callback, `add_custom_code`, or + `rx.call_script`. It is a point-in-time read with no reactivity; prefer + `value` inside components. + + Returns: + An accessor for the client state variable. + + Raises: + ValueError: If the ClientStateVar is not global. + """ + if not self._global_ref: + msg = "ClientStateVar must be global to read the value from any scope." + raise ValueError(msg) + return Var( + _js_expr=f"getClientState({LiteralVar.create(self._state_name)!s})", + _var_data=self._escape_var_data, + ).to(self._var_type) + + @property + def global_set(self) -> Var: + """Set the client state variable from JS outside a React component. + + Unlike `set` this needs no hook, so the returned function can be handed + to a wrapped library as a plain callback. Every subscribed component + re-renders. + + Returns: + A function Var which sets the value when called. + + Raises: + ValueError: If the ClientStateVar is not global. + """ + if not self._global_ref: + msg = "ClientStateVar must be global to set the value from any scope." + raise ValueError(msg) + return Var( + _js_expr=( + f"((value) => setClientState({LiteralVar.create(self._state_name)!s}, value))" + ), + _var_data=self._escape_var_data, + ).to(FunctionVar) + + def retrieve(self, callback: EventHandler | Callable | None = None) -> EventSpec: + """Pass the value of the client state variable to a backend EventHandler. + + The event handler must `yield` or `return` the EventSpec to trigger the event. + + Args: + callback: The callback to pass the value to. + + Returns: + An EventSpec which will retrieve the value when triggered. + + Raises: + ValueError: If the ClientStateVar is not global. + """ + if not self._global_ref: + msg = "ClientStateVar must be global to retrieve the value." + raise ValueError(msg) + callback_kwargs = {"callback": None} + if callback is not None: + callback_kwargs = { + "callback": str( + format.format_queue_events( + callback, + args_spec=lambda result: [result], + ) + ), + } + return server_side( + "_client_state_get", + inspect.signature(_client_state_get), + var_name=self._state_name, + **callback_kwargs, + ) + + def push(self, value: Any) -> EventSpec: + """Push a value to the client state variable from the backend. + + The event handler must `yield` or `return` the EventSpec to trigger the event. + + Args: + value: The value to update. + + Returns: + An EventSpec which will push the value when triggered. + + Raises: + ValueError: If the ClientStateVar is not global. + """ + if not self._global_ref: + msg = "ClientStateVar must be global to push the value." + raise ValueError(msg) + return server_side( + "_client_state_set", + inspect.signature(_client_state_set), + var_name=self._state_name, + value=value, + ) + + +client_state = ClientStateVar.create diff --git a/packages/reflex-base/src/reflex_base/components/client_state_context.py b/packages/reflex-base/src/reflex_base/components/client_state_context.py new file mode 100644 index 00000000000..9e765dbe3d5 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/components/client_state_context.py @@ -0,0 +1,39 @@ +"""App-wrap component mounting the client-state React provider. + +Wraps children in the ``ClientStateProvider`` exported by +``utils/client_state.js``. It is attached to the ``VarData`` a +:class:`~reflex_base.client_state.ClientStateVar` carries, so the compiler picks +it up through the generic Var-driven app-wrap pipeline rather than the JS Layout +template hard-coding it around every app. +""" + +from __future__ import annotations + +from reflex_base.components.component import Component +from reflex_base.constants import Dirs + +# Inside ErrorBoundary (55) so a client-state error is caught, outside the +# theme/toaster/overlay wraps. It depends on neither StateProvider nor +# EventLoopProvider. +CLIENT_STATE_APP_WRAP_PRIORITY = 50 + + +class ClientStateContextProvider(Component): + """App wrap that mounts the React client-state provider around children.""" + + library = f"$/{Dirs.CLIENT_STATE_PATH}" + tag = "ClientStateProvider" + + +def get_client_state_app_wraps() -> tuple[tuple[int, Component], ...]: + """Build the app-wrap entry advertising the client-state provider. + + Returns a fresh instance per call so render-cache state can't leak across + compile runs via ``copy.deepcopy``. Entries are deduped by + ``(priority, tag)``, and equal instances collapse to one wrapper, so any + number of client state vars on a page yield a single provider. + + Returns: + A single ``(priority, provider)`` entry. + """ + return ((CLIENT_STATE_APP_WRAP_PRIORITY, ClientStateContextProvider.create()),) diff --git a/packages/reflex-base/src/reflex_base/constants/base.py b/packages/reflex-base/src/reflex_base/constants/base.py index b5c9079517e..8bc0aefb46d 100644 --- a/packages/reflex-base/src/reflex_base/constants/base.py +++ b/packages/reflex-base/src/reflex_base/constants/base.py @@ -38,6 +38,8 @@ class Dirs(SimpleNamespace): COMPONENTS_PATH = UTILS + "/components" # The name of the contexts file. CONTEXTS_PATH = UTILS + "/context" + # The name of the client state file. + CLIENT_STATE_PATH = UTILS + "/client_state" # The name of the output directory. BUILD_DIR = "build" # The name of the static files directory. diff --git a/packages/reflex-base/src/reflex_base/constants/state.py b/packages/reflex-base/src/reflex_base/constants/state.py index 8742f76e185..b26a440aa20 100644 --- a/packages/reflex-base/src/reflex_base/constants/state.py +++ b/packages/reflex-base/src/reflex_base/constants/state.py @@ -14,3 +14,6 @@ class StateManagerMode(str, Enum): FIELD_MARKER = "_rx_state_" MEMO_MARKER = "_rx_memo_" CAMEL_CASE_MEMO_MARKER = "RxMemo" +# Suffix on the JS identifier a ClientStateVar binds its value to, so a user-chosen +# name can never collide with a JS reserved word (`class`, `const`, ...). +CAMEL_CASE_CLIENT_STATE_MARKER = "RxClientState" diff --git a/packages/reflex-components-internal/src/reflex_components_internal/blocks/demo_form.py b/packages/reflex-components-internal/src/reflex_components_internal/blocks/demo_form.py index cae1362ac7e..f00d638cc15 100644 --- a/packages/reflex-components-internal/src/reflex_components_internal/blocks/demo_form.py +++ b/packages/reflex-components-internal/src/reflex_components_internal/blocks/demo_form.py @@ -9,7 +9,6 @@ import reflex as rx from reflex.event import EventType -from reflex.experimental.client_state import ClientStateVar from reflex.vars.base import get_unique_variable_name from reflex_components_internal.blocks.telemetry.posthog import ( track_demo_form_posthog_submission, @@ -22,8 +21,8 @@ from reflex_components_internal.components.icons.others import select_arrow from reflex_components_internal.utils.twmerge import cn -demo_form_error_message = ClientStateVar.create("demo_form_error_message", "") -demo_form_open_cs = ClientStateVar.create("demo_form_open", False) +demo_form_error_message = rx.client_state("demo_form_error_message", "") +demo_form_open_cs = rx.client_state("demo_form_open", False) PERSONAL_EMAIL_PROVIDERS = r"^(?!.*@(gmail|outlook|hotmail|yahoo|icloud|aol|protonmail|mail|yandex|zoho|live|msn|me|mac|googlemail)\.com$|.*@(yahoo|outlook|hotmail)\.co\.uk$|.*@yahoo\.ca$|.*@yahoo\.co\.in$|.*@proton\.me$).*$" @@ -376,7 +375,7 @@ def demo_form( ), on_submit=[ DemoFormStateUI.track_demo_form_posthog, - rx.call_function(demo_form_open_cs.set_value(False)), + rx.call_function(demo_form_open_cs.set(False)), *extra_on_submit, ], data_default_form_id="965991", @@ -439,10 +438,8 @@ def demo_form_dialog( ), ), open=demo_form_open_cs.value, - on_open_change=demo_form_open_cs.set_value, - on_open_change_complete=[ - rx.call_function(demo_form_error_message.set_value("")) - ], + on_open_change=demo_form_open_cs.set, + on_open_change_complete=[rx.call_function(demo_form_error_message.set(""))], class_name=class_name, **props, ) diff --git a/packages/reflex-components-internal/src/reflex_components_internal/blocks/intro_form.py b/packages/reflex-components-internal/src/reflex_components_internal/blocks/intro_form.py index a5028c5296b..09a848d4dd8 100644 --- a/packages/reflex-components-internal/src/reflex_components_internal/blocks/intro_form.py +++ b/packages/reflex-components-internal/src/reflex_components_internal/blocks/intro_form.py @@ -7,7 +7,6 @@ import reflex as rx from reflex.event import EventType, IndividualEventType -from reflex.experimental.client_state import ClientStateVar from reflex.vars.base import get_unique_variable_name from reflex_components_internal.blocks.telemetry.posthog import ( track_intro_form_posthog_submission, @@ -20,9 +19,9 @@ from reflex_components_internal.components.icons.others import select_arrow from reflex_components_internal.utils.twmerge import cn -intro_form_error_message = ClientStateVar.create("intro_form_error_message", "") -intro_form_open_cs = ClientStateVar.create("intro_form_open", False) -is_submitting_intro_form_cs = ClientStateVar.create("is_submitting_intro_form", False) +intro_form_error_message = rx.client_state("intro_form_error_message", "") +intro_form_open_cs = rx.client_state("intro_form_open", False) +is_submitting_intro_form_cs = rx.client_state("is_submitting_intro_form", False) PERSONAL_EMAIL_PROVIDERS = r"^(?!.*@(gmail|outlook|hotmail|yahoo|icloud|aol|protonmail|mail|yandex|zoho|live|msn|me|mac|googlemail)\.com$|.*@(yahoo|outlook|hotmail)\.co\.uk$|.*@yahoo\.ca$|.*@yahoo\.co\.in$|.*@proton\.me$).*$" @@ -418,7 +417,7 @@ def intro_form_dialog( hi("Cancel01Icon"), variant="ghost", size="icon-sm", - on_click=intro_form_open_cs.set_value(False), + on_click=intro_form_open_cs.set(False), class_name="text-secondary-11", ), ), @@ -436,8 +435,8 @@ def intro_form_dialog( ), open=intro_form_open_cs.value, on_open_change_complete=[ - rx.call_function(intro_form_error_message.set_value("")), - rx.call_function(is_submitting_intro_form_cs.set_value(False)), + rx.call_function(intro_form_error_message.set("")), + rx.call_function(is_submitting_intro_form_cs.set(False)), ], class_name=class_name, **props, diff --git a/pyi_hashes.json b/pyi_hashes.json index f8a05b8d672..6730004e3a3 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -118,7 +118,7 @@ "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "99ebcfc07868061bdc3c2010d85a153f", "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "4f6c26f8c76543cc41e2b9dc400ece8a", "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f170ac685b6ba5892370166c80684db3", - "reflex/__init__.pyi": "a3e1782fab4a9aed55f66cc98af8c217", + "reflex/__init__.pyi": "630f98a9a6b1c357373ecb33f83194c1", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", "reflex/experimental/memo.pyi": "bc8b48357bef580e70a5881b65d3d3f7" } diff --git a/reflex/__init__.py b/reflex/__init__.py index 8aa2bfc2880..f96755bd1e8 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -161,6 +161,7 @@ ], "reflex_components_sonner.toast": ["toast"], "reflex_base.components.props": ["PropsBase"], + "reflex_base.client_state": ["ClientStateVar", "client_state"], "reflex_components_core.datadisplay.logo": ["logo"], "reflex_components_gridjs": ["data_table"], "reflex_components_moment": ["MomentDelta", "moment"], diff --git a/reflex/experimental/client_state.py b/reflex/experimental/client_state.py index e24315b4734..da4b7d501e5 100644 --- a/reflex/experimental/client_state.py +++ b/reflex/experimental/client_state.py @@ -1,297 +1,14 @@ -"""Handle client side state with `useState`.""" +"""Handle client side state with `useClientState`. -from __future__ import annotations - -import dataclasses -import re -from collections.abc import Callable -from typing import Any - -from reflex_base import constants -from reflex_base.event import EventChain, EventHandler, EventSpec, run_script -from reflex_base.utils.imports import ImportVar -from reflex_base.vars import VarData, get_unique_variable_name -from reflex_base.vars.base import LiteralVar, Var -from reflex_base.vars.function import ArgsFunctionOperationBuilder, FunctionVar - -NoValue = object() - - -_refs_import = { - f"$/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")], -} - - -def _client_state_ref(var_name: str) -> Var: - """Get the ref accessor Var for a ClientStateVar. - - Args: - var_name: The name of the variable. - - Returns: - A Var that accesses the ClientStateVar ref slot, carrying the - ``refs`` import from ``$/utils/state``. - """ - return Var( - _js_expr=f"refs['_client_state_{var_name}']", - _var_data=VarData(imports=_refs_import), - ) - - -def _client_state_ref_dict(var_name: str) -> Var: - """Get the per-instance ref-dict accessor Var for a ClientStateVar. - - Args: - var_name: The name of the variable. - - Returns: - A Var that accesses the ClientStateVar's per-instance ref dict, - carrying the ``refs`` import from ``$/utils/state``. - """ - return Var( - _js_expr=f"refs['_client_state_dict_{var_name}']", - _var_data=VarData(imports=_refs_import), - ) - - -@dataclasses.dataclass( - eq=False, - frozen=True, - slots=True, -) -class ClientStateVar(Var): - """A Var that exists on the client via useState.""" - - # Track the names of the getters and setters - _setter_name: str = dataclasses.field(default="") - _getter_name: str = dataclasses.field(default="") - _id_name: str = dataclasses.field(default="") - - # Whether to add the var and setter to the global `refs` object for use in any Component. - _global_ref: bool = dataclasses.field(default=True) - - def __hash__(self) -> int: - """Define a hash function for a var. - - Returns: - The hash of the var. - """ - return hash(( - self._js_expr, - str(self._var_type), - self._getter_name, - self._setter_name, - )) - - @classmethod - def create( - cls, - var_name: str | None = None, - default: Any = NoValue, - global_ref: bool = True, - ) -> ClientStateVar: - """Create a local_state Var that can be accessed and updated on the client. - - The `ClientStateVar` should be included in the highest parent component - that contains the components which will access and manipulate the client - state. It has no visual rendering, including it ensures that the - `useState` hook is called in the correct scope. - - To render the var in a component, use the `value` property. - - To update the var in a component, use the `set` property or `set_value` method. - - To access the var in an event handler, use the `retrieve` method with - `callback` set to the event handler which should receive the value. - - To update the var in an event handler, use the `push` method with the - value to update. - - Args: - var_name: The name of the variable. - default: The default value of the variable. - global_ref: Whether the state should be accessible in any Component and on the backend. +Deprecated location. The implementation moved to +:mod:`reflex_base.client_state` and is exposed as ``rx.client_state``; this +module re-exports it so existing imports keep working. +""" - Returns: - ClientStateVar - - Raises: - ValueError: If the var_name is not a string. - """ - if var_name is None: - var_name = get_unique_variable_name() - id_name = "id_" + get_unique_variable_name() - if not isinstance(var_name, str): - msg = "var_name must be a string." - raise ValueError(msg) - if default is NoValue: - default_var = Var(_js_expr="") - elif not isinstance(default, Var): - default_var = LiteralVar.create(default) - else: - default_var = default - setter_name = f"set{var_name.capitalize()}" - hooks: dict[str, VarData | None] = { - f"const {id_name} = useId()": None, - f"const [{var_name}, {setter_name}] = useState({default_var!s})": None, - } - imports = { - "react": [ImportVar(tag="useState"), ImportVar(tag="useId")], - } - if global_ref: - arg_name = get_unique_variable_name() - setter_ref = _client_state_ref(setter_name) - var_ref = _client_state_ref(var_name) - var_dict_ref = _client_state_ref_dict(var_name) - setter_dict_ref = _client_state_ref_dict(setter_name) - func = ArgsFunctionOperationBuilder.create( - args_names=(arg_name,), - return_expr=Var("Array.prototype.forEach.call") - .to(FunctionVar) - .call( - ( - Var("Object.values") - .to(FunctionVar) - .call(setter_dict_ref) - .to(list) - .to(list) - ) - + Var.create([Var(f"(value) => {{ {var_ref} = value; }}")]).to( - list - ), - ArgsFunctionOperationBuilder.create( - args_names=("setter",), - return_expr=Var("setter").to(FunctionVar).call(Var(arg_name)), - ), - ), - ) - - hooks[f"{setter_ref!s} = {func!s}"] = setter_ref._get_all_var_data() - hooks[f"{var_ref!s} ??= {var_name!s}"] = var_ref._get_all_var_data() - hooks[f"{var_dict_ref!s} ??= {{}}"] = var_dict_ref._get_all_var_data() - hooks[f"{setter_dict_ref!s} ??= {{}}"] = setter_dict_ref._get_all_var_data() - hooks[f"{var_dict_ref!s}[{id_name}] = {var_ref!s}"] = VarData.merge( - var_dict_ref._get_all_var_data(), var_ref._get_all_var_data() - ) - hooks[f"{setter_dict_ref!s}[{id_name}] = {setter_name}"] = ( - setter_dict_ref._get_all_var_data() - ) - return cls( - _js_expr="null", - _setter_name=setter_name, - _getter_name=var_name, - _id_name=id_name, - _global_ref=global_ref, - _var_type=default_var._var_type, - _var_data=VarData.merge( - default_var._var_data, - VarData( - hooks=hooks, - imports=imports, - ), - ), - ) - - @property - def value(self) -> Var: - """Get a placeholder for the Var. - - This property can only be rendered on the frontend. - - To access the value in a backend event handler, see `retrieve`. - - Returns: - an accessor for the client state variable. - """ - js_expr = ( - f"{_client_state_ref_dict(self._getter_name)}[{self._id_name}]" - if self._global_ref - else self._getter_name - ) - return Var(_js_expr=js_expr, _var_data=self._var_data).to(self._var_type) - - def set_value(self, value: Any = NoValue) -> Var: - """Set the value of the client state variable. - - This property can only be attached to a frontend event trigger. - - To set a value from a backend event handler, see `push`. - - Args: - value: The value to set. - - Returns: - A special EventChain Var which will set the value when triggered. - """ - setter = ( - _client_state_ref(self._setter_name) - if self._global_ref - else Var(self._setter_name) - ).to(FunctionVar) - - if value is not NoValue: - # This is a hack to make it work like an EventSpec taking an arg - value_var = LiteralVar.create(value) - value_str = str(value_var) - - setter = ArgsFunctionOperationBuilder.create( - # remove patterns of ["*"] from the value_str using regex - args_names=(re.sub(r"(\?\.)?\[\".*\"\]", "", value_str),) - if value_str.startswith("_") - else (), - return_expr=setter.call(value_var), - ) - - return setter.to(FunctionVar, EventChain) - - @property - def set(self) -> Var: - """Set the value of the client state variable. - - This property can only be attached to a frontend event trigger. - - To set a value from a backend event handler, see `push`. - - Returns: - A special EventChain Var which will set the value when triggered. - """ - return self.set_value() - - def retrieve(self, callback: EventHandler | Callable | None = None) -> EventSpec: - """Pass the value of the client state variable to a backend EventHandler. - - The event handler must `yield` or `return` the EventSpec to trigger the event. - - Args: - callback: The callback to pass the value to. - - Returns: - An EventSpec which will retrieve the value when triggered. - - Raises: - ValueError: If the ClientStateVar is not global. - """ - if not self._global_ref: - msg = "ClientStateVar must be global to retrieve the value." - raise ValueError(msg) - return run_script(_client_state_ref(self._getter_name), callback=callback) - - def push(self, value: Any) -> EventSpec: - """Push a value to the client state variable from the backend. - - The event handler must `yield` or `return` the EventSpec to trigger the event. - - Args: - value: The value to update. +from __future__ import annotations - Returns: - An EventSpec which will push the value when triggered. +from reflex_base.client_state import ClientStateVar as ClientStateVar +from reflex_base.client_state import NoValue as NoValue +from reflex_base.client_state import client_state as client_state - Raises: - ValueError: If the ClientStateVar is not global. - """ - if not self._global_ref: - msg = "ClientStateVar must be global to push the value." - raise ValueError(msg) - value = Var.create(value) - return run_script(f"{_client_state_ref(self._setter_name)}({value})") +__all__ = ["ClientStateVar", "NoValue", "client_state"] diff --git a/tests/integration/tests_playwright/test_client_state.py b/tests/integration/tests_playwright/test_client_state.py new file mode 100644 index 00000000000..44878043f83 --- /dev/null +++ b/tests/integration/tests_playwright/test_client_state.py @@ -0,0 +1,231 @@ +"""Integration tests for ``rx.client_state`` runtime behavior. + +Covers what unit tests cannot: the React runtime in ``utils/client_state.js``. +Shared named vars staying in sync across components, backend ``push``/``retrieve`` +over the new wire events, ``global_ref=False`` isolation, the non-React escape +hatch, functional updaters, and — the property the store exists to guarantee — +that writing one var does not re-render components subscribed only to another. +""" + +from collections.abc import Generator + +import pytest +from playwright.sync_api import Page, expect + +from reflex.testing import AppHarness + + +def ClientStateApp(): + """App exercising ``rx.client_state`` runtime behavior.""" + from reflex_base.vars.function import ArgsFunctionOperationBuilder, FunctionVar + + import reflex as rx + + shared = rx.client_state("shared", default="initial") + counter = rx.client_state("counter", default=0) + other = rx.client_state("other", default="untouched") + + class ClientStateAppState(rx.State): + retrieved: str = "" + + @rx.event + def push_shared(self): + return shared.push("from-backend") + + @rx.event + def do_retrieve(self): + return shared.retrieve(ClientStateAppState.got_value) + + @rx.event + def got_value(self, value: str): + self.retrieved = value + + @rx.memo + def local_input(label: rx.Var[str]) -> rx.Component: + # global_ref=False: each rendered instance owns a private slot. + local = rx.client_state(global_ref=False, default="") + return rx.hstack( + rx.input( + value=local.value, + on_change=local.set, + id=f"local-input-{label}", + ), + rx.text(local.value, id=f"local-echo-{label}"), + ) + + def index() -> rx.Component: + return rx.vstack( + rx.input( + value=ClientStateAppState.router.session.client_token, + read_only=True, + id="token", + ), + # Two independent readers of the same named var. + rx.text(shared.value, id="shared-a"), + rx.text(shared.value, id="shared-b"), + rx.input(value=shared.value, on_change=shared.set, id="shared-input"), + rx.button("set-shared", id="set-shared", on_click=shared.set("clicked")), + # Functional updater. + rx.text(counter.value, id="counter-value"), + rx.button( + "increment", id="increment", on_click=counter.set(lambda v: v + 1) + ), + # A var nothing else writes, to prove writes are isolated. + rx.text(other.value, id="other-value"), + # Backend round trips. + rx.button("push", id="push", on_click=ClientStateAppState.push_shared), + rx.button( + "retrieve", id="retrieve", on_click=ClientStateAppState.do_retrieve + ), + rx.text(ClientStateAppState.retrieved, id="retrieved"), + # Escape hatch: a plain JS function, no hook in scope. This is what + # gets handed to a wrapped library as a callback. + rx.button( + "global-set", + id="global-set", + on_click=rx.call_function( + ArgsFunctionOperationBuilder.create( + args_names=(), + return_expr=shared.global_set.to(FunctionVar).call( + "from-plain-js" + ), + ) + ), + ), + # rx.call_script evals inside the Reflex runtime module, where the + # page's imports are not in scope, so reach the store via refs. + rx.button( + "global-set-script", + id="global-set-script", + on_click=rx.call_script( + 'refs["__client_state"].set("shared", "from-call-script")' + ), + ), + local_input(label="one"), + local_input(label="two"), + ) + + app = rx.App() + app.add_page(index) + + +@pytest.fixture(scope="module") +def client_state_app(tmp_path_factory) -> Generator[AppHarness, None, None]: + """Start the client state app. + + Args: + tmp_path_factory: pytest tmp_path_factory fixture. + + Yields: + The running AppHarness. + """ + with AppHarness.create( + root=tmp_path_factory.mktemp("client_state_app"), + app_source=ClientStateApp, + ) as harness: + yield harness + + +@pytest.fixture +def page(client_state_app: AppHarness, page: Page) -> Page: + """Navigate to the app and wait for hydration. + + Args: + client_state_app: The running harness. + page: The playwright page. + + Returns: + The page, loaded and hydrated. + """ + assert client_state_app.frontend_url is not None + page.goto(client_state_app.frontend_url) + expect(page.locator("#token")).not_to_have_value("") + return page + + +def test_named_var_is_shared_across_components(page: Page) -> None: + """Two components reading one named var stay in sync.""" + expect(page.locator("#shared-a")).to_have_text("initial") + expect(page.locator("#shared-b")).to_have_text("initial") + + page.locator("#shared-input").fill("typed") + + expect(page.locator("#shared-a")).to_have_text("typed") + expect(page.locator("#shared-b")).to_have_text("typed") + + +def test_set_with_bound_value(page: Page) -> None: + """``set(value)`` attached to a trigger sets that value.""" + page.locator("#set-shared").click() + expect(page.locator("#shared-a")).to_have_text("clicked") + + +def test_functional_updater_derives_from_current_value(page: Page) -> None: + """``set(lambda v: v + 1)`` increments rather than overwriting.""" + expect(page.locator("#counter-value")).to_have_text("0") + for expected in ("1", "2", "3"): + page.locator("#increment").click() + expect(page.locator("#counter-value")).to_have_text(expected) + + +def test_push_from_backend(page: Page) -> None: + """A backend ``push`` reaches the mounted components.""" + page.locator("#push").click() + expect(page.locator("#shared-a")).to_have_text("from-backend") + expect(page.locator("#shared-b")).to_have_text("from-backend") + + +def test_retrieve_to_backend(page: Page) -> None: + """``retrieve`` round-trips the current value to a backend handler.""" + page.locator("#shared-input").fill("to-retrieve") + expect(page.locator("#shared-a")).to_have_text("to-retrieve") + + page.locator("#retrieve").click() + expect(page.locator("#retrieved")).to_have_text("to-retrieve") + + +def test_global_set_from_plain_javascript(page: Page) -> None: + """The escape hatch drives the var from JS with no hook in scope.""" + page.locator("#global-set").click() + expect(page.locator("#shared-a")).to_have_text("from-plain-js") + expect(page.locator("#shared-b")).to_have_text("from-plain-js") + + +def test_store_is_reachable_through_refs(page: Page) -> None: + """``refs["__client_state"]`` is the documented entry point for eval'd code. + + ``rx.call_script`` runs inside the Reflex runtime module, so a page-level + import of ``setClientState`` is not in scope there; the single ``refs`` key + is what makes the store reachable (and introspectable from devtools). + """ + page.locator("#global-set-script").click() + expect(page.locator("#shared-a")).to_have_text("from-call-script") + expect(page.locator("#shared-b")).to_have_text("from-call-script") + + +def test_local_vars_are_isolated_between_instances(page: Page) -> None: + """``global_ref=False`` gives each rendered instance its own slot.""" + page.locator("#local-input-one").fill("only-one") + + expect(page.locator("#local-echo-one")).to_have_text("only-one") + expect(page.locator("#local-echo-two")).to_have_text("") + + page.locator("#local-input-two").fill("only-two") + + expect(page.locator("#local-echo-one")).to_have_text("only-one") + expect(page.locator("#local-echo-two")).to_have_text("only-two") + + +def test_writing_one_var_leaves_other_readers_untouched(page: Page) -> None: + """Per-var subscriptions: writing ``shared`` must not disturb ``other``. + + The old implementation fanned every write out to every registered setter, + so a component reading an unrelated var still re-rendered. + """ + expect(page.locator("#other-value")).to_have_text("untouched") + + page.locator("#shared-input").fill("churn") + page.locator("#increment").click() + + expect(page.locator("#shared-a")).to_have_text("churn") + expect(page.locator("#other-value")).to_have_text("untouched") diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 4f47acc1120..a7f7ea11745 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -1320,27 +1320,24 @@ def page() -> Component: ) -def test_client_state_setter_in_call_function_event_imports_refs() -> None: - """A button whose ``on_click`` calls a global ``ClientStateVar`` setter - must memoize and the resulting memo body's imports must include ``refs`` - from ``$/utils/state``. - - Regression: ``ClientStateVar.set_value`` builds its setter as - ``refs['_client_state_']`` but the returned setter ``Var`` does not - carry the ``refs`` import. When the on_click event chain is compiled into - the memo body, the body references ``refs['_client_state_'](42)`` - with no matching ``import { refs } from "$/utils/state"`` — producing a - ``ReferenceError: refs is not defined`` at runtime. +def test_client_state_setter_in_call_function_event_imports_hook() -> None: + """A button whose ``on_click`` calls a ``ClientStateVar`` setter must memoize + and the resulting memo body must declare the ``useClientState`` hook and + import it from ``$/utils/client_state``. + + The setter is the local binding returned by the hook, so the memo body is + only valid if ``ClientStateVar.set`` carries its own hook VarData. When it + did not, the body referenced a setter that nothing declared, producing a + ``ReferenceError`` at runtime. """ from reflex.compiler.compiler import compile_memo_components - from reflex.experimental.client_state import ClientStateVar - counter = ClientStateVar.create("counter", default=0) + counter = rx.client_state("counter", default=0) def page() -> Component: return rx.el.button( "click", - on_click=rx.call_function(counter.set_value(42)), + on_click=rx.call_function(counter.set(42)), ) ctx, _page_ctx = _compile_single_page(page) @@ -1358,25 +1355,32 @@ def page() -> Component: code for path, code in memo_files if Path(path).name == f"{wrapper_tag}.jsx" ) - assert "refs['_client_state_setCounter'](42)" in memo_code, ( - "Expected the memo body to call the client-state setter via refs.\n" + assert "setCounter(42)" in memo_code, ( + "Expected the memo body to call the client-state setter.\n" f"Memo code snippet: {memo_code[:2000]}" ) + assert 'useClientState(0, "counter")' in memo_code, ( + "Expected the memo body to declare the client-state hook so the setter " + f"binding exists.\nMemo code snippet: {memo_code[:2000]}" + ) - state_import_match = re.search( - r'^import\s*\{([^}]*)\}\s*from\s*"\$/utils/state"', + import_match = re.search( + r'^import\s*\{([^}]*)\}\s*from\s*"\$/utils/client_state"', memo_code, flags=re.MULTILINE, ) - assert state_import_match is not None, ( - "Memo body must import from $/utils/state since the on_click handler " - "uses refs['_client_state_setCounter'].\n" - f"Memo code snippet: {memo_code[:2000]}" + assert import_match is not None, ( + "Memo body must import from $/utils/client_state since it calls " + f"useClientState.\nMemo code snippet: {memo_code[:2000]}" + ) + imported_names = {name.strip() for name in import_match.group(1).split(",")} + assert "useClientState" in imported_names, ( + f"Memo body imports {imported_names!r} from $/utils/client_state but is " + f"missing 'useClientState'.\nMemo code snippet: {memo_code[:2000]}" ) - imported_names = {name.strip() for name in state_import_match.group(1).split(",")} - assert "refs" in imported_names, ( - f"Memo body imports {imported_names!r} from $/utils/state but is missing " - "'refs' — the on_click handler references refs['_client_state_setCounter'].\n" + + assert "refs['_client_state" not in memo_code, ( + "Client state must no longer route through the global refs object.\n" f"Memo code snippet: {memo_code[:2000]}" ) @@ -2126,15 +2130,13 @@ def test_client_state_value_inside_snapshot_boundary_is_memoized( ) -> None: """Client-state Vars are reactive and must trigger boundary memoization. - A ``client_state`` Var contributes its ``useState``/``useId`` hooks via + A ``client_state`` Var contributes its ``useClientState`` hook via ``var_data.hooks`` without setting ``var_data.state``. The reactive-Var walk must catch the hooks-only case so client-state-driven content inside a snapshot boundary lands in the memo body. Both global and page-local ``ClientStateVar`` Vars must drive the same wrapping. """ - from reflex.experimental.client_state import ClientStateVar - - cs_var = ClientStateVar.create("titletest", default="hi", global_ref=global_ref) + cs_var = rx.client_state("titletest", default="hi", global_ref=global_ref) title = Title.create(cs_var.value) ctx, page_ctx = _compile_single_page(lambda: title) assert len(ctx.memoize_wrappers) == 1, ( @@ -2143,7 +2145,7 @@ def test_client_state_value_inside_snapshot_boundary_is_memoized( ) page_output = page_ctx.output_code assert page_output is not None - assert "useState" not in page_output, ( + assert "useClientState" not in page_output, ( "Client-state hooks should be inside the memo body, not the page.\n" f"Page output snippet: {page_output[:2000]}" ) diff --git a/tests/units/experimental/__init__.py b/tests/units/experimental/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/units/experimental/test_client_state.py b/tests/units/experimental/test_client_state.py new file mode 100644 index 00000000000..0665f10b59a --- /dev/null +++ b/tests/units/experimental/test_client_state.py @@ -0,0 +1,21 @@ +"""The deprecated reflex.experimental.client_state path still resolves.""" + +import reflex as rx + + +def test_experimental_import_is_the_promoted_class() -> None: + """``reflex.experimental.client_state`` re-exports the reflex-base class.""" + from reflex.experimental.client_state import ClientStateVar + + assert ClientStateVar is rx.ClientStateVar + + +def test_experimental_namespace_factory_still_works() -> None: + """``rx._x.client_state`` keeps building the same vars.""" + assert rx._x.client_state("legacy", default=0)._state_name == "legacy" + + +def test_promoted_names_are_reachable_from_rx() -> None: + """The lazy-loader wiring only fails at attribute access, so assert it.""" + assert rx.client_state("promoted", default=0)._state_name == "promoted" + assert isinstance(rx.client_state("typed", default=0), rx.ClientStateVar) diff --git a/tests/units/reflex_base/test_client_state.py b/tests/units/reflex_base/test_client_state.py new file mode 100644 index 00000000000..29e877b7258 --- /dev/null +++ b/tests/units/reflex_base/test_client_state.py @@ -0,0 +1,460 @@ +"""Tests for reflex_base.client_state.""" + +from typing import Any + +import pytest +from reflex_base.client_state import ClientStateVar, _recovered_event_arg, client_state +from reflex_base.components.client_state_context import CLIENT_STATE_APP_WRAP_PRIORITY +from reflex_base.components.memo import MEMOS +from reflex_base.constants import Dirs +from reflex_base.utils.exceptions import VarTypeError +from reflex_base.vars.base import Var, VarData +from reflex_base.vars.function import FunctionVar + +import reflex as rx +from reflex.compiler import compiler + + +def _hook(cs: ClientStateVar) -> str: + """Get the single hook a client state var contributes. + + Args: + cs: The client state var. + + Returns: + The hook source line. + """ + hooks = list(cs._var_data.hooks) # pyright: ignore [reportOptionalMemberAccess] + assert len(hooks) == 1, f"expected exactly one hook, got {hooks}" + return hooks[0] + + +def _app_wraps(var_data: VarData | None) -> list[tuple[int, str]]: + """Summarize the app wraps a VarData carries. + + Args: + var_data: The var data to inspect. + + Returns: + A list of (priority, tag) pairs. + """ + assert var_data is not None + return [(priority, wrap.tag or "") for priority, wrap in var_data.app_wraps] # pyright: ignore [reportAttributeAccessIssue] + + +def test_single_hook_no_useState() -> None: + """A global var emits one useClientState hook and no raw useState/useId.""" + cs = client_state("counter", default=0) + hook = _hook(cs) + assert ( + hook + == 'const [counterRxClientState, setCounter] = useClientState(0, "counter")' + ) + assert "useState(" not in hook + assert "useId" not in hook + assert "refs[" not in hook + + +def test_local_var_omits_store_name() -> None: + """A ``global_ref=False`` var gets no name, so its slot stays private.""" + cs = client_state("copied", default=False, global_ref=False) + assert _hook(cs) == "const [copiedRxClientState, setCopied] = useClientState(false)" + + +def test_hook_imports_use_client_state() -> None: + """The hook carries the useClientState import.""" + imports = dict(cs_imports := client_state("x", default=0)._var_data.imports) # pyright: ignore [reportOptionalMemberAccess] + assert cs_imports is not None + tags = {i.tag for i in imports[f"$/{Dirs.CLIENT_STATE_PATH}"]} + assert tags == {"useClientState"} + + +@pytest.mark.parametrize("global_ref", [True, False]) +def test_provider_app_wrap_declared(global_ref: bool) -> None: + """The provider is requested in both modes; the hook always uses context.""" + cs = client_state("x", default=0, global_ref=global_ref) + assert _app_wraps(cs._var_data) == [ + (CLIENT_STATE_APP_WRAP_PRIORITY, "ClientStateProvider") + ] + + +def test_two_vars_dedupe_to_one_provider() -> None: + """Two client state vars must not conflict over the app-wrap slot.""" + from reflex_base.vars.base import insert_app_wraps + + target: dict[tuple[int, str], Any] = {} + for name in ("a", "b"): + cs = client_state(name, default=0) + insert_app_wraps(target, cs._var_data.app_wraps) # pyright: ignore [reportOptionalMemberAccess] + assert list(target) == [(CLIENT_STATE_APP_WRAP_PRIORITY, "ClientStateProvider")] + + +@pytest.mark.parametrize("global_ref", [True, False]) +def test_value_is_marked_identifier(global_ref: bool) -> None: + """``value`` renders the marked local binding in both modes.""" + cs = client_state("counter", default=0, global_ref=global_ref) + assert str(cs.value) == "counterRxClientState" + + +def test_set_bare_is_event_chain() -> None: + """``set`` renders the bare setter and is usable as an event trigger value.""" + from reflex_base.event import EventChain + + cs = client_state("counter", default=0) + assert str(cs.set) == "setCounter" + assert cs.set._var_type is EventChain + + +def test_set_bound_value() -> None: + """Calling ``set`` binds a value in a zero-arg wrapper.""" + cs = client_state("counter", default=0) + assert str(cs.set(42)) == "(() => (setCounter(42)))" + + +def test_set_carries_hook_import_and_app_wrap() -> None: + """The setter must drag in its own hook, import and provider.""" + cs = client_state("counter", default=0) + for setter in (cs.set, cs.set(42)): + var_data = setter._get_all_var_data() + assert var_data is not None + assert any("useClientState" in hook for hook in var_data.hooks) + assert f"$/{Dirs.CLIENT_STATE_PATH}" in dict(var_data.imports) + assert _app_wraps(var_data) == [ + (CLIENT_STATE_APP_WRAP_PRIORITY, "ClientStateProvider") + ] + + +@pytest.mark.parametrize( + ("default", "fn", "expected"), + [ + ( + 0, + lambda v: v + 1, + "(() => (setX(((prev{n}RxClientState) => (prev{n}RxClientState + 1)))))", + ), + ( + False, + lambda v: ~v, # noqa: FURB118 - a lambda is what is under test + "(() => (setX(((prev{n}RxClientState) => !(prev{n}RxClientState)))))", + ), + ( + "", + lambda v: v.upper(), + "(() => (setX(((prev{n}RxClientState) => prev{n}RxClientState.toUpperCase()))))", + ), + ], +) +def test_set_functional_updater_is_typed(default: Any, fn: Any, expected: str) -> None: + """A lambda is traced against a placeholder typed like the var.""" + cs = client_state("x", default=default) + rendered = str(cs.set(fn)) + # The placeholder counter is process-global; recover it from the output. + n = rendered.split("prev", 1)[1].split("RxClientState", 1)[0] + assert rendered == expected.format(n=n) + + +def test_set_zero_arg_callable_is_plain_value() -> None: + """A zero-argument callable is treated as the value, not an updater.""" + cs = client_state("x", default=0) + assert str(cs.set(lambda: 7)) == "(() => (setX(7)))" + + +def test_set_rejects_multi_arg_callable() -> None: + """An updater may only take the current value.""" + cs = client_state("x", default=0) + with pytest.raises(VarTypeError): + cs.set(lambda a, b: a + b) # pyright: ignore [reportCallIssue] # noqa: FURB118 - a lambda is what is under test + + +def test_set_passes_function_var_through() -> None: + """A FunctionVar is passed straight through as a runtime updater.""" + cs = client_state("x", default=0) + updater = Var("(p) => p + 1").to(FunctionVar) + assert str(cs.set(updater)) == "(() => (setX((p) => p + 1)))" + + +def test_set_declares_event_arg() -> None: + """A value referencing an event arg makes the wrapper declare it.""" + cs = client_state("x", default="") + assert ( + str(cs.set(Var('_e["target"]["value"]'))) + == '((_e) => (setX(_e["target"]["value"])))' + ) + + +def test_set_declares_event_arg_in_compound_expression() -> None: + """Only the event arg is declared, not the whole expression.""" + cs = client_state("x", default="") + assert ( + str(cs.set(Var('_e["target"]["value"] + "!"'))) + == '((_e) => (setX(_e["target"]["value"] + "!")))' + ) + + +def test_underscore_named_var_is_not_mistaken_for_event_arg() -> None: + """A marked identifier is an in-scope binding, never a trigger parameter.""" + private = client_state("_private", default="") + other = client_state("other", default="") + assert str(other.set(private.value)) == "(() => (setOther(_privateRxClientState)))" + + +@pytest.mark.parametrize( + ("value_str", "expected"), + [ + ('_e["target"]["value"]', "_e"), + ("_e", "_e"), + ('_e["a"] + "b"', "_e"), + ("_privateRxClientState", None), + ("valueRxMemo", None), + ("counterRxClientState", None), + ("42", None), + ('"literal"', None), + ], +) +def test_recovered_event_arg(value_str: str, expected: str | None) -> None: + """Event args are recovered; marked in-scope identifiers are not.""" + assert _recovered_event_arg(value_str) == expected + + +@pytest.mark.parametrize( + "reserved", + [ + "class", + "const", + "let", + "var", + "function", + "return", + "new", + "delete", + "default", + "typeof", + "await", + "if", + "for", + ], +) +def test_reserved_words_are_safe(reserved: str) -> None: + """A JS reserved word is a legal name; the marker keeps the codegen valid.""" + cs = client_state(reserved, default=1) + hook = _hook(cs) + assert hook.startswith(f"const [{reserved}RxClientState, ") + # The store key stays the bare word so the backend can still address it. + assert f'"{reserved}"' in hook + assert cs._state_name == reserved + + +def test_camel_case_names_get_distinct_setters() -> None: + """``myVar`` and ``myvar`` must not collapse onto one setter binding.""" + assert client_state("myVar")._setter_name != client_state("myvar")._setter_name + + +def test_var_name_rejects_var() -> None: + """A Var name would only exist at runtime, so it is rejected.""" + with pytest.raises(ValueError, match="not a Var"): + client_state(Var("dynamic")) # pyright: ignore [reportArgumentType] + + +@pytest.mark.parametrize("bad", ["1foo", "my-name", "a b", "", "a.b"]) +def test_var_name_must_be_identifier(bad: str) -> None: + """The name is emitted as a JS identifier, so it has to be one.""" + with pytest.raises(ValueError, match="identifier"): + client_state(bad) + + +def test_generated_names_are_sequential_and_distinct() -> None: + """Unnamed vars get distinct, counter-derived names.""" + names = [client_state()._state_name for _ in range(3)] + assert len(set(names)) == 3 + assert all(name.startswith("cs") for name in names) + numbers = [int(name.removeprefix("cs")) for name in names] + assert numbers == sorted(numbers) + + +def test_generated_names_unaffected_by_unrelated_var_names() -> None: + """An unrelated placeholder draw must not shift the client state sequence.""" + from reflex_base.vars.base import get_unique_variable_name + + before = int(client_state()._state_name.removeprefix("cs")) + get_unique_variable_name() + rx.Var.create([1, 2, 3]).to(list).map(lambda x: x) # pyright: ignore [reportAttributeAccessIssue] + after = int(client_state()._state_name.removeprefix("cs")) + assert after == before + 1 + + +def test_push_builds_wire_event() -> None: + """``push`` sends a first-class client-state event, not an eval'd script.""" + cs = client_state("counter", default=0) + spec = cs.push(5) + assert spec.handler.fn.__qualname__ == "_client_state_set" + assert {str(k): str(v) for k, v in spec.args} == { + "var_name": '"counter"', + "value": "5", + } + + +def test_retrieve_builds_wire_event() -> None: + """``retrieve`` sends a first-class client-state event with a callback slot.""" + cs = client_state("counter", default=0) + args = {str(k): str(v) for k, v in cs.retrieve().args} + assert cs.retrieve().handler.fn.__qualname__ == "_client_state_get" + assert args["var_name"] == '"counter"' + assert "callback" in args + + +def test_global_accessors_render_module_functions() -> None: + """The escape hatch reads and writes through the module-level functions.""" + cs = client_state("counter", default=0) + assert str(cs.global_value) == 'getClientState("counter")' + assert str(cs.global_set) == '((value) => setClientState("counter", value))' + + +def test_global_accessors_carry_no_hook() -> None: + """The escape hatch must work in any scope, so it drags in no hook.""" + cs = client_state("counter", default=0) + for accessor in (cs.global_value, cs.global_set): + var_data = accessor._get_all_var_data() + assert var_data is not None + assert not var_data.hooks + assert f"$/{Dirs.CLIENT_STATE_PATH}" in dict(var_data.imports) + assert _app_wraps(var_data) == [ + (CLIENT_STATE_APP_WRAP_PRIORITY, "ClientStateProvider") + ] + + +@pytest.mark.parametrize( + "accessor", + ["push", "retrieve", "global_value", "global_set"], +) +def test_name_addressed_paths_require_global(accessor: str) -> None: + """An anonymous slot has no name, so nothing can address it.""" + cs = client_state("x", default=0, global_ref=False) + with pytest.raises(ValueError, match="must be global"): + if accessor == "push": + cs.push(1) + elif accessor == "retrieve": + cs.retrieve() + else: + getattr(cs, accessor) + + +def test_set_value_delegates_and_deprecates(capsys: pytest.CaptureFixture) -> None: + """``set_value`` still works, and says to use ``set``.""" + cs = client_state("counter", default=0) + assert str(cs.set_value(42)) == str(cs.set(42)) + assert "set_value" in capsys.readouterr().out + + +def test_var_renders_as_null() -> None: + """The var object itself renders as null so it can sit in a component tree.""" + assert str(client_state("x", default=0)) == "null" + + +def test_acceptance_throttle_controlled_input_compiles() -> None: + """A memo composing local client state, `.set` bare and bound, and chains. + + This is the target ergonomics for the promoted API: two unnamed local vars + in one component, `.set` attached bare to a trigger and called with a memo + prop Var, and both forms mixed in one event-chain list. + """ + + @rx.memo + def debounce_controlled_input( + value: rx.Var[str], + on_change: rx.EventHandler, + debounce_ms: rx.Var[int], + rest: rx.RestProp, + ) -> rx.Component: + lc_var = rx.client_state(global_ref=False) + lc_last_var = rx.client_state(global_ref=False) + return rx.el.input( + rest, + rx.cond( + value != lc_var.value, + rx.fragment(), + ), + rx.fragment( + key=value, + on_mount=[lc_var.set(value), lc_last_var.set(value)], + ), + value=lc_var.value, + on_change=[lc_last_var.set(lc_var.value), lc_var.set], + ) + + component = debounce_controlled_input( + value="hello", on_change=rx.noop(), debounce_ms=200, class_name="x" + ) + assert component.render() + + definition = MEMOS["DebounceControlledInput", __name__] + files, _ = compiler.compile_memo_components((definition,)) + code = "\n".join(c for _, c in files) + + hook_lines = [ + line.strip() for line in code.splitlines() if "useClientState" in line + ] + declarations = [line for line in hook_lines if line.startswith("const [")] + assert len(declarations) == 2, ( + f"expected one hook per local var, got {declarations}" + ) + # Distinct bindings, and neither is registered under a shared store name. + assert len(set(declarations)) == 2 + assert all("useClientState()" in line for line in declarations) + assert 'from "$/utils/client_state"' in code + + +def test_set_binds_memo_prop_var_without_declaring_an_arg() -> None: + """A memo prop is an in-scope binding, so the wrapper takes no parameter.""" + captured: dict[str, rx.Var] = {} + + @rx.memo + def comp(value: rx.Var[str]) -> rx.Component: + captured["value"] = value + return rx.el.input(value=value) + + comp(value="x") + cs = rx.client_state("target", default="") + assert str(cs.set(captured["value"])) == "(() => (setTarget(valueRxMemo)))" + + +def test_set_with_no_argument_is_the_bare_setter() -> None: + """``cs.set()`` is the same forwarding setter as ``cs.set``.""" + cs = client_state("counter", default=0) + assert str(cs.set()) == str(cs.set) == "setCounter" + + +def test_hash_distinguishes_vars() -> None: + """Vars are hashable and distinct names hash differently.""" + a = client_state("a", default=0) + b = client_state("b", default=0) + assert hash(a) != hash(b) + assert len({a, b, a}) == 2 + + +def test_var_name_rejects_non_string() -> None: + """A non-string, non-Var name is rejected.""" + with pytest.raises(ValueError, match="must be a string"): + client_state(5) # pyright: ignore [reportArgumentType] + + +def test_var_default_is_used_directly() -> None: + """A Var default is embedded as-is and sets the var's type.""" + cs = client_state("x", default=Var("someExpr").to(int)) + assert "useClientState(someExpr" in _hook(cs) + assert cs._var_type is int + + +def test_retrieve_with_callback_serializes_the_handler() -> None: + """``retrieve(callback)`` embeds the queued-events callback in the payload.""" + + class RetrieveState(rx.State): + value: str = "" + + def got(self, value: str): + self.value = value + + cs = client_state("counter", default=0) + args = {str(k): str(v) for k, v in cs.retrieve(RetrieveState.got).args} + assert args["var_name"] == '"counter"' + assert "queueEvents" in args["callback"] + assert "got" in args["callback"] From 6225012893f46bafa2eec65a27d55ef2c62a7953 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 01:47:00 +0000 Subject: [PATCH 02/15] fix(client_state): valid codegen with no default, and two runtime fixes Three issues found reviewing the previous commit: - A named var with no default emitted `useClientState(, "name")`, a syntax error that breaks the page build, because the store name is passed as a second argument and the empty default rendered as nothing. Emit an explicit `undefined`. - `push` sent its value as a JSON payload, so a `Var` -- a client-side expression -- arrived as its own source text instead of being evaluated. Route a Var through the evaluated path via `refs["__client_state"]`, keeping the JSON payload for concrete values. - `getClientStore` memoized a module-level store on the server too, so if the provider were ever absent the SSR fallback could carry a value between requests. Return a fresh store when there is no `document`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- .../.templates/web/utils/client_state.js | 16 +++---- .../src/reflex_base/client_state.py | 32 ++++++++++++- tests/units/reflex_base/test_client_state.py | 48 ++++++++++++++++++- 3 files changed, 84 insertions(+), 12 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js index ea8465c4a3b..97774354aaa 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js @@ -107,11 +107,14 @@ let _clientStore = null; * The client-side store singleton. * * Shared so that non-React callers and the hooks operate on the same slots - * regardless of mount order. Never used during SSR — `ClientStateProvider` - * builds a per-render store on the server so requests stay isolated. + * regardless of mount order. On the server a fresh store is returned every + * call and never memoized, so no value can leak between requests. * @returns The store. */ export const getClientStore = () => { + if (typeof document === "undefined") { + return createClientStateStore(); + } if (_clientStore === null) { _clientStore = createClientStateStore(); } @@ -151,12 +154,9 @@ export const setClientState = (name, value) => { export function ClientStateProvider({ children }) { const storeRef = useRef(null); if (storeRef.current === null) { - // A per-render store on the server keeps requests isolated; on the client, - // share the singleton so `setClientState` reaches these same slots. - storeRef.current = - typeof document === "undefined" - ? createClientStateStore() - : getClientStore(); + // On the client this is the shared singleton, so `setClientState` and the + // hooks reach the same slots; on the server it is per-render. + storeRef.current = getClientStore(); } const store = storeRef.current; diff --git a/packages/reflex-base/src/reflex_base/client_state.py b/packages/reflex-base/src/reflex_base/client_state.py index d6ea6a5ce16..90fe1a933c1 100644 --- a/packages/reflex-base/src/reflex_base/client_state.py +++ b/packages/reflex-base/src/reflex_base/client_state.py @@ -16,7 +16,13 @@ CAMEL_CASE_MEMO_MARKER, FIELD_MARKER, ) -from reflex_base.event import EventChain, EventHandler, EventSpec, server_side +from reflex_base.event import ( + EventChain, + EventHandler, + EventSpec, + run_script, + server_side, +) from reflex_base.utils import console, format from reflex_base.utils.exceptions import VarTypeError from reflex_base.utils.imports import ImportVar @@ -52,6 +58,17 @@ _VALID_NAME = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*$") +# The store's entry point on the global `refs` object. This is the only binding +# reachable from the scope `run_script` code is evaluated in, and doubles as the +# devtools handle for inspecting client state. Must match CLIENT_STATE_REF in +# `$/utils/client_state`. +_client_state_store_ref = Var( + _js_expr='refs["__client_state"]', + _var_data=VarData( + imports={f"$/{Dirs.STATE_PATH}": [ImportVar(tag="refs")]}, + ), +) + # Reflex marks every identifier it puts in scope; an unmarked `_`-leading name is # an event-arg placeholder from `parse_args_spec`. _IN_SCOPE_MARKERS = ( @@ -268,7 +285,10 @@ def create( ) raise ValueError(msg) if default is NoValue: - default_var = Var(_js_expr="") + # Explicit `undefined` rather than an empty expression: the name is + # passed as a second argument, so an empty first argument would emit + # `useClientState(, "name")` -- a syntax error. + default_var = Var(_js_expr="undefined") elif not isinstance(default, Var): default_var = LiteralVar.create(default) else: @@ -474,6 +494,14 @@ def push(self, value: Any) -> EventSpec: if not self._global_ref: msg = "ClientStateVar must be global to push the value." raise ValueError(msg) + if isinstance(value, Var): + # A Var is a client-side expression, which cannot survive the JSON + # event payload -- it would arrive as its own source text. Evaluate + # it on the client instead, reaching the store through `refs` (the + # only binding in scope where run_script's code is evaluated). + return run_script( + f"{_client_state_store_ref!s}.set({LiteralVar.create(self._state_name)!s}, {value!s})" + ) return server_side( "_client_state_set", inspect.signature(_client_state_set), diff --git a/tests/units/reflex_base/test_client_state.py b/tests/units/reflex_base/test_client_state.py index 29e877b7258..7f4de4ab3b2 100644 --- a/tests/units/reflex_base/test_client_state.py +++ b/tests/units/reflex_base/test_client_state.py @@ -55,6 +55,23 @@ def test_single_hook_no_useState() -> None: assert "refs[" not in hook +@pytest.mark.parametrize("global_ref", [True, False]) +def test_omitted_default_emits_valid_javascript(global_ref: bool) -> None: + """No default must still emit a syntactically valid hook call. + + Regression: an empty default expression rendered as + ``useClientState(, "name")`` once the store name became a second argument, + which is a syntax error that breaks the whole page build. + """ + cs = client_state("counter", global_ref=global_ref) + hook = _hook(cs) + assert "(," not in hook + expected = 'undefined, "counter"' if global_ref else "undefined" + assert ( + hook == f"const [counterRxClientState, setCounter] = useClientState({expected})" + ) + + def test_local_var_omits_store_name() -> None: """A ``global_ref=False`` var gets no name, so its slot stays private.""" cs = client_state("copied", default=False, global_ref=False) @@ -397,9 +414,10 @@ def debounce_controlled_input( assert len(declarations) == 2, ( f"expected one hook per local var, got {declarations}" ) - # Distinct bindings, and neither is registered under a shared store name. + # Distinct bindings, and neither is registered under a shared store name + # (a named var would pass the name as a second, string, argument). assert len(set(declarations)) == 2 - assert all("useClientState()" in line for line in declarations) + assert all("useClientState(undefined)" in line for line in declarations) assert 'from "$/utils/client_state"' in code @@ -458,3 +476,29 @@ def got(self, value: str): assert args["var_name"] == '"counter"' assert "queueEvents" in args["callback"] assert "got" in args["callback"] + + +def test_push_plain_value_uses_json_payload() -> None: + """A concrete value crosses the wire as JSON, not as JS source.""" + from reflex_base.event import fix_events + + cs = client_state("counter", default=0) + event = fix_events([cs.push({"a": 1})], token="tok")[0] + assert event.name.endswith("_client_state_set") + assert event.payload == {"var_name": "counter", "value": {"a": 1}} + + +def test_push_var_is_evaluated_on_the_client() -> None: + """A Var is a client-side expression, so it must not be sent as its text. + + A JSON payload would deliver the literal source (``"Date.now()"``), so a Var + keeps the evaluated path, reaching the store through ``refs``. + """ + from reflex_base.event import fix_events + + cs = client_state("counter", default=0) + event = fix_events([cs.push(Var("Date.now()"))], token="tok")[0] + assert event.name.endswith("_call_function") + assert 'refs["__client_state"].set("counter", Date.now())' in str( + event.payload["function"] + ) From b745638a3d58cfb1529b8af66dc4522238054dce Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 06:59:59 +0000 Subject: [PATCH 03/15] fix(client_state): address review nits on retrieve, provider teardown, exports - `_client_state_get` returned early when no provider was mounted, leaving a handler awaiting `retrieve` blocked on a result that would never arrive. Call back with undefined instead: it may fail, but it fails visibly. - Several providers can share one store (an embedded app rendered alongside a main app), so the first to unmount deleted the `refs` entry out from under the others. Reference-count mounted providers and only drop it on the last. - Export `ClientStateSetter` so the type `.set` returns can be named in an annotation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- .../.templates/web/utils/client_state.js | 9 ++++++++- .../reflex_base/.templates/web/utils/state.js | 5 +++-- pyi_hashes.json | 2 +- reflex/__init__.py | 6 +++++- tests/units/experimental/test_client_state.py | 2 ++ tests/units/reflex_base/test_client_state.py | 20 +++++++++++++++++++ 6 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js index 97774354aaa..98ecc4e4060 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js @@ -103,6 +103,11 @@ export const createClientStateStore = () => { let _clientStore = null; +// How many providers are currently mounted. Several can share one store (an +// embedded app rendered alongside a main app), so the `refs` entry must survive +// until the last of them unmounts. +let _mountedProviders = 0; + /** * The client-side store singleton. * @@ -163,8 +168,10 @@ export function ClientStateProvider({ children }) { useEffect(() => { // Client-only, so the server's module-scope `refs` is never written. refs[CLIENT_STATE_REF] = store; + _mountedProviders += 1; return () => { - if (refs[CLIENT_STATE_REF] === store) { + _mountedProviders -= 1; + if (_mountedProviders === 0 && refs[CLIENT_STATE_REF] === store) { delete refs[CLIENT_STATE_REF]; } }; diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index c11a8a58601..edd68efb261 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -395,15 +395,16 @@ export const applyEvent = async (event, socket, navigate, params) => { if (event.name == "_client_state_get") { const store = refs["__client_state"]; if (store === undefined) { + // Still call back, with undefined: the handler awaiting this result would + // otherwise wait for a value that is never coming. console.error( `Cannot read client state "${event.payload.var_name}": no ClientStateProvider is mounted.`, ); - return; } try { await applyResultCallback( event, - store.get(event.payload.var_name), + store?.get(event.payload.var_name), socket, navigate, params, diff --git a/pyi_hashes.json b/pyi_hashes.json index 6730004e3a3..e76695790cd 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -118,7 +118,7 @@ "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "99ebcfc07868061bdc3c2010d85a153f", "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "4f6c26f8c76543cc41e2b9dc400ece8a", "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f170ac685b6ba5892370166c80684db3", - "reflex/__init__.pyi": "630f98a9a6b1c357373ecb33f83194c1", + "reflex/__init__.pyi": "6a1a667017c016e586c3af7f8486f329", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", "reflex/experimental/memo.pyi": "bc8b48357bef580e70a5881b65d3d3f7" } diff --git a/reflex/__init__.py b/reflex/__init__.py index f96755bd1e8..2603a96d60f 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -161,7 +161,11 @@ ], "reflex_components_sonner.toast": ["toast"], "reflex_base.components.props": ["PropsBase"], - "reflex_base.client_state": ["ClientStateVar", "client_state"], + "reflex_base.client_state": [ + "ClientStateSetter", + "ClientStateVar", + "client_state", + ], "reflex_components_core.datadisplay.logo": ["logo"], "reflex_components_gridjs": ["data_table"], "reflex_components_moment": ["MomentDelta", "moment"], diff --git a/tests/units/experimental/test_client_state.py b/tests/units/experimental/test_client_state.py index 0665f10b59a..ddf05b8c6cc 100644 --- a/tests/units/experimental/test_client_state.py +++ b/tests/units/experimental/test_client_state.py @@ -19,3 +19,5 @@ def test_promoted_names_are_reachable_from_rx() -> None: """The lazy-loader wiring only fails at attribute access, so assert it.""" assert rx.client_state("promoted", default=0)._state_name == "promoted" assert isinstance(rx.client_state("typed", default=0), rx.ClientStateVar) + # Exported so `.set` can be named in a type annotation. + assert isinstance(rx.client_state("setter", default=0).set, rx.ClientStateSetter) diff --git a/tests/units/reflex_base/test_client_state.py b/tests/units/reflex_base/test_client_state.py index 7f4de4ab3b2..046a5d7b247 100644 --- a/tests/units/reflex_base/test_client_state.py +++ b/tests/units/reflex_base/test_client_state.py @@ -502,3 +502,23 @@ def test_push_var_is_evaluated_on_the_client() -> None: assert 'refs["__client_state"].set("counter", Date.now())' in str( event.payload["function"] ) + + +def test_retrieve_callback_runs_even_without_a_store() -> None: + """The runtime must call back with undefined rather than never resuming. + + Asserted against the shipped ``state.js`` because a handler awaiting + ``retrieve`` would otherwise hang forever when no provider is mounted. + """ + from pathlib import Path + + import reflex_base + + state_js = ( + Path(reflex_base.__file__).parent / ".templates" / "web" / "utils" / "state.js" + ).read_text() + branch = state_js.split('event.name == "_client_state_get"')[1].split("return;")[0] + assert "applyResultCallback" in branch + # Optional chaining rather than an early return, so a missing store still + # reaches the callback with undefined. + assert "store?.get(" in branch From 047710ef640c0309930ca7e801d316838c793d8e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 07:32:28 +0000 Subject: [PATCH 04/15] test(js): add vitest unit tests for the shipped frontend javascript The python suites can only see this code through compiled output, and an integration test cannot reach behavior that needs no running app -- provider teardown, the SSR branch, subscription bookkeeping. Two fixes in this branch landed untested for exactly that reason. Adds `tests/js/`, deliberately outside `.templates/web` since everything in there is copied verbatim into generated apps. `$/...` specifiers resolve to the template tree via a vitest alias; `$/utils/state` is stubbed, because the real module pulls in socket.io, react-router and the per-app generated `context.js`. Scoped to `client_state.js` for now -- `state.js` needs those stubs before it is unit-testable, and the integration tests already cover its interaction end to end. Nineteen tests covering slot semantics (per-var listener isolation, updaters, equal-value bail, create-on-write, unsubscribe), `getClientStore` client singleton vs. per-call on the server, provider refcounting across several mounted providers and StrictMode's double mount, `useClientState` sharing and isolation, and the non-React escape hatch. Each of the three behaviors these were written for was confirmed to fail the intended test when the fix is reverted. Runs as a `js-unit-tests` job in the existing unit-tests workflow. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- .github/workflows/unit_tests.yml | 20 + .gitignore | 3 + AGENTS.md | 20 + tests/js/client_state.test.js | 323 +++++ tests/js/package-lock.json | 2139 ++++++++++++++++++++++++++++++ tests/js/package.json | 15 + tests/js/stubs/state.js | 8 + tests/js/vitest.config.js | 44 + 8 files changed, 2572 insertions(+) create mode 100644 tests/js/client_state.test.js create mode 100644 tests/js/package-lock.json create mode 100644 tests/js/package.json create mode 100644 tests/js/stubs/state.js create mode 100644 tests/js/vitest.config.js diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index d24923c456c..f2432925f0c 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -82,6 +82,26 @@ jobs: - name: Generate coverage report run: uv run coverage html + js-unit-tests: + # Unit tests for the javascript Reflex ships in + # `reflex-base/.templates/web`, which the python suites can only reach + # through compiled output. + timeout-minutes: 10 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: tests/js/package-lock.json + - run: npm ci + working-directory: tests/js + - run: npm test + working-directory: tests/js + unit-tests-macos: timeout-minutes: 30 if: github.event_name == 'push' && github.ref == 'refs/heads/main' diff --git a/.gitignore b/.gitignore index 533bcfcec8e..17d1903ac6d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ reflex.db .env.* node_modules package-lock.json +# ...except the javascript test harness, which CI installs with `npm ci`. +!tests/js/package-lock.json *.pyi .pre-commit-config.yaml .claude/.worktrees @@ -33,3 +35,4 @@ CLAUDE.local.md # Backups written by scripts/delete_automated_releases.sh automated-releases-backup-*.json +tests/js/node_modules diff --git a/AGENTS.md b/AGENTS.md index eae113a8ffa..bfbe255d4d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ uv run python scripts/check_min_deps.py # validate each uv run python scripts/check_min_deps.py --check-dev-pins [pkg] # publish gate: fail if pkg (default: all) declares an unpublishable *.dev dependency pin uv run python scripts/make_pyi.py # regenerate .pyi stubs uv run pre-commit run --all-files # all pre-commit hooks +npm --prefix tests/js ci && npm --prefix tests/js test # javascript unit tests (frontend templates) ``` ## Layout @@ -31,6 +32,7 @@ uv run pre-commit run --all-files # all pre-commi reflex/ # main framework package (app, state, compiler, components, utils, istate) packages/ # workspace sub-packages (reflex-base, reflex-components-*, reflex-docgen, reflex-components-internal) tests/units/ # unit tests, mirrors source tree +tests/js/ # vitest unit tests for the shipped frontend javascript tests/integration/ # Selenium integration tests (run in dev+prod modes) tests_playwright/ # Playwright integration tests (preferred for new tests) tests/benchmarks/ # performance benchmarks @@ -58,6 +60,24 @@ docs/ # documentation site (separate workspace member) - unit tests should primarily cover a single module, and should be named accordingly, including subdirectories (e.g. `tests/units/istate/test_manager.py` for `reflex/istate/manager.py`). For subpackages, also include the corresponding path below `src/` (e.g. `tests/units/reflex_base/event/test_context.py` for `packages/reflex-base/src/reflex_base/event/context.py`). - **Integration tests:** prefer Playwright (`tests/integration/tests_playwright/`). Integration tests are slow — extend existing test apps rather than creating new ones for trivial functionality. Multiple test cases sharing one app is fine. +### Frontend javascript tests + +The javascript Reflex ships lives in +`packages/reflex-base/src/reflex_base/.templates/web/` and is copied verbatim into a +user's `.web` directory, so tests must **not** live inside that tree. They go in +`tests/js/`, which has its own `package.json` and runs under vitest + jsdom: +`npm --prefix tests/js test`. + +Reach for these when behavior can only be observed at runtime and an integration +test would be indirect or impossible to set up — provider teardown, SSR vs. client +branches, subscription bookkeeping. Prefer a Playwright test when the thing you +want to assert is visible in a real app. + +`$/...` specifiers resolve to the template tree via a vitest alias. `$/utils/state` +is stubbed (`tests/js/stubs/state.js`) because the real module pulls in socket.io, +react-router and the per-app *generated* `utils/context.js`; a module needing those +is not currently unit-testable. + ### Integration test patterns Apps as factory functions, run via `AppHarness`: diff --git a/tests/js/client_state.test.js b/tests/js/client_state.test.js new file mode 100644 index 00000000000..98b0ef3f5ed --- /dev/null +++ b/tests/js/client_state.test.js @@ -0,0 +1,323 @@ +/** + * Unit tests for `utils/client_state.js`. + * + * These cover the parts the Python and Playwright suites structurally cannot: + * teardown when several providers share one store, SSR store isolation, and the + * per-slot subscription behavior the design rests on. + */ +import { readFileSync } from "node:fs"; + +import { act } from "react"; +import { createElement, StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { + CLIENT_STATE_REF, + ClientStateProvider, + createClientStateStore, + getClientState, + getClientStore, + setClientState, + useClientState, +} from "$/utils/client_state"; +import { refs } from "$/utils/state"; + +// React 19 wants this set when driving roots manually. +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +/** Mount a tree into a detached root, returning it and its container. */ +const mount = (element, { strict = false } = {}) => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(strict ? createElement(StrictMode, null, element) : element); + }); + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +}; + +afterEach(() => { + delete refs[CLIENT_STATE_REF]; +}); + +describe("store slots", () => { + test("a named slot is shared and seeded by the first default", () => { + const store = createClientStateStore(); + const first = store.slot("shared", "initial"); + const second = store.slot("shared", "ignored"); + + expect(second).toBe(first); + expect(store.get("shared")).toBe("initial"); + }); + + test("an unnamed slot is private and unaddressable by name", () => { + const store = createClientStateStore(); + const a = store.slot(undefined, "a"); + const b = store.slot(undefined, "b"); + + expect(a).not.toBe(b); + a.set("changed"); + expect(b.getSnapshot()).toBe("b"); + // Anonymous slots are never registered, so nothing can reach them by name. + expect(store.get(undefined)).toBeUndefined(); + }); + + test("writing one var does not notify another var's subscribers", () => { + const store = createClientStateStore(); + const watched = vi.fn(); + const unrelated = vi.fn(); + store.slot("a", 0).subscribe(watched); + store.slot("b", 0).subscribe(unrelated); + + store.set("a", 1); + + expect(watched).toHaveBeenCalledTimes(1); + expect(unrelated).not.toHaveBeenCalled(); + }); + + test("a function value is applied as an updater", () => { + const store = createClientStateStore(); + store.slot("n", 1); + + store.set("n", (previous) => previous + 41); + + expect(store.get("n")).toBe(42); + }); + + test("setting an equal value notifies nobody", () => { + const store = createClientStateStore(); + const listener = vi.fn(); + store.slot("n", 7).subscribe(listener); + + store.set("n", 7); + + expect(listener).not.toHaveBeenCalled(); + expect(store.get("n")).toBe(7); + }); + + test("writing an unknown name creates the slot", () => { + // A value pushed from the backend before any component mounts has to be + // retained, so the component picks it up when it does mount. + const store = createClientStateStore(); + + store.set("later", "pushed early"); + + expect(store.get("later")).toBe("pushed early"); + expect(store.slot("later", "default ignored").getSnapshot()).toBe( + "pushed early", + ); + }); + + test("unsubscribing detaches the listener", () => { + const store = createClientStateStore(); + const listener = vi.fn(); + const unsubscribe = store.slot("n", 0).subscribe(listener); + + unsubscribe(); + store.set("n", 1); + + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe("getClientStore", () => { + test("is a singleton on the client", () => { + expect(getClientStore()).toBe(getClientStore()); + }); + + test("is per-call on the server, so nothing leaks between requests", () => { + const realDocument = globalThis.document; + // The module keys off `typeof document`, which is how it tells SSR apart. + // @ts-expect-error - deleting a global for the duration of the test. + delete globalThis.document; + try { + const first = getClientStore(); + first.set("leaky", "request one"); + + const second = getClientStore(); + + expect(second).not.toBe(first); + expect(second.get("leaky")).toBeUndefined(); + } finally { + globalThis.document = realDocument; + } + }); +}); + +describe("ClientStateProvider", () => { + test("publishes the store on refs while mounted", () => { + expect(refs[CLIENT_STATE_REF]).toBeUndefined(); + + const { unmount } = mount(createElement(ClientStateProvider, null, null)); + + expect(refs[CLIENT_STATE_REF]).toBe(getClientStore()); + + unmount(); + + expect(refs[CLIENT_STATE_REF]).toBeUndefined(); + }); + + test("keeps the refs entry until the last provider unmounts", () => { + // Two app roots on one page (an embedded app beside a main app) share the + // client singleton, so the first teardown must not strand the other. + const first = mount(createElement(ClientStateProvider, null, null)); + const second = mount(createElement(ClientStateProvider, null, null)); + + first.unmount(); + + expect(refs[CLIENT_STATE_REF]).toBe(getClientStore()); + + second.unmount(); + + expect(refs[CLIENT_STATE_REF]).toBeUndefined(); + }); + + test("survives StrictMode's double mount", () => { + const { unmount } = mount(createElement(ClientStateProvider, null, null), { + strict: true, + }); + + expect(refs[CLIENT_STATE_REF]).toBe(getClientStore()); + + unmount(); + + expect(refs[CLIENT_STATE_REF]).toBeUndefined(); + }); +}); + +describe("useClientState", () => { + /** Render `useClientState(default, name)` and report renders and value. */ + const probe = (defaultValue, name, id) => { + const renders = { count: 0, value: undefined, set: undefined }; + const Probe = () => { + const [value, set] = useClientState(defaultValue, name); + renders.count += 1; + renders.value = value; + renders.set = set; + return createElement("span", { id }, String(value)); + }; + return { renders, element: createElement(Probe) }; + }; + + test("shares a named var across components", () => { + const a = probe("initial", "shared", "a"); + const b = probe("initial", "shared", "b"); + const { unmount } = mount( + createElement(ClientStateProvider, null, a.element, b.element), + ); + + act(() => a.renders.set("typed")); + + expect(a.renders.value).toBe("typed"); + expect(b.renders.value).toBe("typed"); + + unmount(); + }); + + test("keeps unnamed vars private to each component", () => { + const a = probe("", undefined, "a"); + const b = probe("", undefined, "b"); + const { unmount } = mount( + createElement(ClientStateProvider, null, a.element, b.element), + ); + + act(() => a.renders.set("mine")); + + expect(a.renders.value).toBe("mine"); + expect(b.renders.value).toBe(""); + + unmount(); + }); + + test("does not re-render a component reading an unrelated var", () => { + // The property the whole store design exists for. + const watched = probe(0, "watched", "watched"); + const unrelated = probe(0, "unrelated", "unrelated"); + const { unmount } = mount( + createElement( + ClientStateProvider, + null, + watched.element, + unrelated.element, + ), + ); + const before = unrelated.renders.count; + + act(() => watched.renders.set(1)); + + expect(watched.renders.value).toBe(1); + expect(unrelated.renders.count).toBe(before); + + unmount(); + }); + + test("a late mount reads the current value, not the default", () => { + const early = probe("default", "late", "early"); + const first = mount( + createElement(ClientStateProvider, null, early.element), + ); + act(() => early.renders.set("current")); + + const late = probe("default", "late", "late"); + const second = mount( + createElement(ClientStateProvider, null, late.element), + ); + + expect(late.renders.value).toBe("current"); + + second.unmount(); + first.unmount(); + }); + + test("accepts a functional updater", () => { + const counter = probe(1, "counter", "counter"); + const { unmount } = mount( + createElement(ClientStateProvider, null, counter.element), + ); + + act(() => counter.renders.set((previous) => previous + 41)); + + expect(counter.renders.value).toBe(42); + + unmount(); + }); +}); + +describe("non-React escape hatch", () => { + test("reads and writes the store the hooks are bound to", () => { + const renders = { count: 0, value: undefined }; + const Probe = () => { + const [value] = useClientState("initial", "escaped"); + renders.count += 1; + renders.value = value; + return null; + }; + const { unmount } = mount( + createElement(ClientStateProvider, null, createElement(Probe)), + ); + + expect(getClientState("escaped")).toBe("initial"); + + act(() => setClientState("escaped", "from plain js")); + + expect(renders.value).toBe("from plain js"); + expect(getClientState("escaped")).toBe("from plain js"); + + unmount(); + }); +}); + +test("CLIENT_STATE_REF matches the key state.js reads", () => { + // The runtime reaches the store through `refs` rather than an import, to + // avoid a cycle, so the key is duplicated and has to stay in sync. + const stateJs = readFileSync(`${__WEB_ROOT__}utils/state.js`, "utf8"); + + expect(stateJs).toContain(`refs["${CLIENT_STATE_REF}"]`); +}); diff --git a/tests/js/package-lock.json b/tests/js/package-lock.json new file mode 100644 index 00000000000..1554d47d4a9 --- /dev/null +++ b/tests/js/package-lock.json @@ -0,0 +1,2139 @@ +{ + "name": "reflex-frontend-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "reflex-frontend-tests", + "devDependencies": { + "jsdom": "^26.1.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "vitest": "^3.2.4" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/tests/js/package.json b/tests/js/package.json new file mode 100644 index 00000000000..fb813dc3294 --- /dev/null +++ b/tests/js/package.json @@ -0,0 +1,15 @@ +{ + "name": "reflex-frontend-tests", + "private": true, + "type": "module", + "description": "Unit tests for the javascript Reflex ships in reflex-base/.templates/web.", + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "jsdom": "^26.1.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "vitest": "^3.2.4" + } +} diff --git a/tests/js/stubs/state.js b/tests/js/stubs/state.js new file mode 100644 index 00000000000..8a8e3e11255 --- /dev/null +++ b/tests/js/stubs/state.js @@ -0,0 +1,8 @@ +/** + * Stand-in for `$/utils/state`, exposing only what the units under test import. + * + * The real module reaches for socket.io, react-router, `$/env.json` and the + * per-app generated `context.js`. `refs` itself is just a bare object there, so + * a stub is faithful as well as convenient. + */ +export const refs = {}; diff --git a/tests/js/vitest.config.js b/tests/js/vitest.config.js new file mode 100644 index 00000000000..c3c789aba81 --- /dev/null +++ b/tests/js/vitest.config.js @@ -0,0 +1,44 @@ +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +import { defineConfig } from "vitest/config"; + +// The units under test live outside this directory, so node resolution from +// them never reaches these node_modules. Point `react` at the copy installed +// here, which also guarantees one React instance across test and subject. +const require = createRequire(import.meta.url); + +// The files under test live in the template tree that `reflex init` copies into +// a user's `.web`. Tests deliberately sit outside it, since everything in there +// is copied verbatim into generated apps. +const webRoot = fileURLToPath( + new URL( + "../../packages/reflex-base/src/reflex_base/.templates/web/", + import.meta.url, + ), +); + +export default defineConfig({ + test: { + environment: "jsdom", + include: ["**/*.test.js"], + }, + // Under jsdom `import.meta.url` is an http:// URL, so tests that need to read + // a source file get the location from here instead. + define: { __WEB_ROOT__: JSON.stringify(webRoot) }, + resolve: { + alias: [ + // `$/utils/state` pulls in socket.io, react-router and the per-app + // generated `context.js`, none of which these units need. Stub the one + // binding they import from it. + { + find: "$/utils/state", + replacement: fileURLToPath( + new URL("./stubs/state.js", import.meta.url), + ), + }, + { find: /^\$\//, replacement: webRoot }, + { find: /^react$/, replacement: require.resolve("react") }, + ], + }, +}); From b7dc16da1c1fbcee5066d09f10ab4257de9b0ff2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 08:03:26 +0000 Subject: [PATCH 05/15] refactor(client_state): pass the registry in, and fix hash and docstring - `client_state.js` no longer imports `refs` from `$/utils/state`. The provider takes the object to publish its store on as a `registry` prop, which the python side supplies as the `refs` Var carrying its own import. The module is now independent of where that lives, so the python side can move it without touching this javascript. Its unit tests pass their own object, so the `$/utils/state` stub is gone too. - `__hash__` now includes `_state_name` and `_global_ref`. Two vars differing only in those compared equal, despite carrying materially different VarData. - The `create` docstring described scoping incorrectly. A named var is readable and writable from any component and from the backend; an anonymous one is private to the component its hook is emitted in. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- .../.templates/web/utils/client_state.js | 28 ++++++--- .../src/reflex_base/client_state.py | 11 ++-- .../components/client_state_context.py | 23 +++++++- tests/js/client_state.test.js | 59 ++++++++++--------- tests/js/stubs/state.js | 8 --- tests/js/vitest.config.js | 9 --- 6 files changed, 80 insertions(+), 58 deletions(-) delete mode 100644 tests/js/stubs/state.js diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js index 98ecc4e4060..4ece38868c1 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js @@ -19,9 +19,10 @@ import { useSyncExternalStore, } from "react"; -import { refs } from "$/utils/state"; - -/** The single `refs` key holding the live store, for devtools introspection. */ +/** + * Key under which the provider publishes its store on the `registry` object it + * is handed, for backend-evaluated code and devtools introspection. + */ export const CLIENT_STATE_REF = "__client_state"; /** @@ -154,9 +155,13 @@ export const setClientState = (name, value) => { * Provide the client state store to the tree. * @param props The component props. * @param props.children The children to render. + * @param props.registry Optional object to publish the store on, under + * `CLIENT_STATE_REF`, so code running outside the React tree can reach it. + * Passed in by the caller rather than imported, so this module stays + * independent of where that lives. * @returns The provider element. */ -export function ClientStateProvider({ children }) { +export function ClientStateProvider({ children, registry }) { const storeRef = useRef(null); if (storeRef.current === null) { // On the client this is the shared singleton, so `setClientState` and the @@ -166,16 +171,21 @@ export function ClientStateProvider({ children }) { const store = storeRef.current; useEffect(() => { - // Client-only, so the server's module-scope `refs` is never written. - refs[CLIENT_STATE_REF] = store; + if (registry === undefined) { + return undefined; + } + // In an effect, so the store is never published during an SSR render. + registry[CLIENT_STATE_REF] = store; _mountedProviders += 1; return () => { _mountedProviders -= 1; - if (_mountedProviders === 0 && refs[CLIENT_STATE_REF] === store) { - delete refs[CLIENT_STATE_REF]; + // Several providers can share one store, so only the last one out clears + // the entry. + if (_mountedProviders === 0 && registry[CLIENT_STATE_REF] === store) { + delete registry[CLIENT_STATE_REF]; } }; - }, [store]); + }, [store, registry]); return createElement(ClientStateContext.Provider, { value: store }, children); } diff --git a/packages/reflex-base/src/reflex_base/client_state.py b/packages/reflex-base/src/reflex_base/client_state.py index 90fe1a933c1..a5c1d104534 100644 --- a/packages/reflex-base/src/reflex_base/client_state.py +++ b/packages/reflex-base/src/reflex_base/client_state.py @@ -223,6 +223,8 @@ def __hash__(self) -> int: str(self._var_type), self._getter_name, self._setter_name, + self._state_name, + self._global_ref, )) @classmethod @@ -234,10 +236,11 @@ def create( ) -> ClientStateVar: """Create a local_state Var that can be accessed and updated on the client. - The `ClientStateVar` should be included in the highest parent component - that contains the components which will access and manipulate the client - state. It has no visual rendering, including it ensures that the - `useClientState` hook is called in the correct scope. + With ``global_ref`` set (the default) the state is keyed by name in a + store shared across the app, so it can be read and written from any + component and from the backend. Without it the state is anonymous: it is + private to the component the hook is emitted in, and `push`, `retrieve`, + `global_value` and `global_set` cannot address it. To render the var in a component, use the `value` property. diff --git a/packages/reflex-base/src/reflex_base/components/client_state_context.py b/packages/reflex-base/src/reflex_base/components/client_state_context.py index 9e765dbe3d5..6a95a82a708 100644 --- a/packages/reflex-base/src/reflex_base/components/client_state_context.py +++ b/packages/reflex-base/src/reflex_base/components/client_state_context.py @@ -9,14 +9,27 @@ from __future__ import annotations +from typing import Any + from reflex_base.components.component import Component from reflex_base.constants import Dirs +from reflex_base.utils.imports import ImportVar +from reflex_base.vars.base import Var, VarData # Inside ErrorBoundary (55) so a client-state error is caught, outside the # theme/toaster/overlay wraps. It depends on neither StateProvider nor # EventLoopProvider. CLIENT_STATE_APP_WRAP_PRIORITY = 50 +# The global object backend-evaluated code reaches the store through. Passed to +# the provider as a prop rather than imported by ``client_state.js``, so this +# side owns where the store is published and the javascript stays independent +# of it. +refs_var = Var( + _js_expr="refs", + _var_data=VarData(imports={f"$/{Dirs.STATE_PATH}": [ImportVar(tag="refs")]}), +) + class ClientStateContextProvider(Component): """App wrap that mounts the React client-state provider around children.""" @@ -24,6 +37,9 @@ class ClientStateContextProvider(Component): library = f"$/{Dirs.CLIENT_STATE_PATH}" tag = "ClientStateProvider" + # Object the provider publishes its store on, keyed by CLIENT_STATE_REF. + registry: Var[dict[str, Any]] + def get_client_state_app_wraps() -> tuple[tuple[int, Component], ...]: """Build the app-wrap entry advertising the client-state provider. @@ -36,4 +52,9 @@ def get_client_state_app_wraps() -> tuple[tuple[int, Component], ...]: Returns: A single ``(priority, provider)`` entry. """ - return ((CLIENT_STATE_APP_WRAP_PRIORITY, ClientStateContextProvider.create()),) + return ( + ( + CLIENT_STATE_APP_WRAP_PRIORITY, + ClientStateContextProvider.create(registry=refs_var), + ), + ) diff --git a/tests/js/client_state.test.js b/tests/js/client_state.test.js index 98b0ef3f5ed..a8c50fbf657 100644 --- a/tests/js/client_state.test.js +++ b/tests/js/client_state.test.js @@ -21,7 +21,6 @@ import { setClientState, useClientState, } from "$/utils/client_state"; -import { refs } from "$/utils/state"; // React 19 wants this set when driving roots manually. globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -43,8 +42,11 @@ const mount = (element, { strict = false } = {}) => { }; }; -afterEach(() => { - delete refs[CLIENT_STATE_REF]; +/** A stand-in for the global object the app publishes the store on. */ +let registry; + +beforeEach(() => { + registry = {}; }); describe("store slots", () => { @@ -152,43 +154,46 @@ describe("getClientStore", () => { }); describe("ClientStateProvider", () => { - test("publishes the store on refs while mounted", () => { - expect(refs[CLIENT_STATE_REF]).toBeUndefined(); + test("publishes the store on the registry it is given", () => { + expect(registry[CLIENT_STATE_REF]).toBeUndefined(); - const { unmount } = mount(createElement(ClientStateProvider, null, null)); + const { unmount } = mount(createElement(ClientStateProvider, { registry })); - expect(refs[CLIENT_STATE_REF]).toBe(getClientStore()); + expect(registry[CLIENT_STATE_REF]).toBe(getClientStore()); unmount(); - expect(refs[CLIENT_STATE_REF]).toBeUndefined(); + expect(registry[CLIENT_STATE_REF]).toBeUndefined(); }); - test("keeps the refs entry until the last provider unmounts", () => { + test("keeps the entry until the last provider unmounts", () => { // Two app roots on one page (an embedded app beside a main app) share the // client singleton, so the first teardown must not strand the other. - const first = mount(createElement(ClientStateProvider, null, null)); - const second = mount(createElement(ClientStateProvider, null, null)); + const first = mount(createElement(ClientStateProvider, { registry })); + const second = mount(createElement(ClientStateProvider, { registry })); first.unmount(); - expect(refs[CLIENT_STATE_REF]).toBe(getClientStore()); + expect(registry[CLIENT_STATE_REF]).toBe(getClientStore()); second.unmount(); - expect(refs[CLIENT_STATE_REF]).toBeUndefined(); + expect(registry[CLIENT_STATE_REF]).toBeUndefined(); }); test("survives StrictMode's double mount", () => { - const { unmount } = mount(createElement(ClientStateProvider, null, null), { - strict: true, - }); + const { unmount } = mount( + createElement(ClientStateProvider, { registry }), + { + strict: true, + }, + ); - expect(refs[CLIENT_STATE_REF]).toBe(getClientStore()); + expect(registry[CLIENT_STATE_REF]).toBe(getClientStore()); unmount(); - expect(refs[CLIENT_STATE_REF]).toBeUndefined(); + expect(registry[CLIENT_STATE_REF]).toBeUndefined(); }); }); @@ -210,7 +215,7 @@ describe("useClientState", () => { const a = probe("initial", "shared", "a"); const b = probe("initial", "shared", "b"); const { unmount } = mount( - createElement(ClientStateProvider, null, a.element, b.element), + createElement(ClientStateProvider, { registry }, a.element, b.element), ); act(() => a.renders.set("typed")); @@ -225,7 +230,7 @@ describe("useClientState", () => { const a = probe("", undefined, "a"); const b = probe("", undefined, "b"); const { unmount } = mount( - createElement(ClientStateProvider, null, a.element, b.element), + createElement(ClientStateProvider, { registry }, a.element, b.element), ); act(() => a.renders.set("mine")); @@ -243,7 +248,7 @@ describe("useClientState", () => { const { unmount } = mount( createElement( ClientStateProvider, - null, + { registry }, watched.element, unrelated.element, ), @@ -261,13 +266,13 @@ describe("useClientState", () => { test("a late mount reads the current value, not the default", () => { const early = probe("default", "late", "early"); const first = mount( - createElement(ClientStateProvider, null, early.element), + createElement(ClientStateProvider, { registry }, early.element), ); act(() => early.renders.set("current")); const late = probe("default", "late", "late"); const second = mount( - createElement(ClientStateProvider, null, late.element), + createElement(ClientStateProvider, { registry }, late.element), ); expect(late.renders.value).toBe("current"); @@ -279,7 +284,7 @@ describe("useClientState", () => { test("accepts a functional updater", () => { const counter = probe(1, "counter", "counter"); const { unmount } = mount( - createElement(ClientStateProvider, null, counter.element), + createElement(ClientStateProvider, { registry }, counter.element), ); act(() => counter.renders.set((previous) => previous + 41)); @@ -300,7 +305,7 @@ describe("non-React escape hatch", () => { return null; }; const { unmount } = mount( - createElement(ClientStateProvider, null, createElement(Probe)), + createElement(ClientStateProvider, { registry }, createElement(Probe)), ); expect(getClientState("escaped")).toBe("initial"); @@ -315,8 +320,8 @@ describe("non-React escape hatch", () => { }); test("CLIENT_STATE_REF matches the key state.js reads", () => { - // The runtime reaches the store through `refs` rather than an import, to - // avoid a cycle, so the key is duplicated and has to stay in sync. + // The runtime reaches the store through the object it is handed, so the key + // is duplicated on the reading side and has to stay in sync. const stateJs = readFileSync(`${__WEB_ROOT__}utils/state.js`, "utf8"); expect(stateJs).toContain(`refs["${CLIENT_STATE_REF}"]`); diff --git a/tests/js/stubs/state.js b/tests/js/stubs/state.js deleted file mode 100644 index 8a8e3e11255..00000000000 --- a/tests/js/stubs/state.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Stand-in for `$/utils/state`, exposing only what the units under test import. - * - * The real module reaches for socket.io, react-router, `$/env.json` and the - * per-app generated `context.js`. `refs` itself is just a bare object there, so - * a stub is faithful as well as convenient. - */ -export const refs = {}; diff --git a/tests/js/vitest.config.js b/tests/js/vitest.config.js index c3c789aba81..0c352b92848 100644 --- a/tests/js/vitest.config.js +++ b/tests/js/vitest.config.js @@ -28,15 +28,6 @@ export default defineConfig({ define: { __WEB_ROOT__: JSON.stringify(webRoot) }, resolve: { alias: [ - // `$/utils/state` pulls in socket.io, react-router and the per-app - // generated `context.js`, none of which these units need. Stub the one - // binding they import from it. - { - find: "$/utils/state", - replacement: fileURLToPath( - new URL("./stubs/state.js", import.meta.url), - ), - }, { find: /^\$\//, replacement: webRoot }, { find: /^react$/, replacement: require.resolve("react") }, ], From 7d80c8faf384723daaa7e59fd13d920227082d0e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 12:19:36 +0000 Subject: [PATCH 06/15] feat(client_state): scope client state by name down the component tree Client state was two tiers selected by `global_ref`, and the anonymous tier did not survive Reflex's own compiler: because touching a client state var is itself a memoization trigger, every consumer compiles to its own React component, so an anonymous var read in one place and written in another became two disconnected slots. An ordinary stateful sibling was enough to trigger it. The page compiled and simply did not work. Names now resolve down a scope chain. A scope owns some names and delegates the rest to its parent; the first component in a tree to use a name claims it for its descendants. Separate instances of a boundary get separate state, everything under one boundary shares, and optimizer-generated boundaries stay invisible -- so a subtree split across memo modules keeps resolving the same slot and no memo code has to be refactored. Which tier you get follows from whether you name the var, so `global_ref` is gone: a named var resolves at the root scope and stays reachable from the backend via `push` / `retrieve` / `global_value` / `global_set`; an unnamed one is owned by the tree that first uses it. Where you *construct* the var decides who shares it, mirroring React's lifted state -- and because construction happens once per call at compile time, a plain helper function called N times yields N independent states with no memo, no keys and no configuration. The boundary is emitted as an HOC on the memo definition's existing `wrapper` extension point, not as a provider inside its returned JSX: a component's hooks run before its own output mounts, so an inner provider would leave the memo's own `useClientState` resolving against the enclosing scope and sharing across instances. A new `is_instance_boundary` flag on `MemoComponentDefinition`, set only by `@rx.memo`, keeps auto-memo wrappers transparent, and the wrap is gated on the subtree actually using client state so pages don't pay per memo. Also: - The new API is `rx.client_state(default, *, name=None, prefix="cs")` -- a single positional default, reading like `useState`. Putting `default` first matters now that the first argument decides global vs scoped: `rx.client_state("default")` used to look like a value while naming the var. `prefix` customizes generated names to keep compiled output readable. - `rx._x.client_state` keeps the original signature and carries every deprecation notice, so the new API has none. Its `global_ref=False` drops the name, which reproduces the old anonymous behavior exactly under the new rules. - 27 vitest tests for the scope chain and 3 compiler tests for the emission gating; each behavior was confirmed to fail its intended test when the corresponding piece is reverted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- .../pages/integrations/integration_gallery.py | 2 +- .../reflex_docs/templates/docpage/docpage.py | 2 +- docs/library/data-display/icon.md | 2 +- docs/wrapping-react/overview.md | 4 +- .../.templates/web/utils/client_state.js | 224 ++++++++++----- .../src/reflex_base/client_state.py | 94 +++++-- .../components/client_state_context.py | 28 ++ .../src/reflex_base/components/memo.py | 6 + .../blocks/demo_form.py | 4 +- .../blocks/intro_form.py | 6 +- reflex/compiler/utils.py | 10 + reflex/experimental/__init__.py | 3 +- reflex/experimental/client_state.py | 47 +++- .../tests_playwright/test_client_state.py | 11 +- tests/js/client_state.test.js | 261 ++++++++++++++++-- tests/units/compiler/test_memoize_plugin.py | 112 +++++++- tests/units/experimental/test_client_state.py | 38 ++- tests/units/reflex_base/test_client_state.py | 189 +++++++++---- 18 files changed, 832 insertions(+), 211 deletions(-) diff --git a/docs/app/reflex_docs/pages/integrations/integration_gallery.py b/docs/app/reflex_docs/pages/integrations/integration_gallery.py index 2fdaab3eaf3..17c9ccd365e 100644 --- a/docs/app/reflex_docs/pages/integrations/integration_gallery.py +++ b/docs/app/reflex_docs/pages/integrations/integration_gallery.py @@ -5,7 +5,7 @@ from .integration_list import get_integration_path from .integration_request import request_integration_dialog -selected_filter = rx.client_state("selected_filter", "All") +selected_filter = rx.client_state("All", name="selected_filter") FilterOptions = [ {"name": "AI", "icon": "BotIcon"}, diff --git a/docs/app/reflex_docs/templates/docpage/docpage.py b/docs/app/reflex_docs/templates/docpage/docpage.py index de0398a5f7d..02b6b6719a3 100644 --- a/docs/app/reflex_docs/templates/docpage/docpage.py +++ b/docs/app/reflex_docs/templates/docpage/docpage.py @@ -85,7 +85,7 @@ def feedback_button_toc() -> rx.Component: @rx.memo def copy_to_markdown(text: rx.Var[str]) -> rx.Component: - copied = rx.client_state("is_copied", default=False, global_ref=False) + copied = rx.client_state(False) return marketing_button( rx.cond( copied.value, diff --git a/docs/library/data-display/icon.md b/docs/library/data-display/icon.md index cd4efa6e678..7b6aedad345 100644 --- a/docs/library/data-display/icon.md +++ b/docs/library/data-display/icon.md @@ -8,7 +8,7 @@ import reflex as rx from reflex_components_lucide.icon import LUCIDE_ICON_LIST -icon_search_cs = rx.client_state("icon_search", default="") +icon_search_cs = rx.client_state("", name="icon_search") @rx.memo diff --git a/docs/wrapping-react/overview.md b/docs/wrapping-react/overview.md index c23f7b99b44..6bbcad55118 100644 --- a/docs/wrapping-react/overview.md +++ b/docs/wrapping-react/overview.md @@ -70,7 +70,7 @@ class ColorPicker(NoSSRComponent): color_picker = ColorPicker.create -ColorPickerState = rx.client_state(default="#db114b", var_name="color") +ColorPickerState = rx.client_state("#db114b", name="color") ``` ```python eval @@ -130,7 +130,7 @@ library that hands you a plain JavaScript callback -- or you are writing your ow they work anywhere in your compiled page: ```python -picker_color = rx.client_state("picker_color", default="#db114b") +picker_color = rx.client_state("#db114b", name="picker_color") class MyPicker(rx.Component): diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js index 4ece38868c1..552e8b23278 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js @@ -1,14 +1,20 @@ /** - * Client-only state, shared by name across components without a backend rx.State. + * Client-only state, scoped by name down the component tree. * * `useClientState` is the only thing compiled components call. Everything else - * here is the bookkeeping it needs: a store of independently-subscribable slots, - * the context that delivers it, and a module-level door for JS that runs outside - * the React tree (see `getClientState` / `setClientState`). + * here is the bookkeeping it needs: independently-subscribable slots, the scope + * chain that decides which slot a name resolves to, and a module-level door for + * JS that runs outside the React tree (`getClientState` / `setClientState`). + * + * Scoping: a scope owns some names and delegates the rest to its parent. The + * first component in a tree to use a name claims it for its descendants, so + * separate instances of a boundary get separate state while everything under one + * boundary shares. Compiler-inserted `ClientStateScope` elements create the + * boundaries; boundaries that only exist as a compiler optimization do not, so + * splitting a subtree across memo modules is semantically invisible. * * Each slot owns its own listener set, so writing one var only re-renders the - * components subscribed to *that* var. The context value is the store object - * itself and never changes identity, so mounting the provider never cascades. + * components subscribed to *that* var. */ import { createContext, @@ -26,9 +32,9 @@ import { export const CLIENT_STATE_REF = "__client_state"; /** - * Create a slot: one named (or anonymous) piece of client state. + * Create a slot: one piece of client state, with its own subscribers. * @param value The initial value. - * @returns A slot with its own listener set. + * @returns The slot. */ const createSlot = (value) => { const listeners = new Set(); @@ -54,61 +60,91 @@ const createSlot = (value) => { }; /** - * Create a store of client state slots. - * @returns The store. + * Create a scope: a node in the ownership chain. + * @param parent The enclosing scope, or null for a root. + * @returns The scope. */ -export const createClientStateStore = () => { - const slots = new Map(); - - /** - * Get the slot for `name`, creating it if absent. - * @param name The slot name. - * @param defaultValue Initial value, used only when creating the slot. - * @returns The named slot. - */ - const namedSlot = (name, defaultValue) => { - let slot = slots.get(name); - if (slot === undefined) { - slot = createSlot(defaultValue); - slots.set(name, slot); - } - return slot; +const createScope = (parent) => { + const owned = new Map(); + const scope = { + parent, + owned, + /** + * Claim `name` in this scope, or return the slot already claimed here. + * + * Get-or-create, so a double invocation under StrictMode or a re-entrant + * render converges on one slot rather than replacing it. + * @param name The client state name. + * @param defaultValue Initial value, used only when claiming. + * @returns The slot this scope owns for `name`. + */ + own: (name, defaultValue) => { + let slot = owned.get(name); + if (slot === undefined) { + slot = createSlot(defaultValue); + owned.set(name, slot); + } + return slot; + }, + /** + * Find the slot an ancestor (or this scope) already owns for `name`. + * @param name The client state name. + * @returns The slot, or undefined when nothing in the chain owns it. + */ + find: (name) => { + for (let current = scope; current !== null; current = current.parent) { + const found = current.owned.get(name); + if (found !== undefined) { + return found; + } + } + return undefined; + }, }; + return scope; +}; + +/** + * Walk to the root of a scope chain. + * @param scope Any scope in the chain. + * @returns The root scope. + */ +const rootOf = (scope) => { + let current = scope; + while (current.parent !== null) { + current = current.parent; + } + return current; +}; +/** + * Create a store: the root scope, plus the by-name access the backend uses. + * @returns The store. + */ +export const createClientStateStore = () => { + const root = createScope(null); return { + root, /** - * Resolve the slot a `useClientState` call should bind to. - * @param name The shared name, or a falsy value for a private slot. - * @param defaultValue The initial value. - * @returns A shared slot when named, else a fresh anonymous one. - */ - slot: (name, defaultValue) => - name ? namedSlot(name, defaultValue) : createSlot(defaultValue), - /** - * Read a named slot's current value. - * @param name The slot name. - * @returns The value, or undefined if the slot does not exist yet. + * Read a name from the root scope. + * @param name The client state name. + * @returns The value, or undefined if nothing owns the name yet. */ - get: (name) => slots.get(name)?.value, + get: (name) => root.owned.get(name)?.value, /** - * Write a named slot, creating it if it does not exist yet, so a value - * pushed before any component mounts is picked up on mount. - * @param name The slot name. + * Write a name in the root scope, claiming it if needed, so a value pushed + * before any component mounts is picked up on mount. + * @param name The client state name. * @param value The value, or an updater function. */ set: (name, value) => { - namedSlot(name, undefined).set(value); + root.own(name, undefined).set(value); }, }; }; let _clientStore = null; -// How many providers are currently mounted. Several can share one store (an -// embedded app rendered alongside a main app), so the `refs` entry must survive -// until the last of them unmounts. -let _mountedProviders = 0; - /** * The client-side store singleton. * @@ -127,20 +163,22 @@ export const getClientStore = () => { return _clientStore; }; -export const ClientStateContext = createContext(null); +/** The nearest owning scope. Null outside any provider. */ +export const ClientStateScopeContext = createContext(null); /** - * Read a named client state var from outside the React tree. + * Read a globally-named client state var from outside the React tree. * * A point-in-time snapshot with no reactivity; prefer the value returned by - * `useClientState` inside components. + * `useClientState` inside components. Only names declared as global resolve + * here — tree-scoped vars are deliberately unreachable from outside their tree. * @param name The client state var name. * @returns The current value. */ export const getClientState = (name) => getClientStore().get(name); /** - * Write a named client state var from outside the React tree. + * Write a globally-named client state var from outside the React tree. * * Every subscribed component re-renders. Use this to drive client state from * third-party library callbacks or other non-React JS. @@ -151,8 +189,10 @@ export const setClientState = (name, value) => { getClientStore().set(name, value); }; +let _mountedProviders = 0; + /** - * Provide the client state store to the tree. + * Provide the root scope to the tree. * @param props The component props. * @param props.children The children to render. * @param props.registry Optional object to publish the store on, under @@ -187,24 +227,84 @@ export function ClientStateProvider({ children, registry }) { }; }, [store, registry]); - return createElement(ClientStateContext.Provider, { value: store }, children); + return createElement( + ClientStateScopeContext.Provider, + { value: store.root }, + children, + ); +} + +/** + * Open a client state scope around a subtree. + * + * Emitted by the compiler at component-instance boundaries. Names first used + * inside are owned here, so each mounted instance gets its own state and its + * descendants share it. + * @param props The component props. + * @param props.children The children to render. + * @returns The provider element. + */ +export function ClientStateScope({ children }) { + const parent = useContext(ClientStateScopeContext); + const scopeRef = useRef(null); + if (scopeRef.current === null || scopeRef.current.parent !== parent) { + scopeRef.current = createScope(parent ?? getClientStore().root); + } + return createElement( + ClientStateScopeContext.Provider, + { value: scopeRef.current }, + children, + ); } +/** + * Wrap a component so each mounted instance gets its own client state scope. + * + * The scope must sit *above* the component, not inside what it returns: a + * component's hooks run before the elements it returns are mounted, so a + * provider in its own output would leave its own `useClientState` calls + * resolving against the enclosing scope and sharing state across instances. + * + * The compiler applies this to memo definitions that are real component + * instance boundaries, leaving optimizer-generated ones untouched so they stay + * semantically invisible. + * @param Component The component to wrap. + * @returns The wrapped component. + */ +export const withClientStateScope = (Component) => { + const Wrapped = (props) => + createElement(ClientStateScope, null, createElement(Component, props)); + Wrapped.displayName = `withClientStateScope(${ + Component.displayName ?? Component.name ?? "Component" + })`; + return Wrapped; +}; + /** * Subscribe to a piece of client state. * @param defaultValue The initial value. - * @param name Shared name, or omitted for state private to this component. + * @param name The name identifying this var. Compiler-generated when the caller + * did not choose one, and always a compile-time constant. + * @param isGlobal When true the name resolves in the root scope, ignoring any + * enclosing boundary, so it is shared app-wide and reachable from the backend. * @returns A `[value, setValue]` pair, like `useState`. */ -export function useClientState(defaultValue, name) { - const store = useContext(ClientStateContext) ?? getClientStore(); - const slotRef = useRef(null); - if (slotRef.current === null) { - // `name` is a compile-time constant per call site, so the slot a mounted - // hook is bound to can never change. - slotRef.current = store.slot(name, defaultValue); +export function useClientState(defaultValue, name, isGlobal) { + const contextScope = useContext(ClientStateScopeContext); + const nearest = contextScope ?? getClientStore().root; + const scope = isGlobal ? rootOf(nearest) : nearest; + + const bindingRef = useRef(null); + if (bindingRef.current === null || bindingRef.current.scope !== scope) { + // Re-resolve when the scope identity changes: binding once would strand a + // mounted hook on a slot from a scope that no longer applies. + bindingRef.current = { + scope, + slot: scope.find(name) ?? scope.own(name, defaultValue), + }; } - const slot = slotRef.current; + const { slot } = bindingRef.current; + const value = useSyncExternalStore( slot.subscribe, slot.getSnapshot, diff --git a/packages/reflex-base/src/reflex_base/client_state.py b/packages/reflex-base/src/reflex_base/client_state.py index a5c1d104534..6400fbcde5d 100644 --- a/packages/reflex-base/src/reflex_base/client_state.py +++ b/packages/reflex-base/src/reflex_base/client_state.py @@ -56,6 +56,16 @@ # generated var-name sequence. _placeholder_counter = itertools.count() +# Raised for every path that addresses a var by name from outside its tree. +_NOT_GLOBAL_MSG = ( + "Cannot {action}: this client state var is scoped to the component tree " + 'that uses it. Give it a name -- rx.client_state("my_name") -- to make it ' + "global and addressable." +) + +# Default prefix for generated names. +_DEFAULT_NAME_PREFIX = "cs" + _VALID_NAME = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*$") # The store's entry point on the global `refs` object. This is the only binding @@ -206,8 +216,9 @@ class ClientStateVar(Var): # The bare name keying this var in the client state store. _state_name: str = dataclasses.field(default="") - # Whether the state is shared by name (and reachable from the backend). - _global_ref: bool = dataclasses.field(default=True) + # Whether the name resolves in the app-wide root scope (and is therefore + # reachable from the backend) rather than being owned by a component tree. + _is_global: bool = dataclasses.field(default=True) # VarData without the hook, for accessors that work in any JS scope. _escape_var_data: VarData | None = dataclasses.field(default=None) @@ -224,23 +235,28 @@ def __hash__(self) -> int: self._getter_name, self._setter_name, self._state_name, - self._global_ref, + self._is_global, )) @classmethod def create( cls, - var_name: str | None = None, default: Any = NoValue, - global_ref: bool = True, + *, + name: str | None = None, + prefix: str = _DEFAULT_NAME_PREFIX, ) -> ClientStateVar: - """Create a local_state Var that can be accessed and updated on the client. + """Create a client state Var that can be accessed and updated on the client. - With ``global_ref`` set (the default) the state is keyed by name in a - store shared across the app, so it can be read and written from any - component and from the backend. Without it the state is anonymous: it is - private to the component the hook is emitted in, and `push`, `retrieve`, - `global_value` and `global_set` cannot address it. + Whether the state is shared app-wide follows from whether you name it: + + - ``rx.client_state("my_name")`` is **global**. It resolves in one + app-wide store, so any component and the backend can read and write + it, and `push`, `retrieve`, `global_value` and `global_set` work. + - ``rx.client_state()`` is **tree-scoped**. It gets a compile-time name + and the first component to use it claims it for its descendants, so + each instance of that component gets its own state -- like React's + ``useState`` -- and nothing outside the tree can address it. To render the var in a component, use the `value` property. @@ -258,32 +274,46 @@ def create( `global_value` and `global_set` properties. Args: - var_name: The name of the variable. default: The default value of the variable. - global_ref: Whether the state should be accessible in any Component and on the backend. + name: Optional name. Naming the var makes it global. + prefix: Prefix for the generated name when the var is unnamed, to + keep the compiled javascript readable. Ignored when ``name`` is + given. Returns: ClientStateVar Raises: - ValueError: If var_name is not a valid identifier string. + ValueError: If name or prefix is not a valid identifier string. """ - if var_name is None: - var_name = f"cs{next(_name_counter)}" + # Named -> global, unnamed -> tree-scoped. + is_global = name is not None + if name is None: + if not _VALID_NAME.match(prefix): + msg = ( + f"prefix {prefix!r} is not a valid javascript identifier; it " + "is emitted as one in the compiled app." + ) + raise ValueError(msg) + # One shared counter across every prefix, so a generated name is + # unique no matter what prefixes are in play. + var_name = f"{prefix}{next(_name_counter)}" + else: + var_name = name if isinstance(var_name, Var): msg = ( - "var_name must be a string, not a Var. The name keys the client " + "name must be a string, not a Var. The name keys the client " "state store and is embedded in the events that `push`, " "`retrieve` and `global_set` send, so it has to be known at " "compile time." ) raise ValueError(msg) if not isinstance(var_name, str): - msg = "var_name must be a string." + msg = "name must be a string." raise ValueError(msg) if not _VALID_NAME.match(var_name): msg = ( - f"var_name {var_name!r} is not a valid javascript identifier; it " + f"name {var_name!r} is not a valid javascript identifier; it " "is emitted as one in the compiled app." ) raise ValueError(msg) @@ -300,9 +330,13 @@ def create( # word; the store key stays the bare name. getter_name = f"{var_name}{CAMEL_CASE_CLIENT_STATE_MARKER}" setter_name = f"set{var_name[0].upper()}{var_name[1:]}" - name_arg = f", {LiteralVar.create(var_name)!s}" if global_ref else "" + # The name is always passed: it identifies the slot within whichever + # scope owns it. The trailing flag is what escapes to the root scope. + args = f"{default_var!s}, {LiteralVar.create(var_name)!s}" + if is_global: + args += ", true" hooks: dict[str, VarData | None] = { - f"const [{getter_name}, {setter_name}] = useClientState({default_var!s}{name_arg})": None, + f"const [{getter_name}, {setter_name}] = useClientState({args})": None, } app_wraps = get_client_state_app_wraps() return cls( @@ -310,7 +344,7 @@ def create( _setter_name=setter_name, _getter_name=getter_name, _state_name=var_name, - _global_ref=global_ref, + _is_global=is_global, _var_type=default_var._var_type, _var_data=VarData.merge( default_var._var_data, @@ -414,8 +448,8 @@ def global_value(self) -> Var: Raises: ValueError: If the ClientStateVar is not global. """ - if not self._global_ref: - msg = "ClientStateVar must be global to read the value from any scope." + if not self._is_global: + msg = _NOT_GLOBAL_MSG.format(action="read the value from outside the tree") raise ValueError(msg) return Var( _js_expr=f"getClientState({LiteralVar.create(self._state_name)!s})", @@ -436,8 +470,8 @@ def global_set(self) -> Var: Raises: ValueError: If the ClientStateVar is not global. """ - if not self._global_ref: - msg = "ClientStateVar must be global to set the value from any scope." + if not self._is_global: + msg = _NOT_GLOBAL_MSG.format(action="set the value from outside the tree") raise ValueError(msg) return Var( _js_expr=( @@ -460,8 +494,8 @@ def retrieve(self, callback: EventHandler | Callable | None = None) -> EventSpec Raises: ValueError: If the ClientStateVar is not global. """ - if not self._global_ref: - msg = "ClientStateVar must be global to retrieve the value." + if not self._is_global: + msg = _NOT_GLOBAL_MSG.format(action="retrieve the value") raise ValueError(msg) callback_kwargs = {"callback": None} if callback is not None: @@ -494,8 +528,8 @@ def push(self, value: Any) -> EventSpec: Raises: ValueError: If the ClientStateVar is not global. """ - if not self._global_ref: - msg = "ClientStateVar must be global to push the value." + if not self._is_global: + msg = _NOT_GLOBAL_MSG.format(action="push a value") raise ValueError(msg) if isinstance(value, Var): # A Var is a client-side expression, which cannot survive the JSON diff --git a/packages/reflex-base/src/reflex_base/components/client_state_context.py b/packages/reflex-base/src/reflex_base/components/client_state_context.py index 6a95a82a708..2c0e037a6e1 100644 --- a/packages/reflex-base/src/reflex_base/components/client_state_context.py +++ b/packages/reflex-base/src/reflex_base/components/client_state_context.py @@ -15,6 +15,7 @@ from reflex_base.constants import Dirs from reflex_base.utils.imports import ImportVar from reflex_base.vars.base import Var, VarData +from reflex_base.vars.function import FunctionVar # Inside ErrorBoundary (55) so a client-state error is caught, outside the # theme/toaster/overlay wraps. It depends on neither StateProvider nor @@ -31,6 +32,33 @@ ) +def scoped_memo_wrapper(inner: Var | None) -> Var: + """Compose a memo wrapper that also opens a client state scope. + + The scope has to sit *above* the component function: a component's hooks run + before the elements it returns are mounted, so a provider inside its own + output would leave its own ``useClientState`` calls resolving against the + enclosing scope and sharing state across instances. + + Args: + inner: The wrapper the definition would otherwise use, if any. + + Returns: + A function Var suitable for ``MemoComponentDefinition.wrapper``. + """ + scope_import = VarData( + imports={f"$/{Dirs.CLIENT_STATE_PATH}": [ImportVar(tag="withClientStateScope")]} + ) + if inner is None: + return Var(_js_expr="withClientStateScope", _var_data=scope_import).to( + FunctionVar + ) + return Var( + _js_expr=f"((Component) => withClientStateScope({inner!s}(Component)))", + _var_data=VarData.merge(scope_import, inner._get_all_var_data()), + ).to(FunctionVar) + + class ClientStateContextProvider(Component): """App wrap that mounts the React client-state provider around children.""" diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index 8c0d1e9d98a..8b0dba8cb56 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -324,6 +324,11 @@ class MemoComponentDefinition(MemoDefinition): # wrapper's ``VarData`` supplies its imports, so a custom wrapper brings # its own and ``None`` pulls in nothing. wrapper: Var | None = DEFAULT_MEMO_WRAPPER + # Whether each render of this memo is a distinct component instance from + # the user's point of view. True only for ``@rx.memo``; the auto-memoize + # optimizer's wrappers leave it False so they stay semantically invisible + # -- notably to client state, which opens a scope per instance boundary. + is_instance_boundary: bool = False @property def component(self) -> Component: @@ -2019,6 +2024,7 @@ def _memo_impl( ), _runtime_inferred_params=frozenset(missing_params), wrapper=wrapper, + is_instance_boundary=True, ) memo_callable = _create_component_wrapper(definition) else: diff --git a/packages/reflex-components-internal/src/reflex_components_internal/blocks/demo_form.py b/packages/reflex-components-internal/src/reflex_components_internal/blocks/demo_form.py index f00d638cc15..f0eced6a75e 100644 --- a/packages/reflex-components-internal/src/reflex_components_internal/blocks/demo_form.py +++ b/packages/reflex-components-internal/src/reflex_components_internal/blocks/demo_form.py @@ -21,8 +21,8 @@ from reflex_components_internal.components.icons.others import select_arrow from reflex_components_internal.utils.twmerge import cn -demo_form_error_message = rx.client_state("demo_form_error_message", "") -demo_form_open_cs = rx.client_state("demo_form_open", False) +demo_form_error_message = rx.client_state("", name="demo_form_error_message") +demo_form_open_cs = rx.client_state(False, name="demo_form_open") PERSONAL_EMAIL_PROVIDERS = r"^(?!.*@(gmail|outlook|hotmail|yahoo|icloud|aol|protonmail|mail|yandex|zoho|live|msn|me|mac|googlemail)\.com$|.*@(yahoo|outlook|hotmail)\.co\.uk$|.*@yahoo\.ca$|.*@yahoo\.co\.in$|.*@proton\.me$).*$" diff --git a/packages/reflex-components-internal/src/reflex_components_internal/blocks/intro_form.py b/packages/reflex-components-internal/src/reflex_components_internal/blocks/intro_form.py index 09a848d4dd8..8bb9631f697 100644 --- a/packages/reflex-components-internal/src/reflex_components_internal/blocks/intro_form.py +++ b/packages/reflex-components-internal/src/reflex_components_internal/blocks/intro_form.py @@ -19,9 +19,9 @@ from reflex_components_internal.components.icons.others import select_arrow from reflex_components_internal.utils.twmerge import cn -intro_form_error_message = rx.client_state("intro_form_error_message", "") -intro_form_open_cs = rx.client_state("intro_form_open", False) -is_submitting_intro_form_cs = rx.client_state("is_submitting_intro_form", False) +intro_form_error_message = rx.client_state("", name="intro_form_error_message") +intro_form_open_cs = rx.client_state(False, name="intro_form_open") +is_submitting_intro_form_cs = rx.client_state(False, name="is_submitting_intro_form") PERSONAL_EMAIL_PROVIDERS = r"^(?!.*@(gmail|outlook|hotmail|yahoo|icloud|aol|protonmail|mail|yandex|zoho|live|msn|me|mac|googlemail)\.com$|.*@(yahoo|outlook|hotmail)\.co\.uk$|.*@yahoo\.ca$|.*@yahoo\.co\.in$|.*@proton\.me$).*$" diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 216b5d4bed6..110bb6362ee 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -17,6 +17,7 @@ from urllib.parse import urlparse from reflex_base import constants +from reflex_base.components.client_state_context import scoped_memo_wrapper from reflex_base.components.component import Component, ComponentStyle from reflex_base.components.memo import ( MemoComponentDefinition, @@ -434,6 +435,15 @@ def compile_experimental_component_memo( # var itself, so a custom wrapper brings its own imports and ``None`` # pulls in nothing. wrapper = definition.wrapper + if ( + definition.is_instance_boundary + and f"$/{constants.Dirs.CLIENT_STATE_PATH}" in imports + ): + # This memo is a real component instance boundary and its body uses + # client state, so wrap it in a client state scope: names it declares are + # owned per instance, and its descendants resolve to the same slots. + # Gated on actual usage so pages don't pay a provider per memo. + wrapper = scoped_memo_wrapper(wrapper) if wrapper is not None and (wrapper_var_data := wrapper._get_all_var_data()): for lib, fields in wrapper_var_data.imports: imports.setdefault(lib, []).extend(fields) diff --git a/reflex/experimental/__init__.py b/reflex/experimental/__init__.py index ffb49c16d0e..e65c172a59b 100644 --- a/reflex/experimental/__init__.py +++ b/reflex/experimental/__init__.py @@ -12,6 +12,7 @@ from . import hooks as hooks from .client_state import ClientStateVar as ClientStateVar +from .client_state import client_state as _legacy_client_state logger = logging.getLogger(__name__) @@ -71,7 +72,7 @@ def register_component_warning(component_name: str): _x = ExperimentalNamespace( - client_state=ClientStateVar.create, + client_state=_legacy_client_state, hooks=hooks, code_block=code_block, hybrid_property=hybrid_property, diff --git a/reflex/experimental/client_state.py b/reflex/experimental/client_state.py index da4b7d501e5..0831092f0df 100644 --- a/reflex/experimental/client_state.py +++ b/reflex/experimental/client_state.py @@ -1,14 +1,51 @@ -"""Handle client side state with `useClientState`. +"""Deprecated `ClientStateVar` entry point. -Deprecated location. The implementation moved to -:mod:`reflex_base.client_state` and is exposed as ``rx.client_state``; this -module re-exports it so existing imports keep working. +The implementation moved to :mod:`reflex_base.client_state` and is exposed as +``rx.client_state``, whose signature is `client_state(default, *, name=None)`. +This module keeps the original signature working and is where the deprecation +notices live, so the new API carries none of them. """ from __future__ import annotations +from typing import Any + from reflex_base.client_state import ClientStateVar as ClientStateVar from reflex_base.client_state import NoValue as NoValue -from reflex_base.client_state import client_state as client_state +from reflex_base.utils import console __all__ = ["ClientStateVar", "NoValue", "client_state"] + + +def client_state( + var_name: str | None = None, + default: Any = NoValue, + global_ref: bool | Any = NoValue, +) -> ClientStateVar: + """Create a client state var using the original argument order. + + Args: + var_name: The name of the variable. Naming it makes the var global. + default: The default value of the variable. + global_ref: Formerly selected whether the state was app-wide. Scoping now + follows from whether the var is named, so this is only honored to + keep existing callers behaving as they did. + + Returns: + The client state var. + """ + console.deprecate( + feature_name="rx._x.client_state", + reason=( + "Use rx.client_state(default, name=...) instead. Naming a var makes " + "it global; an unnamed var is scoped to the component tree that " + "first uses it, so `global_ref` is no longer needed." + ), + deprecation_version="0.9.9", + removal_version="1.0", + ) + # `global_ref=False` meant "anonymous": the name was never a store key, so + # dropping it reproduces that exactly under the new scoping rules. + if global_ref is not NoValue and not global_ref: + var_name = None + return ClientStateVar.create(default=default, name=var_name) diff --git a/tests/integration/tests_playwright/test_client_state.py b/tests/integration/tests_playwright/test_client_state.py index 44878043f83..fe0b300c317 100644 --- a/tests/integration/tests_playwright/test_client_state.py +++ b/tests/integration/tests_playwright/test_client_state.py @@ -21,9 +21,9 @@ def ClientStateApp(): import reflex as rx - shared = rx.client_state("shared", default="initial") - counter = rx.client_state("counter", default=0) - other = rx.client_state("other", default="untouched") + shared = rx.client_state("initial", name="shared") + counter = rx.client_state(0, name="counter") + other = rx.client_state("untouched", name="other") class ClientStateAppState(rx.State): retrieved: str = "" @@ -42,8 +42,9 @@ def got_value(self, value: str): @rx.memo def local_input(label: rx.Var[str]) -> rx.Component: - # global_ref=False: each rendered instance owns a private slot. - local = rx.client_state(global_ref=False, default="") + # Unnamed: constructed inside the component, so each rendered instance + # owns its own slot. + local = rx.client_state("") return rx.hstack( rx.input( value=local.value, diff --git a/tests/js/client_state.test.js b/tests/js/client_state.test.js index a8c50fbf657..881dc33f359 100644 --- a/tests/js/client_state.test.js +++ b/tests/js/client_state.test.js @@ -15,11 +15,13 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { CLIENT_STATE_REF, ClientStateProvider, + ClientStateScope, createClientStateStore, getClientState, getClientStore, setClientState, useClientState, + withClientStateScope, } from "$/utils/client_state"; // React 19 wants this set when driving roots manually. @@ -50,33 +52,31 @@ beforeEach(() => { }); describe("store slots", () => { - test("a named slot is shared and seeded by the first default", () => { + test("a name is claimed once and seeded by the first default", () => { const store = createClientStateStore(); - const first = store.slot("shared", "initial"); - const second = store.slot("shared", "ignored"); + const first = store.root.own("shared", "initial"); + const second = store.root.own("shared", "ignored"); expect(second).toBe(first); expect(store.get("shared")).toBe("initial"); }); - test("an unnamed slot is private and unaddressable by name", () => { + test("a child scope shadows nothing it does not own", () => { const store = createClientStateStore(); - const a = store.slot(undefined, "a"); - const b = store.slot(undefined, "b"); - - expect(a).not.toBe(b); - a.set("changed"); - expect(b.getSnapshot()).toBe("b"); - // Anonymous slots are never registered, so nothing can reach them by name. - expect(store.get(undefined)).toBeUndefined(); + const parentSlot = store.root.own("shared", "from parent"); + + // A child that has not claimed the name resolves to the parent's slot. + const child = { parent: store.root, owned: new Map() }; + expect(store.root.find("shared")).toBe(parentSlot); + expect(child.parent.find("shared")).toBe(parentSlot); }); test("writing one var does not notify another var's subscribers", () => { const store = createClientStateStore(); const watched = vi.fn(); const unrelated = vi.fn(); - store.slot("a", 0).subscribe(watched); - store.slot("b", 0).subscribe(unrelated); + store.root.own("a", 0).subscribe(watched); + store.root.own("b", 0).subscribe(unrelated); store.set("a", 1); @@ -86,7 +86,7 @@ describe("store slots", () => { test("a function value is applied as an updater", () => { const store = createClientStateStore(); - store.slot("n", 1); + store.root.own("n", 1); store.set("n", (previous) => previous + 41); @@ -96,7 +96,7 @@ describe("store slots", () => { test("setting an equal value notifies nobody", () => { const store = createClientStateStore(); const listener = vi.fn(); - store.slot("n", 7).subscribe(listener); + store.root.own("n", 7).subscribe(listener); store.set("n", 7); @@ -112,7 +112,7 @@ describe("store slots", () => { store.set("later", "pushed early"); expect(store.get("later")).toBe("pushed early"); - expect(store.slot("later", "default ignored").getSnapshot()).toBe( + expect(store.root.own("later", "default ignored").getSnapshot()).toBe( "pushed early", ); }); @@ -120,7 +120,7 @@ describe("store slots", () => { test("unsubscribing detaches the listener", () => { const store = createClientStateStore(); const listener = vi.fn(); - const unsubscribe = store.slot("n", 0).subscribe(listener); + const unsubscribe = store.root.own("n", 0).subscribe(listener); unsubscribe(); store.set("n", 1); @@ -199,10 +199,10 @@ describe("ClientStateProvider", () => { describe("useClientState", () => { /** Render `useClientState(default, name)` and report renders and value. */ - const probe = (defaultValue, name, id) => { + const probe = (defaultValue, name, id, isGlobal) => { const renders = { count: 0, value: undefined, set: undefined }; const Probe = () => { - const [value, set] = useClientState(defaultValue, name); + const [value, set] = useClientState(defaultValue, name, isGlobal); renders.count += 1; renders.value = value; renders.set = set; @@ -226,17 +226,19 @@ describe("useClientState", () => { unmount(); }); - test("keeps unnamed vars private to each component", () => { - const a = probe("", undefined, "a"); - const b = probe("", undefined, "b"); + test("shares one scope between siblings under the same boundary", () => { + // Two consumers of the same name with no boundary between them: the + // enclosing scope owns it, so an auto-memo split stays invisible. + const a = probe("", "sibling", "a"); + const b = probe("", "sibling", "b"); const { unmount } = mount( createElement(ClientStateProvider, { registry }, a.element, b.element), ); - act(() => a.renders.set("mine")); + act(() => a.renders.set("shared")); - expect(a.renders.value).toBe("mine"); - expect(b.renders.value).toBe(""); + expect(a.renders.value).toBe("shared"); + expect(b.renders.value).toBe("shared"); unmount(); }); @@ -319,6 +321,213 @@ describe("non-React escape hatch", () => { }); }); +describe("scope chain", () => { + /** Render `useClientState` and report renders, value and setter. */ + const probe = (defaultValue, name, isGlobal) => { + const renders = { count: 0, value: undefined, set: undefined }; + const Probe = () => { + const [value, set] = useClientState(defaultValue, name, isGlobal); + renders.count += 1; + renders.value = value; + renders.set = set; + return null; + }; + return { renders, element: createElement(Probe) }; + }; + + test("a descendant inherits the slot its ancestor scope owns", () => { + // The boundary's own consumer claims the name; a component further down + // resolves up the chain to that same slot. + const owner = probe("initial", "claimed"); + const descendant = probe("initial", "claimed"); + const { unmount } = mount( + createElement( + ClientStateProvider, + { registry }, + createElement( + ClientStateScope, + null, + owner.element, + createElement(ClientStateScope, null, descendant.element), + ), + ), + ); + + act(() => owner.renders.set("written by owner")); + + expect(descendant.renders.value).toBe("written by owner"); + + unmount(); + }); + + test("sibling boundaries get separate slots for the same name", () => { + const first = probe("initial", "perInstance"); + const second = probe("initial", "perInstance"); + const { unmount } = mount( + createElement( + ClientStateProvider, + { registry }, + createElement(ClientStateScope, null, first.element), + createElement(ClientStateScope, null, second.element), + ), + ); + + act(() => first.renders.set("only mine")); + + expect(first.renders.value).toBe("only mine"); + expect(second.renders.value).toBe("initial"); + + unmount(); + }); + + test("a nested boundary claims a name its ancestors have not", () => { + const outer = probe("initial", "onlyInner"); + const inner = probe("initial", "onlyInner"); + const { unmount } = mount( + createElement( + ClientStateProvider, + { registry }, + createElement( + ClientStateScope, + null, + createElement(ClientStateScope, null, inner.element), + outer.element, + ), + ), + ); + + // The inner boundary rendered first and claimed it there, so the outer + // consumer -- which is not a descendant of it -- is unaffected. + act(() => inner.renders.set("inner only")); + + expect(inner.renders.value).toBe("inner only"); + expect(outer.renders.value).toBe("initial"); + + unmount(); + }); + + test("a global name stays global inside a boundary", () => { + const scoped = probe("initial", "globalName", true); + const atRoot = probe("initial", "globalName", true); + const { unmount } = mount( + createElement( + ClientStateProvider, + { registry }, + createElement(ClientStateScope, null, scoped.element), + atRoot.element, + ), + ); + + act(() => scoped.renders.set("from inside a boundary")); + + expect(atRoot.renders.value).toBe("from inside a boundary"); + // Reachable from outside React, which is the point of being global. + expect(getClientState("globalName")).toBe("from inside a boundary"); + + unmount(); + }); + + test("a scoped name is not reachable from outside the tree", () => { + const scoped = probe("initial", "treeOnly"); + const { unmount } = mount( + createElement( + ClientStateProvider, + { registry }, + createElement(ClientStateScope, null, scoped.element), + ), + ); + + act(() => scoped.renders.set("private")); + + expect(scoped.renders.value).toBe("private"); + expect(getClientState("treeOnly")).toBeUndefined(); + + unmount(); + }); + + test("writing in one boundary leaves a sibling boundary unrendered", () => { + const first = probe(0, "isolated"); + const second = probe(0, "isolated"); + const { unmount } = mount( + createElement( + ClientStateProvider, + { registry }, + createElement(ClientStateScope, null, first.element), + createElement(ClientStateScope, null, second.element), + ), + ); + const before = second.renders.count; + + act(() => first.renders.set(1)); + + expect(first.renders.value).toBe(1); + expect(second.renders.count).toBe(before); + + unmount(); + }); +}); + +describe("withClientStateScope", () => { + test("gives each mounted instance its own state", () => { + // The shape the compiler emits for a real component boundary. The scope has + // to be outside the component, since its hooks run before its output + // mounts -- inside, every instance would share the enclosing scope. + // Track the LATEST value per instance: comparing first-render snapshots + // passes even when the state is shared. + const latest = {}; + const setters = {}; + const Counter = ({ which }) => { + const [value, set] = useClientState(0, "perInstanceHoc"); + latest[which] = value; + setters[which] = set; + return null; + }; + const Scoped = withClientStateScope(Counter); + + const { unmount } = mount( + createElement( + ClientStateProvider, + { registry }, + createElement(Scoped, { which: "a" }), + createElement(Scoped, { which: "b" }), + ), + ); + + act(() => setters.a(1)); + + expect(latest.a).toBe(1); + expect(latest.b).toBe(0); + + unmount(); + }); + + test("descendants of a wrapped instance share its state", () => { + const parentSeen = []; + const childSeen = []; + const Child = () => { + const [value] = useClientState("initial", "sharedWithChild"); + childSeen.push(value); + return null; + }; + const Parent = () => { + const [value, set] = useClientState("initial", "sharedWithChild"); + parentSeen.push({ value, set }); + return createElement(Child); + }; + const Scoped = withClientStateScope(Parent); + + const { unmount } = mount( + createElement(ClientStateProvider, { registry }, createElement(Scoped)), + ); + + act(() => parentSeen[0].set("from parent")); + + expect(childSeen.at(-1)).toBe("from parent"); + + unmount(); + }); +}); + test("CLIENT_STATE_REF matches the key state.js reads", () => { // The runtime reaches the store through the object it is handed, so the key // is duplicated on the reading side and has to stay in sync. diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index a7f7ea11745..24fe0f53102 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -1332,7 +1332,7 @@ def test_client_state_setter_in_call_function_event_imports_hook() -> None: """ from reflex.compiler.compiler import compile_memo_components - counter = rx.client_state("counter", default=0) + counter = rx.client_state(0, name="counter") def page() -> Component: return rx.el.button( @@ -1359,7 +1359,7 @@ def page() -> Component: "Expected the memo body to call the client-state setter.\n" f"Memo code snippet: {memo_code[:2000]}" ) - assert 'useClientState(0, "counter")' in memo_code, ( + assert 'useClientState(0, "counter", true)' in memo_code, ( "Expected the memo body to declare the client-state hook so the setter " f"binding exists.\nMemo code snippet: {memo_code[:2000]}" ) @@ -2124,19 +2124,19 @@ def test_static_restricted_element_no_id_no_children_does_not_memoize() -> None: ) -@pytest.mark.parametrize("global_ref", [True, False]) +@pytest.mark.parametrize("name", ["titletest", None]) def test_client_state_value_inside_snapshot_boundary_is_memoized( - global_ref: bool, + name: str | None, ) -> None: """Client-state Vars are reactive and must trigger boundary memoization. A ``client_state`` Var contributes its ``useClientState`` hook via ``var_data.hooks`` without setting ``var_data.state``. The reactive-Var walk must catch the hooks-only case so client-state-driven content - inside a snapshot boundary lands in the memo body. Both global and - page-local ``ClientStateVar`` Vars must drive the same wrapping. + inside a snapshot boundary lands in the memo body. Both a named (global) + and an unnamed (tree-scoped) var must drive the same wrapping. """ - cs_var = rx.client_state("titletest", default="hi", global_ref=global_ref) + cs_var = rx.client_state("hi", name=name) title = Title.create(cs_var.value) ctx, page_ctx = _compile_single_page(lambda: title) assert len(ctx.memoize_wrappers) == 1, ( @@ -2376,3 +2376,101 @@ def test_each_memo_wrapper_emits_one_component_module_file() -> None: "for Plain, one for WithProp, and one snapshot wrapper for the " f"LeafComponent boundary. Got: {sorted(ctx.memoize_wrappers)}" ) + + +def _memo_export_line(files: object, symbol: str) -> str: + """Get the `export const` line for one memo symbol. + + Memos from the same source module are grouped into one JS file, so assertions + about a single memo's wrapper have to look at its own export line rather than + the whole file. + + Args: + files: The compiled (path, code) pairs. + symbol: Substring identifying the memo's exported symbol. + + Returns: + The matching export line. + """ + for _path, code in files: # pyright: ignore [reportGeneralTypeIssues] + for line in code.splitlines(): + if line.startswith("export const") and symbol in line: + return line + msg = f"no export line found for {symbol!r}" + raise AssertionError(msg) + + +def test_explicit_memo_using_client_state_opens_a_scope() -> None: + """An ``@rx.memo`` whose body uses client state is an instance boundary. + + The scope must wrap the component function, not sit inside what it returns: + a component's hooks run before its own output mounts, so an inner provider + would leave its own ``useClientState`` resolving against the enclosing scope + and sharing state across instances. + """ + from reflex_base.components.memo import MEMOS + + from reflex.compiler.compiler import compile_memo_components + + @rx.memo + def scoped_toggle(label: rx.Var[str]) -> Component: + local = rx.client_state(False) + return rx.el.button(label, on_click=local.set(True)) + + scoped_toggle(label="x") + files, _ = compile_memo_components(memos=tuple(MEMOS.values())) + export_line = _memo_export_line(files, "ScopedToggle") + + assert "withClientStateScope(memo(Component))" in export_line, ( + f"expected the memo wrapped in a client state scope.\n{export_line}" + ) + assert any( + "withClientStateScope" in line + for _path, code in files + for line in code.splitlines() + if line.startswith("import") + ), "the scope HOC must be imported" + + +def test_memo_without_client_state_is_not_wrapped() -> None: + """Pages must not pay for a scope provider on every memo.""" + from reflex_base.components.memo import MEMOS + + from reflex.compiler.compiler import compile_memo_components + + @rx.memo + def unscoped_label(label: rx.Var[str]) -> Component: + return rx.text(label) + + unscoped_label(label="y") + files, _ = compile_memo_components(memos=tuple(MEMOS.values())) + export_line = _memo_export_line(files, "UnscopedLabel") + + assert "= memo(" in export_line, f"expected the plain memo wrapper.\n{export_line}" + assert "withClientStateScope" not in export_line + + +def test_auto_memo_wrappers_do_not_open_a_scope() -> None: + """Optimizer-generated wrappers must stay semantically invisible. + + Auto-memoization splits one logical component across modules; if each split + opened a scope, the pieces would resolve different slots and a var read in + one and written in another would silently disconnect. + """ + from reflex.compiler.compiler import compile_memo_components + + counter = rx.client_state(0, name="autotransparent") + + def page() -> Component: + return rx.vstack( + rx.text(counter.value), + rx.el.button("set", on_click=counter.set(1)), + ) + + ctx, _page_ctx = _compile_single_page(page) + files, _ = compile_memo_components(memos=tuple(ctx.auto_memo_components.values())) + assert files, "expected auto-memo wrappers for the client-state consumers" + for path, code in files: + assert "withClientStateScope" not in code, ( + f"auto-memo wrapper {path} must not open a client state scope" + ) diff --git a/tests/units/experimental/test_client_state.py b/tests/units/experimental/test_client_state.py index ddf05b8c6cc..b03938aacc3 100644 --- a/tests/units/experimental/test_client_state.py +++ b/tests/units/experimental/test_client_state.py @@ -1,5 +1,7 @@ """The deprecated reflex.experimental.client_state path still resolves.""" +import pytest + import reflex as rx @@ -12,12 +14,40 @@ def test_experimental_import_is_the_promoted_class() -> None: def test_experimental_namespace_factory_still_works() -> None: """``rx._x.client_state`` keeps building the same vars.""" - assert rx._x.client_state("legacy", default=0)._state_name == "legacy" + # The shim keeps the original positional signature. + assert rx._x.client_state("legacy", 0)._state_name == "legacy" def test_promoted_names_are_reachable_from_rx() -> None: """The lazy-loader wiring only fails at attribute access, so assert it.""" - assert rx.client_state("promoted", default=0)._state_name == "promoted" - assert isinstance(rx.client_state("typed", default=0), rx.ClientStateVar) + assert rx.client_state(0, name="promoted")._state_name == "promoted" + assert isinstance(rx.client_state(0, name="typed"), rx.ClientStateVar) # Exported so `.set` can be named in a type annotation. - assert isinstance(rx.client_state("setter", default=0).set, rx.ClientStateSetter) + assert isinstance(rx.client_state(0, name="setter").set, rx.ClientStateSetter) + + +def test_legacy_named_var_is_global() -> None: + """The old positional form keeps naming -- and therefore globalizing -- vars.""" + cs = rx._x.client_state("legacy_named", 0) + assert cs._state_name == "legacy_named" + assert cs._is_global + + +def test_legacy_global_ref_false_drops_the_name() -> None: + """``global_ref=False`` meant anonymous, which is now a dropped name. + + The name was never a store key in that mode, so discarding it reproduces the + old behavior exactly under the new scoping rules. + """ + cs = rx._x.client_state("is_copied", False, False) + assert cs._state_name != "is_copied" + assert not cs._is_global + + +def test_legacy_path_warns(capsys: pytest.CaptureFixture) -> None: + """All the deprecation noise lives on the old entry point, not the new API.""" + rx._x.client_state("warned", 0) + assert "rx._x.client_state" in capsys.readouterr().out + + rx.client_state(0, name="quiet") + assert "deprecat" not in capsys.readouterr().out.lower() diff --git a/tests/units/reflex_base/test_client_state.py b/tests/units/reflex_base/test_client_state.py index 046a5d7b247..f125fc63ed5 100644 --- a/tests/units/reflex_base/test_client_state.py +++ b/tests/units/reflex_base/test_client_state.py @@ -44,52 +44,58 @@ def _app_wraps(var_data: VarData | None) -> list[tuple[int, str]]: def test_single_hook_no_useState() -> None: """A global var emits one useClientState hook and no raw useState/useId.""" - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") hook = _hook(cs) - assert ( - hook - == 'const [counterRxClientState, setCounter] = useClientState(0, "counter")' + assert hook == ( + 'const [counterRxClientState, setCounter] = useClientState(0, "counter", true)' ) assert "useState(" not in hook assert "useId" not in hook assert "refs[" not in hook -@pytest.mark.parametrize("global_ref", [True, False]) -def test_omitted_default_emits_valid_javascript(global_ref: bool) -> None: +def test_omitted_default_emits_valid_javascript() -> None: """No default must still emit a syntactically valid hook call. Regression: an empty default expression rendered as ``useClientState(, "name")`` once the store name became a second argument, which is a syntax error that breaks the whole page build. """ - cs = client_state("counter", global_ref=global_ref) + cs = client_state(name="counter") hook = _hook(cs) assert "(," not in hook - expected = 'undefined, "counter"' if global_ref else "undefined" - assert ( - hook == f"const [counterRxClientState, setCounter] = useClientState({expected})" + assert hook == ( + "const [counterRxClientState, setCounter] = " + 'useClientState(undefined, "counter", true)' ) -def test_local_var_omits_store_name() -> None: - """A ``global_ref=False`` var gets no name, so its slot stays private.""" - cs = client_state("copied", default=False, global_ref=False) - assert _hook(cs) == "const [copiedRxClientState, setCopied] = useClientState(false)" +def test_unnamed_var_is_scoped_not_global() -> None: + """An unnamed var still gets a name, but no flag escaping it to the root.""" + cs = client_state(default=False) + assert _hook(cs).endswith(f'useClientState(false, "{cs._state_name}")') + assert not cs._is_global + + +def test_named_var_is_global() -> None: + """Naming a var is what makes it app-wide and backend-addressable.""" + cs = client_state(False, name="shared") + assert _hook(cs).endswith('useClientState(false, "shared", true)') + assert cs._is_global def test_hook_imports_use_client_state() -> None: """The hook carries the useClientState import.""" - imports = dict(cs_imports := client_state("x", default=0)._var_data.imports) # pyright: ignore [reportOptionalMemberAccess] + imports = dict(cs_imports := client_state(0, name="x")._var_data.imports) # pyright: ignore [reportOptionalMemberAccess] assert cs_imports is not None tags = {i.tag for i in imports[f"$/{Dirs.CLIENT_STATE_PATH}"]} assert tags == {"useClientState"} -@pytest.mark.parametrize("global_ref", [True, False]) -def test_provider_app_wrap_declared(global_ref: bool) -> None: - """The provider is requested in both modes; the hook always uses context.""" - cs = client_state("x", default=0, global_ref=global_ref) +@pytest.mark.parametrize("name", ["x", None]) +def test_provider_app_wrap_declared(name: str | None) -> None: + """The provider is requested either way; the hook always uses context.""" + cs = client_state(0, name=name) assert _app_wraps(cs._var_data) == [ (CLIENT_STATE_APP_WRAP_PRIORITY, "ClientStateProvider") ] @@ -101,15 +107,14 @@ def test_two_vars_dedupe_to_one_provider() -> None: target: dict[tuple[int, str], Any] = {} for name in ("a", "b"): - cs = client_state(name, default=0) + cs = client_state(0, name=name) insert_app_wraps(target, cs._var_data.app_wraps) # pyright: ignore [reportOptionalMemberAccess] assert list(target) == [(CLIENT_STATE_APP_WRAP_PRIORITY, "ClientStateProvider")] -@pytest.mark.parametrize("global_ref", [True, False]) -def test_value_is_marked_identifier(global_ref: bool) -> None: - """``value`` renders the marked local binding in both modes.""" - cs = client_state("counter", default=0, global_ref=global_ref) +def test_value_is_marked_identifier() -> None: + """``value`` renders the marked local binding.""" + cs = client_state(0, name="counter") assert str(cs.value) == "counterRxClientState" @@ -117,20 +122,20 @@ def test_set_bare_is_event_chain() -> None: """``set`` renders the bare setter and is usable as an event trigger value.""" from reflex_base.event import EventChain - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") assert str(cs.set) == "setCounter" assert cs.set._var_type is EventChain def test_set_bound_value() -> None: """Calling ``set`` binds a value in a zero-arg wrapper.""" - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") assert str(cs.set(42)) == "(() => (setCounter(42)))" def test_set_carries_hook_import_and_app_wrap() -> None: """The setter must drag in its own hook, import and provider.""" - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") for setter in (cs.set, cs.set(42)): var_data = setter._get_all_var_data() assert var_data is not None @@ -163,7 +168,7 @@ def test_set_carries_hook_import_and_app_wrap() -> None: ) def test_set_functional_updater_is_typed(default: Any, fn: Any, expected: str) -> None: """A lambda is traced against a placeholder typed like the var.""" - cs = client_state("x", default=default) + cs = client_state(default, name="x") rendered = str(cs.set(fn)) # The placeholder counter is process-global; recover it from the output. n = rendered.split("prev", 1)[1].split("RxClientState", 1)[0] @@ -172,27 +177,27 @@ def test_set_functional_updater_is_typed(default: Any, fn: Any, expected: str) - def test_set_zero_arg_callable_is_plain_value() -> None: """A zero-argument callable is treated as the value, not an updater.""" - cs = client_state("x", default=0) + cs = client_state(0, name="x") assert str(cs.set(lambda: 7)) == "(() => (setX(7)))" def test_set_rejects_multi_arg_callable() -> None: """An updater may only take the current value.""" - cs = client_state("x", default=0) + cs = client_state(0, name="x") with pytest.raises(VarTypeError): cs.set(lambda a, b: a + b) # pyright: ignore [reportCallIssue] # noqa: FURB118 - a lambda is what is under test def test_set_passes_function_var_through() -> None: """A FunctionVar is passed straight through as a runtime updater.""" - cs = client_state("x", default=0) + cs = client_state(0, name="x") updater = Var("(p) => p + 1").to(FunctionVar) assert str(cs.set(updater)) == "(() => (setX((p) => p + 1)))" def test_set_declares_event_arg() -> None: """A value referencing an event arg makes the wrapper declare it.""" - cs = client_state("x", default="") + cs = client_state("", name="x") assert ( str(cs.set(Var('_e["target"]["value"]'))) == '((_e) => (setX(_e["target"]["value"])))' @@ -201,7 +206,7 @@ def test_set_declares_event_arg() -> None: def test_set_declares_event_arg_in_compound_expression() -> None: """Only the event arg is declared, not the whole expression.""" - cs = client_state("x", default="") + cs = client_state("", name="x") assert ( str(cs.set(Var('_e["target"]["value"] + "!"'))) == '((_e) => (setX(_e["target"]["value"] + "!")))' @@ -210,8 +215,8 @@ def test_set_declares_event_arg_in_compound_expression() -> None: def test_underscore_named_var_is_not_mistaken_for_event_arg() -> None: """A marked identifier is an in-scope binding, never a trigger parameter.""" - private = client_state("_private", default="") - other = client_state("other", default="") + private = client_state("", name="_private") + other = client_state("", name="other") assert str(other.set(private.value)) == "(() => (setOther(_privateRxClientState)))" @@ -253,7 +258,7 @@ def test_recovered_event_arg(value_str: str, expected: str | None) -> None: ) def test_reserved_words_are_safe(reserved: str) -> None: """A JS reserved word is a legal name; the marker keeps the codegen valid.""" - cs = client_state(reserved, default=1) + cs = client_state(1, name=reserved) hook = _hook(cs) assert hook.startswith(f"const [{reserved}RxClientState, ") # The store key stays the bare word so the backend can still address it. @@ -263,20 +268,23 @@ def test_reserved_words_are_safe(reserved: str) -> None: def test_camel_case_names_get_distinct_setters() -> None: """``myVar`` and ``myvar`` must not collapse onto one setter binding.""" - assert client_state("myVar")._setter_name != client_state("myvar")._setter_name + assert ( + client_state(name="myVar")._setter_name + != client_state(name="myvar")._setter_name + ) def test_var_name_rejects_var() -> None: """A Var name would only exist at runtime, so it is rejected.""" with pytest.raises(ValueError, match="not a Var"): - client_state(Var("dynamic")) # pyright: ignore [reportArgumentType] + client_state(name=Var("dynamic")) # pyright: ignore [reportArgumentType] @pytest.mark.parametrize("bad", ["1foo", "my-name", "a b", "", "a.b"]) def test_var_name_must_be_identifier(bad: str) -> None: """The name is emitted as a JS identifier, so it has to be one.""" with pytest.raises(ValueError, match="identifier"): - client_state(bad) + client_state(name=bad) def test_generated_names_are_sequential_and_distinct() -> None: @@ -301,7 +309,7 @@ def test_generated_names_unaffected_by_unrelated_var_names() -> None: def test_push_builds_wire_event() -> None: """``push`` sends a first-class client-state event, not an eval'd script.""" - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") spec = cs.push(5) assert spec.handler.fn.__qualname__ == "_client_state_set" assert {str(k): str(v) for k, v in spec.args} == { @@ -312,7 +320,7 @@ def test_push_builds_wire_event() -> None: def test_retrieve_builds_wire_event() -> None: """``retrieve`` sends a first-class client-state event with a callback slot.""" - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") args = {str(k): str(v) for k, v in cs.retrieve().args} assert cs.retrieve().handler.fn.__qualname__ == "_client_state_get" assert args["var_name"] == '"counter"' @@ -321,14 +329,14 @@ def test_retrieve_builds_wire_event() -> None: def test_global_accessors_render_module_functions() -> None: """The escape hatch reads and writes through the module-level functions.""" - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") assert str(cs.global_value) == 'getClientState("counter")' assert str(cs.global_set) == '((value) => setClientState("counter", value))' def test_global_accessors_carry_no_hook() -> None: """The escape hatch must work in any scope, so it drags in no hook.""" - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") for accessor in (cs.global_value, cs.global_set): var_data = accessor._get_all_var_data() assert var_data is not None @@ -345,8 +353,8 @@ def test_global_accessors_carry_no_hook() -> None: ) def test_name_addressed_paths_require_global(accessor: str) -> None: """An anonymous slot has no name, so nothing can address it.""" - cs = client_state("x", default=0, global_ref=False) - with pytest.raises(ValueError, match="must be global"): + cs = client_state(default=0) + with pytest.raises(ValueError, match="scoped to the component tree"): if accessor == "push": cs.push(1) elif accessor == "retrieve": @@ -357,14 +365,14 @@ def test_name_addressed_paths_require_global(accessor: str) -> None: def test_set_value_delegates_and_deprecates(capsys: pytest.CaptureFixture) -> None: """``set_value`` still works, and says to use ``set``.""" - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") assert str(cs.set_value(42)) == str(cs.set(42)) assert "set_value" in capsys.readouterr().out def test_var_renders_as_null() -> None: """The var object itself renders as null so it can sit in a component tree.""" - assert str(client_state("x", default=0)) == "null" + assert str(client_state(0, name="x")) == "null" def test_acceptance_throttle_controlled_input_compiles() -> None: @@ -382,8 +390,8 @@ def debounce_controlled_input( debounce_ms: rx.Var[int], rest: rx.RestProp, ) -> rx.Component: - lc_var = rx.client_state(global_ref=False) - lc_last_var = rx.client_state(global_ref=False) + lc_var = rx.client_state() + lc_last_var = rx.client_state() return rx.el.input( rest, rx.cond( @@ -414,10 +422,10 @@ def debounce_controlled_input( assert len(declarations) == 2, ( f"expected one hook per local var, got {declarations}" ) - # Distinct bindings, and neither is registered under a shared store name - # (a named var would pass the name as a second, string, argument). + # Distinct bindings, and neither escapes to the root scope (a named var + # would pass a trailing `true`). assert len(set(declarations)) == 2 - assert all("useClientState(undefined)" in line for line in declarations) + assert all(not line.rstrip(";").endswith("true)") for line in declarations) assert 'from "$/utils/client_state"' in code @@ -431,20 +439,20 @@ def comp(value: rx.Var[str]) -> rx.Component: return rx.el.input(value=value) comp(value="x") - cs = rx.client_state("target", default="") + cs = rx.client_state("", name="target") assert str(cs.set(captured["value"])) == "(() => (setTarget(valueRxMemo)))" def test_set_with_no_argument_is_the_bare_setter() -> None: """``cs.set()`` is the same forwarding setter as ``cs.set``.""" - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") assert str(cs.set()) == str(cs.set) == "setCounter" def test_hash_distinguishes_vars() -> None: """Vars are hashable and distinct names hash differently.""" - a = client_state("a", default=0) - b = client_state("b", default=0) + a = client_state(0, name="a") + b = client_state(0, name="b") assert hash(a) != hash(b) assert len({a, b, a}) == 2 @@ -452,12 +460,12 @@ def test_hash_distinguishes_vars() -> None: def test_var_name_rejects_non_string() -> None: """A non-string, non-Var name is rejected.""" with pytest.raises(ValueError, match="must be a string"): - client_state(5) # pyright: ignore [reportArgumentType] + client_state(name=5) # pyright: ignore [reportArgumentType] def test_var_default_is_used_directly() -> None: """A Var default is embedded as-is and sets the var's type.""" - cs = client_state("x", default=Var("someExpr").to(int)) + cs = client_state(Var("someExpr").to(int), name="x") assert "useClientState(someExpr" in _hook(cs) assert cs._var_type is int @@ -471,7 +479,7 @@ class RetrieveState(rx.State): def got(self, value: str): self.value = value - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") args = {str(k): str(v) for k, v in cs.retrieve(RetrieveState.got).args} assert args["var_name"] == '"counter"' assert "queueEvents" in args["callback"] @@ -482,7 +490,7 @@ def test_push_plain_value_uses_json_payload() -> None: """A concrete value crosses the wire as JSON, not as JS source.""" from reflex_base.event import fix_events - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") event = fix_events([cs.push({"a": 1})], token="tok")[0] assert event.name.endswith("_client_state_set") assert event.payload == {"var_name": "counter", "value": {"a": 1}} @@ -496,7 +504,7 @@ def test_push_var_is_evaluated_on_the_client() -> None: """ from reflex_base.event import fix_events - cs = client_state("counter", default=0) + cs = client_state(0, name="counter") event = fix_events([cs.push(Var("Date.now()"))], token="tok")[0] assert event.name.endswith("_call_function") assert 'refs["__client_state"].set("counter", Date.now())' in str( @@ -522,3 +530,62 @@ def test_retrieve_callback_runs_even_without_a_store() -> None: # Optional chaining rather than an early return, so a missing store still # reaches the callback with undefined. assert "store?.get(" in branch + + +def test_each_construction_gets_its_own_name() -> None: + """A plain helper called N times yields N independent states. + + ``rx.client_state()`` runs once per call at compile time, so a helper + function that constructs one gives every call site its own slot -- no memo, + no keys, no configuration. + """ + names = [client_state(0)._state_name for _ in range(3)] + assert len(set(names)) == 3 + + +def test_all_consumers_of_one_var_share_the_name() -> None: + """Every consumer emits the same hook, so an auto-memo split still shares. + + This is the property the redesign exists for: reading and writing a var + compile to separate memo modules, and they must resolve the same slot. + """ + cs = client_state(0) + hooks = { + _hook(cs), + *( + hook + for accessor in (cs.value, cs.set, cs.set(1)) + for hook in (accessor._get_all_var_data() or VarData()).hooks + ), + } + assert len(hooks) == 1, f"expected one shared hook, got {hooks}" + + +def test_prefix_customizes_the_generated_name() -> None: + """A prefix keeps the compiled javascript readable for internal use.""" + cs = client_state(0, prefix="counter") + assert cs._state_name.startswith("counter") + assert _hook(cs).startswith("const [counter") + + +def test_prefix_is_ignored_when_named() -> None: + """An explicit name wins; the prefix only shapes generated names.""" + cs = client_state(0, name="explicit", prefix="ignored") + assert cs._state_name == "explicit" + + +def test_generated_names_stay_unique_across_prefixes() -> None: + """One shared counter, so mixing prefixes can never collide.""" + names = [ + client_state(0, prefix="a")._state_name, + client_state(0, prefix="b")._state_name, + client_state(0)._state_name, + ] + assert len(set(names)) == 3 + + +@pytest.mark.parametrize("bad", ["1bad", "my-prefix", "", "a b"]) +def test_prefix_must_be_an_identifier(bad: str) -> None: + """The prefix is emitted as part of a JS identifier, so it has to be one.""" + with pytest.raises(ValueError, match="prefix"): + client_state(0, prefix=bad) From c94d4b9f91700e20bccc40cc1cef68cb70726ff2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 12:22:01 +0000 Subject: [PATCH 07/15] docs(client_state): document naming and tree scoping The wrapping-react page still described the retired `global_ref` model. Explain what actually decides sharing now: naming a var makes it global, an unnamed one is scoped to the tree that uses it, and *where you construct it* picks the owner -- including the consequence that a page-level read collapses per-instance state below it. Covers the plain-helper case and `prefix=`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- docs/wrapping-react/overview.md | 56 +++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/docs/wrapping-react/overview.md b/docs/wrapping-react/overview.md index 6bbcad55118..5eb897e394f 100644 --- a/docs/wrapping-react/overview.md +++ b/docs/wrapping-react/overview.md @@ -121,6 +121,58 @@ def index(): ) ``` +## Naming And Scoping Client State + +Whether a client state var is shared app-wide follows from whether you name it: + +```python +shared = rx.client_state( + "", name="search_query" +) # global: any component, and the backend +private = rx.client_state("") # scoped to the component tree using it +``` + +A **named** var resolves in one app-wide store, so any component can read and write it and +`push`, `retrieve`, `global_value` and `global_set` all work against it. + +An **unnamed** var gets a compile-time name and is scoped: the first component in a tree to +use it claims it for that tree, so each instance of that component gets its own state, the +way React's `useState` does. Nothing outside the tree can address it. + +Where you *construct* the var decides who shares it. Construct it inside a component and +each instance gets its own: + +```python +@rx.memo +def copy_button(text: rx.Var[str]) -> rx.Component: + copied = rx.client_state(False) # one per rendered button + ... +``` + +Construct it once at module level and reference it from several components and they share +it, owned by the outermost one that uses it — the same way lifting state up works in React. +Note the consequence: adding a read at page level makes the page the owner, which collapses +per-instance state below it into one shared value. + +A plain helper function called several times gives each call its own state for free, since +the var is constructed once per call: + +```python +def counter(): + count = rx.client_state(0) # a distinct var per call + return rx.hstack( + rx.button("-", on_click=count.set(lambda v: v - 1)), + rx.heading(count.value), + rx.button("+", on_click=count.set(lambda v: v + 1)), + ) + + +rx.vstack(counter(), counter(), counter()) # three independent counters +``` + +Pass `prefix=` to make generated names readable in the compiled output: +`rx.client_state(0, prefix="counter")`. + ## Setting Client State From Plain JavaScript `value` and `set` are the normal way to use a client state var, but they resolve to a @@ -144,8 +196,8 @@ class MyPicker(rx.Component): Reads through `global_value` are a point-in-time snapshot with no reactivity, so prefer `value` inside components. Writes through `global_set` re-render every component -subscribed to that var, exactly like `set` does. Both require a named (non-local) -client state var, since the name is what identifies the value. +subscribed to that var, exactly like `set` does. Both require a **named** client state +var, since the name is what identifies the value. `rx.call_script` is the one place these do not work: its code is evaluated inside the Reflex runtime module, so your page's imports are not in scope there. Reach the store From 73add078d5deaf2cbed122d1290156375080791d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 13:14:25 +0000 Subject: [PATCH 08/15] feat(foreach): scope loop vars per item, fixing #3210 `rx.foreach` rendered its item and index as the `.map` callback's parameters, which went out of scope the moment anything referencing them compiled into its own function -- an `on_submit` lifted into a `useCallback`, or a subtree lifted into its own memo module. The page threw `ReferenceError: index is not defined` (#3210), and the documented workaround was a hidden form input. Each rendered item is now wrapped in a `ScopedValues` provider that publishes the item and index by name, and a loop var carries a `useScopedValue` read for them. The hook declares the same identifier the callback binds, so inside the loop body the parameter shadows it (where the parameter is the real value) and anywhere else the context read wins. For that to reach the consumers, `Foreach` stops being a snapshot *boundary* and becomes only a structural snapshot child: its subtree is user content, so it keeps memoizing and each consumer lands in its own module below the per-item provider. The subtree is walked with a memoize-only hook chain, so no page-level collector sees it -- its hooks, imports, refs and custom code still belong to the memo body that renders it, and the page stays free of the loop scope. The provider is the element the map yields, so it is what React reconciles the list by and therefore what carries the key: an explicit key on the item is lifted onto it, otherwise the index keys by position as before. An auto-memo wrapper now also inherits the key of the component it replaces, which a keyed item root would otherwise lose. `ScopedValues` opens a client state scope too, since one rendered item is one component instance. An unnamed `rx.client_state` var in a `foreach` body is therefore per item, the way `useState` would be in a React list, which closes the inline-foreach gap in client state scoping. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- docs/library/dynamic-rendering/foreach.md | 46 +++++ docs/wrapping-react/overview.md | 12 ++ .../.templates/web/utils/client_state.js | 56 ++++++ .../src/reflex_base/compiler/templates.py | 11 +- .../reflex_base/components/tags/iter_tag.py | 56 ++++-- .../reflex_components_core/core/foreach.py | 25 ++- reflex/compiler/plugins/memoize.py | 110 ++++++++++-- .../integration/tests_playwright/test_memo.py | 80 +++++++++ tests/js/client_state.test.js | 167 ++++++++++++++++++ tests/units/compiler/test_memoize_plugin.py | 120 ++++++++++++- tests/units/components/core/test_foreach.py | 76 ++++++++ 11 files changed, 722 insertions(+), 37 deletions(-) diff --git a/docs/library/dynamic-rendering/foreach.md b/docs/library/dynamic-rendering/foreach.md index 3d0252a6398..e8d39adc65d 100644 --- a/docs/library/dynamic-rendering/foreach.md +++ b/docs/library/dynamic-rendering/foreach.md @@ -217,8 +217,54 @@ def foreach_complex_dict_example(): ) ``` +## Per-Item State And Event Handlers + +Each rendered item gets its own scope, so the item and index are available +anywhere in that item's subtree -- including in event handlers and in +components the compiler splits out on its own: + +```python +class TodoState(rx.State): + items: list[str] = ["write docs", "ship it"] + + @rx.event + def done(self, item: str, index: int): ... + + +def todo_row(item: rx.Var[str], index: rx.Var[int]) -> rx.Component: + return rx.hstack( + rx.text(item), + rx.button("done", on_click=TodoState.done(item, index)), + ) + + +def todo_list(): + return rx.vstack(rx.foreach(TodoState.items, todo_row)) +``` + +Client state works the same way: an unnamed `rx.client_state` var in a +`foreach` body is per item, the way `useState` would be in a React list. + +```python +def expandable_row(item: rx.Var[str]) -> rx.Component: + expanded = rx.client_state(False) # one per rendered row + return rx.vstack( + rx.button(item, on_click=expanded.set(~expanded.value)), + rx.cond(expanded.value, rx.text(f"details for {item}")), + ) +``` + +By default each item is keyed by its position in the list. Pass `key=` on the +item to key by identity instead, which is what preserves a row's DOM state +(a typed-in value, focus, an in-flight animation) when the list is reordered: + +```python +rx.foreach(TodoState.items, lambda item: todo_row(item, key=item)) +``` + ## API Reference + ### `rx.foreach` ```python diff --git a/docs/wrapping-react/overview.md b/docs/wrapping-react/overview.md index 5eb897e394f..0f78a83483e 100644 --- a/docs/wrapping-react/overview.md +++ b/docs/wrapping-react/overview.md @@ -170,6 +170,18 @@ def counter(): rx.vstack(counter(), counter(), counter()) # three independent counters ``` +Each item rendered by `rx.foreach` is its own scope too, so an unnamed var used in a +loop body is per item: + +```python +def row(item: rx.Var[str]) -> rx.Component: + expanded = rx.client_state(False) # one per rendered row + ... + + +rx.foreach(State.items, row) +``` + Pass `prefix=` to make generated names readable in the compiled output: `rx.client_state(0, prefix="counter")`. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js index 552e8b23278..d4b0c0b5a34 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/client_state.js @@ -280,6 +280,62 @@ export const withClientStateScope = (Component) => { return Wrapped; }; +/** + * Per-render values provided down the tree, chained like the slot scopes. + * + * Distinct from the slot scopes on purpose: these are read-only values that + * change every render (a loop's item and index), so their context identity has + * to be free to change, while a slot scope must stay stable or mounted hooks + * would rebind. Null outside any provider. + */ +export const ScopedValuesContext = createContext(null); + +/** + * Provide read-only values to a subtree, keyed by name. + * + * This is how a loop hands its item and index to descendants that compile into + * their own components: they read by name from context instead of closing over + * a variable that only exists inside the loop callback. + * + * It also opens a client state scope, because it marks a component instance the + * same way a memo boundary does -- one rendered item. That makes an unnamed + * client state var used in a loop body per item, which is what a reader of the + * Python expects and what React's `useState` would do. + * @param props The component props. + * @param props.children The children to render. + * @param props.values Mapping of name to value for this subtree. + * @returns The provider element. + */ +export function ScopedValues({ children, values }) { + const parent = useContext(ScopedValuesContext); + // A fresh object each render is correct here -- the values themselves change + // per render, and nothing subscribes to them. + const chained = { parent, values }; + return createElement( + ScopedValuesContext.Provider, + { value: chained }, + createElement(ClientStateScope, null, children), + ); +} + +/** + * Read a value provided by an enclosing `ScopedValues`. + * + * Walks outward, so a nested loop's descendants can still reach the outer loop's + * values. Names are generated at compile time, so nested loops never collide. + * @param name The value's name. + * @returns The value, or undefined when nothing provides it. + */ +export function useScopedValue(name) { + const scope = useContext(ScopedValuesContext); + for (let current = scope; current !== null; current = current.parent) { + if (name in current.values) { + return current.values[name]; + } + } + return undefined; +} + /** * Subscribe to a piece of client state. * @param defaultValue The initial value. diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 59520613899..e95b0cea440 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -87,7 +87,16 @@ def render_iterable_tag(component: Any) -> str: children_rendered = "".join([ _RenderUtils.render(child) for child in component.get("children", []) ]) - return f"Array.prototype.map.call({component['iterable_state']} ?? [],(({component['arg_name']},{component['arg_index']})=>({children_rendered})))" + arg = component["arg_name"] + index = component["arg_index"] + # Provide the item and index to the subtree by name. Descendants that + # compile into their own components read them from context, so a hoisted + # handler or a lifted memo body no longer loses the loop scope. + values = f"{{{arg}:{arg},{index}:{index}}}" + # The provider is the element the map yields, so it carries the key. + key = component["item_key"] + wrapped = f"jsx(ScopedValues,{{key:{key},values:{values}}},{children_rendered})" + return f"Array.prototype.map.call({component['iterable_state']} ?? [],(({arg},{index})=>({wrapped})))" @staticmethod def render_match_tag(component: Any) -> str: diff --git a/packages/reflex-base/src/reflex_base/components/tags/iter_tag.py b/packages/reflex-base/src/reflex_base/components/tags/iter_tag.py index f5391905ea3..1e153069159 100644 --- a/packages/reflex-base/src/reflex_base/components/tags/iter_tag.py +++ b/packages/reflex-base/src/reflex_base/components/tags/iter_tag.py @@ -8,14 +8,56 @@ from typing import TYPE_CHECKING from reflex_base.components.tags.tag import Tag +from reflex_base.constants import Dirs +from reflex_base.utils.imports import ImportVar from reflex_base.utils.types import GenericType from reflex_base.vars import LiteralArrayVar, Var, get_unique_variable_name +from reflex_base.vars.base import LiteralVar, VarData from reflex_base.vars.sequence import _determine_value_of_array_index if TYPE_CHECKING: from reflex_base.components.component import Component +_SCOPED_VALUE_IMPORT = { + f"$/{Dirs.CLIENT_STATE_PATH}": [ImportVar(tag="useScopedValue")] +} + + +def scoped_loop_var(name: str, var_type: GenericType) -> Var: + """Build a loop var that reads its value from the enclosing scope. + + A loop var used to render as nothing but the map callback's parameter, which + broke the moment anything referencing it compiled into its own function -- an + event handler hoisted into a ``useCallback``, or a subtree lifted into its own + memo module (reflex-dev/reflex#3210). The loop now publishes the item and + index by name around each rendered item, so a consumer reads them from + context wherever the compiler puts it. + + The hook declares the *same* identifier as the map callback's parameter, on + purpose. Hooks float to the top of whichever component they land in, so in + the module that renders the loop itself the declaration sits above the + ``.map`` and would read nothing -- the parameter shadows it for everything + inside the callback, which is exactly the scope where the parameter is the + real value. Anywhere else there is no parameter, and the context read wins. + + Args: + name: The name the value is provided under. + var_type: The type of the value. + + Returns: + A Var carrying the hook that reads the value. + """ + return Var( + _js_expr=name, + _var_type=var_type, + _var_data=VarData( + hooks={f"const {name} = useScopedValue({LiteralVar.create(name)!s})": None}, + imports=_SCOPED_VALUE_IMPORT, + ), + ).guess_type() + + @dataclasses.dataclass(frozen=True) class IterTag(Tag): """An iterator tag.""" @@ -50,10 +92,7 @@ def get_index_var(self) -> Var: Returns: The index var. """ - return Var( - _js_expr=self.index_var_name, - _var_type=int, - ).guess_type() + return scoped_loop_var(self.index_var_name, int) def get_arg_var(self) -> Var: """Get the arg var for the tag (with curly braces). @@ -63,10 +102,7 @@ def get_arg_var(self) -> Var: Returns: The arg var. """ - return Var( - _js_expr=self.arg_var_name, - _var_type=self.get_iterable_var_type(), - ).guess_type() + return scoped_loop_var(self.arg_var_name, self.get_iterable_var_type()) def render_component(self) -> Component: """Render the component. @@ -110,8 +146,4 @@ def render_component(self) -> Component: msg = "The render function must return a component." raise ValueError(msg) - # Set the component key. - if component.key is None: - component.key = index - return component diff --git a/packages/reflex-components-core/src/reflex_components_core/core/foreach.py b/packages/reflex-components-core/src/reflex_components_core/core/foreach.py index 1df59feda9d..80e5e4abef7 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/foreach.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/foreach.py @@ -10,7 +10,7 @@ from reflex_base.components.component import Component, field from reflex_base.components.tags import IterTag -from reflex_base.constants import MemoizationMode +from reflex_base.constants import Dirs from reflex_base.constants.state import FIELD_MARKER from reflex_base.utils import types from reflex_base.utils.exceptions import UntypedVarError @@ -31,10 +31,16 @@ class ForeachRenderError(TypeError): class Foreach(Component): """A component that takes in an iterable and a render function and renders a list of components.""" - _memoization_mode = MemoizationMode(recursive=False) - iterable: Var[Iterable] = field(doc="The iterable to create components from.") + def add_imports(self) -> dict[str, str]: + """Import the provider each item's subtree is wrapped in. + + Returns: + The imports for the component. + """ + return {f"$/{Dirs.CLIENT_STATE_PATH}": "ScopedValues"} + render_fn: Callable = field( doc="A function from the render args to the component.", default=Fragment.create, @@ -168,12 +174,25 @@ def render(self): The dictionary for template of component. """ tag = self._render() + # The per-item provider is the element the map yields, so it is what + # React reconciles the list by and therefore what carries the key. An + # explicit key on the item is lifted up to it; otherwise the loop index + # keys by position, as it always has. + item_key = next( + ( + str(LiteralVar.create(child.key)) + for child in self.children + if isinstance(child, Component) and child.key is not None + ), + tag.index_var_name, + ) return dict( tag, iterable_state=str(tag.iterable), arg_name=tag.arg_var_name, arg_index=tag.index_var_name, + item_key=item_key, ) diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index 36aee008ae7..f671a9ed6ea 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -21,6 +21,7 @@ from __future__ import annotations import dataclasses +import functools from typing import Any from reflex_base.components.component import BaseComponent, Component @@ -35,6 +36,7 @@ from reflex_base.constants.compiler import MemoizationDisposition from reflex_base.plugins import ComponentAndChildren, PageContext from reflex_base.plugins.base import Plugin +from reflex_base.plugins.compiler import CompilerHooks from reflex.compiler.plugins.builtin import ( collect_var_app_wraps_for_component, @@ -199,6 +201,21 @@ def _should_memoize(component: Component) -> bool: return bool(component.event_triggers) +@functools.cache +def _memoize_only_hooks() -> CompilerHooks: + """Return a hook chain that runs auto-memoization and nothing else. + + Used to walk a structural snapshot child's subtree: it must keep memoizing + so descendants get their own modules, but no page-level collector may see + it -- the subtree is compiled into the snapshot's own memo body. The plugin + holds no state, so one chain is shared across compiles. + + Returns: + A single-plugin hook chain. + """ + return CompilerHooks(plugins=(MemoizeStatefulPlugin(),)) + + @dataclasses.dataclass(frozen=True, slots=True) class MemoizeStatefulPlugin(Plugin): """Auto-memoize stateful components with experimental-memo wrappers. @@ -208,18 +225,22 @@ class MemoizeStatefulPlugin(Plugin): wrappers (see ``get_memoization_strategy``): - Snapshot wrappers (``MemoizationLeaf``-style boundaries and structural - ``Foreach`` wrappers): wrapped in ``enter_component`` - and returned with empty structural children. The walker skips descent, so - hooks attached to the captured body are compiled into the memo body only. + ``Foreach`` wrappers): wrapped in ``enter_component`` and returned with + empty structural children, so hooks attached to the captured body are + compiled into the memo body only. - Passthrough wrappers are wrapped in ``leave_component`` after descendants have already compiled, so any inner memo wrappers flow into this wrapper's children. - Descendants of a snapshot boundary are never independently memoized; the + Descendants of a snapshot *boundary* are never independently memoized; the boundary owns the wrapping decision for its whole subtree. This is tracked via ``PageContext.memoize_suppressor_stack`` — a stack of component ids that pushed suppression, popped in ``leave_component`` when the matching component leaves. + + A structural snapshot child is the one case in between: its subtree is user + content, so it keeps memoizing, but under a memoize-only hook chain that no + page-level collector sees (``_memoize_structural_child``). """ def enter_component( @@ -231,7 +252,7 @@ def enter_component( compile_context: Any, in_prop_tree: bool = False, ) -> BaseComponent | ComponentAndChildren | None: - """Memoize snapshot-boundary subtrees before descent. + """Memoize snapshot subtrees before descent. Snapshot boundaries (``MemoizationLeaf``-style, see ``is_snapshot_boundary``) stash state-referencing hooks inside @@ -243,7 +264,10 @@ def enter_component( entirely — the boundary's full snapshot lives only in the memo component definition compiled separately. - Non-boundary components are handled in ``leave_component`` so their + Structural snapshot children (``Foreach``) seal the same way, but their + subtree is memoized on the way in rather than skipped. + + Everything else is handled in ``leave_component`` so its already-compiled children flow into the wrapper. Args: @@ -262,16 +286,20 @@ def enter_component( return None if page_context.memoize_suppressor_stack: return None - strategy = get_memoization_strategy(comp) - if strategy is not MemoizationStrategy.SNAPSHOT: - return None - snapshot_boundary = is_snapshot_boundary(comp) + if not is_snapshot_boundary(comp): + if get_memoization_strategy(comp) is not MemoizationStrategy.SNAPSHOT: + return None + # A structural snapshot child (``Foreach``) also renders its whole + # subtree into its own memo body, but unlike a boundary that + # subtree is user content that must keep memoizing: a descendant + # needs its own module so its hooks land below the per-item scope + # the loop provides. Memoize it here, sealed from the page walk. + return self._memoize_structural_child(comp, page_context, compile_context) if not _should_memoize(comp): # Boundary not worth wrapping — still suppress descendants so # they don't memoize independently of the boundary's subtree. - if snapshot_boundary: - page_context.memoize_suppressor_stack.append(id(comp)) + page_context.memoize_suppressor_stack.append(id(comp)) return None wrapper = self._build_wrapper( @@ -299,7 +327,7 @@ def leave_component( compile_context: Any, in_prop_tree: bool = False, ) -> BaseComponent | ComponentAndChildren | None: - """Wrap non-boundary memoizables and pop any suppression this component pushed. + """Wrap memoizables handled after descent, and pop this component's suppression. Args: comp: The component being visited. @@ -333,8 +361,8 @@ def leave_component( comp = page_context.own(comp) comp.children = list(children) - strategy = get_memoization_strategy(comp) - if strategy is MemoizationStrategy.SNAPSHOT: + if is_snapshot_boundary(comp): + # Already handled (and sealed) in ``enter_component``. return None if not _should_memoize(comp): @@ -352,6 +380,54 @@ def leave_component( return self._build_wrapper(comp, page_context, compile_context) + def _memoize_structural_child( + self, + comp: Component, + page_context: PageContext, + compile_context: Any, + ) -> ComponentAndChildren | None: + """Memoize a structural snapshot child's subtree without exposing it. + + The subtree is walked with this plugin alone, so descendants still get + their own memo modules while the page collector never sees them — their + hooks, imports, refs and custom code belong to the memo body that + renders them, exactly as when the walker skipped the subtree outright. + + Args: + comp: The structural snapshot child. + page_context: The active page context. + compile_context: The active compile context. + + Returns: + A ``(wrapper, ())`` replacement, or ``None`` if not worth wrapping. + """ + if not _should_memoize(comp): + return None + + hooks = _memoize_only_hooks() + memoized_children = [ + hooks.compile_component( + child, + page_context=page_context, + compile_context=compile_context, + ) + for child in comp.children + ] + if any( + memoized is not original + for memoized, original in zip(memoized_children, comp.children, strict=True) + ): + comp = page_context.own(comp) + comp.children = memoized_children + + wrapper = self._build_wrapper(comp, page_context, compile_context) + if wrapper is None: + return None + # Var-declared app wraps still have to reach the page registry; the + # collector that normally surfaces them never walks this subtree. + collect_var_app_wraps_in_subtree(page_context.app_wrap_components, comp) + return (wrapper, ()) + @staticmethod def _build_wrapper( comp: Component, @@ -393,6 +469,10 @@ def _build_wrapper( compile_context.auto_memo_components[tag, definition.source_module] = definition wrapper = wrapper_factory() + # The wrapper takes the wrapped component's place in the tree, so it has + # to take its key too: the key belongs to the element the parent renders, + # and a key left behind on the memo body does nothing. + wrapper.key = comp.key # The wrapper has no structural children at the page level, but parents # walking ``_get_all_refs`` (e.g. ``Form._get_form_refs`` collecting # ref_ mappings into ``handleSubmit``) need to see refs from the diff --git a/tests/integration/tests_playwright/test_memo.py b/tests/integration/tests_playwright/test_memo.py index 9aadeda53bc..8c1d5dd624d 100644 --- a/tests/integration/tests_playwright/test_memo.py +++ b/tests/integration/tests_playwright/test_memo.py @@ -55,6 +55,10 @@ def replace_tree(self): def reverse_order(self): self.order = list(reversed(self.order)) + @rx.event + def record_submit(self, item: str, position: int): + self.last_value = f"{item}@{position}" + @rx.memo def my_memoed_component( some_value: rx.Var[str], @@ -87,6 +91,30 @@ def unwrapped_label(value: rx.Var[str]) -> rx.Component: # component that must still render and follow its prop. return rx.text(value, id="unwrapped-label") + def scoped_row(item: rx.Var[str], position: rx.Var[int]) -> rx.Component: + # No ``rx.memo``: an inline foreach body, which is where loop vars used + # to fall out of scope. Every consumer here compiles into its own + # module -- the submit handler into a ``useCallback``, the client state + # read into its own memo -- so each one only works if it can reach the + # loop item from the scope the loop provides around the item. + opened = rx.client_state(False, prefix="opened") + return rx.hstack( + rx.form( + rx.el.button("submit", type="submit"), + on_submit=lambda _form_data: MemoState.record_submit(item, position), + id=f"scoped-form-{position}", + ), + rx.el.button( + "toggle", + id=f"scoped-toggle-{position}", + on_click=opened.set(~opened.value), + ), + rx.text( + rx.cond(opened.value, f"open:{item}", f"closed:{item}"), + id=f"scoped-status-{position}", + ), + ) + def index() -> rx.Component: return rx.vstack( rx.input( @@ -112,6 +140,10 @@ def index() -> rx.Component: id="keyed-rows", ), unwrapped_label(value=MemoState.last_value), + rx.box( + rx.foreach(MemoState.order, scoped_row), + id="scoped-rows", + ), ) app = rx.App() @@ -263,3 +295,51 @@ def test_memo_wrapper_none_renders_and_updates( expect(page.locator("#unwrapped-label")).to_have_text("") page.locator("#memo-input").fill("unwrapped_update") expect(page.locator("#unwrapped-label")).to_have_text("unwrapped_update") + + +def test_foreach_item_handler_receives_its_own_loop_vars( + memo_app: AppHarness, page: Page +) -> None: + """A submit handler inside an inline foreach body sees its item and index. + + Regression for reflex-dev/reflex#3210: the handler compiles into a + ``useCallback`` that the compiler lifts out of the ``.map`` body, so the + loop vars it referenced were not in scope and the page threw + ``ReferenceError``. Submitting each row must report that row's own values. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + _load_page(page, memo_app) + + for position, item in enumerate(("row-a", "row-b", "row-c")): + page.locator(f"#scoped-form-{position} button").click() + expect(page.locator("#memo-last-value")).to_have_text(f"{item}@{position}") + + +def test_foreach_item_client_state_is_per_item( + memo_app: AppHarness, page: Page +) -> None: + """An unnamed client state var in an inline foreach body is per item. + + The var is constructed once at compile time, so all three rows resolve the + same generated name -- against the scope the loop opens around each item, + which is what makes them independent. The rendered text also interpolates + the loop item, so this covers the item reaching a memoized reader. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + _load_page(page, memo_app) + + expect(page.locator("#scoped-status-0")).to_have_text("closed:row-a") + expect(page.locator("#scoped-status-1")).to_have_text("closed:row-b") + + page.locator("#scoped-toggle-1").click() + + expect(page.locator("#scoped-status-1")).to_have_text("open:row-b") + # The other rows are untouched: each item owns its own slot. + expect(page.locator("#scoped-status-0")).to_have_text("closed:row-a") + expect(page.locator("#scoped-status-2")).to_have_text("closed:row-c") diff --git a/tests/js/client_state.test.js b/tests/js/client_state.test.js index 881dc33f359..d096579ecff 100644 --- a/tests/js/client_state.test.js +++ b/tests/js/client_state.test.js @@ -16,11 +16,13 @@ import { CLIENT_STATE_REF, ClientStateProvider, ClientStateScope, + ScopedValues, createClientStateStore, getClientState, getClientStore, setClientState, useClientState, + useScopedValue, withClientStateScope, } from "$/utils/client_state"; @@ -528,6 +530,171 @@ describe("withClientStateScope", () => { }); }); +describe("scoped values", () => { + /** Render a component that reads one scoped value by name. */ + const reader = (name) => { + const seen = []; + const Reader = () => { + seen.push(useScopedValue(name)); + return null; + }; + return { seen, element: createElement(Reader) }; + }; + + test("a descendant component reads a value it never received as a prop", () => { + // The shape a loop emits: the value lives in context, so a descendant that + // compiled into its own component can still see it. + const item = reader("item0"); + const { unmount } = mount( + createElement(ScopedValues, { values: { item0: "a" } }, item.element), + ); + + expect(item.seen.at(-1)).toBe("a"); + + unmount(); + }); + + test("a nested provider still exposes the outer values", () => { + const outer = reader("outer0"); + const inner = reader("inner0"); + const { unmount } = mount( + createElement( + ScopedValues, + { values: { outer0: "out" } }, + createElement( + ScopedValues, + { values: { inner0: "in" } }, + outer.element, + inner.element, + ), + ), + ); + + expect(outer.seen.at(-1)).toBe("out"); + expect(inner.seen.at(-1)).toBe("in"); + + unmount(); + }); + + test("a nearer provider shadows the same name", () => { + const item = reader("item0"); + const { unmount } = mount( + createElement( + ScopedValues, + { values: { item0: "outer" } }, + createElement( + ScopedValues, + { values: { item0: "inner" } }, + item.element, + ), + ), + ); + + expect(item.seen.at(-1)).toBe("inner"); + + unmount(); + }); + + test("an unprovided name reads undefined rather than throwing", () => { + const item = reader("missing"); + const { unmount } = mount( + createElement(ScopedValues, { values: {} }, item.element), + ); + + expect(item.seen.at(-1)).toBeUndefined(); + + unmount(); + }); + + test("reading outside any provider is undefined", () => { + const item = reader("orphan"); + const { unmount } = mount(item.element); + + expect(item.seen.at(-1)).toBeUndefined(); + + unmount(); + }); + + test("a re-render with new values is seen by descendants", () => { + // A loop re-renders with a new item on every list change, so the provided + // value must not be frozen at first render. + const item = reader("item0"); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const render = (value) => + act(() => { + root.render( + createElement( + ScopedValues, + { values: { item0: value } }, + item.element, + ), + ); + }); + + render("first"); + expect(item.seen.at(-1)).toBe("first"); + + render("second"); + expect(item.seen.at(-1)).toBe("second"); + + act(() => root.unmount()); + container.remove(); + }); + + test("each provided subtree owns its unnamed client state", () => { + // One rendered item is one component instance, so an unnamed var used in a + // loop body must not be shared between items. + const stateProbe = () => { + const renders = { value: undefined, set: undefined }; + const Probe = () => { + const [value, set] = useClientState("", "cs0"); + renders.value = value; + renders.set = set; + return null; + }; + return { renders, element: createElement(Probe) }; + }; + const first = stateProbe(); + const second = stateProbe(); + const { unmount } = mount( + createElement( + ClientStateProvider, + { registry }, + createElement(ScopedValues, { values: { item0: "a" } }, first.element), + createElement(ScopedValues, { values: { item0: "b" } }, second.element), + ), + ); + + act(() => first.renders.set("typed into the first")); + + expect(first.renders.value).toBe("typed into the first"); + expect(second.renders.value).toBe(""); + + unmount(); + }); + + test("sibling providers give each subtree its own value", () => { + // One loop, two items: each item's subtree sees only its own value. + const first = reader("item0"); + const second = reader("item0"); + const { unmount } = mount( + createElement( + "div", + null, + createElement(ScopedValues, { values: { item0: "a" } }, first.element), + createElement(ScopedValues, { values: { item0: "b" } }, second.element), + ), + ); + + expect(first.seen.at(-1)).toBe("a"); + expect(second.seen.at(-1)).toBe("b"); + + unmount(); + }); +}); + test("CLIENT_STATE_REF matches the key state.js reads", () => { // The runtime reaches the store through the object it is handed, so the key // is duplicated on the reading side and has to stay in sync. diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 24fe0f53102..c2a9b1d1bb0 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -20,6 +20,7 @@ get_memoization_strategy, ) from reflex_base.constants.compiler import MemoizationDisposition, MemoizationMode +from reflex_base.constants.state import FIELD_MARKER from reflex_base.plugins import CompileContext, CompilerHooks, PageContext from reflex_base.utils import memo_paths from reflex_base.vars import VarData @@ -126,6 +127,16 @@ class SpecialFormMemoState(BaseState): flag: Field[bool] = field(default=True) value: Field[str] = field(default="a") + @rx.event + def record(self, index: int, form_data: dict): + """Record a submission from a loop item. + + Args: + index: The loop index the form was rendered for. + form_data: The submitted form data. + """ + self.value = f"{index}:{form_data}" + @dataclasses.dataclass(slots=True) class FakePage: @@ -437,6 +448,9 @@ def test_foreach_parent_does_not_absorb_sibling_into_snapshot() -> None: reactive content into the same wide memo body. The parent should now render on the page side, with Foreach and any reactive sibling each getting their own independent wrapper. + + The Foreach snapshot is not opaque: the walker descends into the item body, + so the item's own loop-var consumer gets a third, independent wrapper. """ ctx, _page_ctx = _compile_single_page( lambda: rx.box( @@ -455,7 +469,7 @@ def test_foreach_parent_does_not_absorb_sibling_into_snapshot() -> None: ] wrapped_types = {type(definition.component) for definition in wrapped_definitions} - assert len(wrapped_definitions) == 2 + assert len(wrapped_definitions) == 3 assert Box not in wrapped_types foreach_definition = next( @@ -468,16 +482,25 @@ def test_foreach_parent_does_not_absorb_sibling_into_snapshot() -> None: is MemoizationStrategy.SNAPSHOT ) - bare_definition = next( + bare_definitions = [ definition for definition in wrapped_definitions if isinstance(definition.component, Bare) - ) - assert ( - get_memoization_strategy(bare_definition.component) + ] + assert len(bare_definitions) == 2 + assert all( + get_memoization_strategy(definition.component) is MemoizationStrategy.PASSTHROUGH + for definition in bare_definitions ) - assert bare_definition is not foreach_definition + # One reads app state on the page side, the other reads the loop item from + # the scope the Foreach provides around each rendered item. + bare_contents = { + str(cast(Bare, definition.component).contents) + for definition in bare_definitions + } + assert any("items_rx_state_.length" in contents for contents in bare_contents) + assert any(contents == f"item{FIELD_MARKER}" for contents in bare_contents) def test_common_memoization_snapshot_helper_classifies_snapshot_cases() -> None: @@ -2474,3 +2497,88 @@ def page() -> Component: assert "withClientStateScope" not in code, ( f"auto-memo wrapper {path} must not open a client state scope" ) + + +def test_foreach_item_event_handler_reaches_the_loop_index() -> None: + """A hoisted item handler reads the loop index from the item's scope. + + Regression for reflex-dev/reflex#3210: the ``on_submit`` callback of a form + rendered inside an ``rx.foreach`` compiles into a ``useCallback`` that the + compiler lifts out of the ``.map`` body, so the callback parameter the loop + index used to render as was not in scope and the page threw + ``ReferenceError: index is not defined``. The index now renders as a + ``useScopedValue`` read inside the handler's own memo module. + """ + from reflex.compiler.compiler import compile_memo_components + + ctx, page_ctx = _compile_single_page( + lambda: rx.vstack( + rx.foreach( + Var.range(3), + lambda index: rx.form( + rx.input(name="input"), + on_submit=lambda form_data: SpecialFormMemoState.record( + index, form_data + ), + ), + ) + ) + ) + + files, _imports = compile_memo_components( + memos=tuple(ctx.auto_memo_components.values()), + ) + code = "\n".join(memo_code for _path, memo_code in files) + + handler = next( + block for block in code.split("export const ") if "handleSubmit" in block + ) + index_read = re.search(r'const (\w+) = useScopedValue\("(index\w*)"\)', handler) + assert index_read is not None, f"no scoped index read in the handler\n{handler}" + local, provided = index_read.groups() + # The handler sends the scoped read, not the map callback parameter. + assert f'["index"] : {local}' in handler + + # ... and the loop provides that exact name around each rendered item. + foreach_block = next( + block for block in code.split("export const ") if "Array.prototype.map" in block + ) + assert f"{provided}:{provided}" in foreach_block + # The read has to happen below the provider, which means in a module of its + # own: hooks are hoisted to the top of whichever component they land in, so + # a read in the module that *renders* the provider would sit above it. + assert handler is not foreach_block, "the handler must be its own memo module" + # Inside the loop body the callback parameter of the same name shadows any + # hoisted read, so inline uses see the real per-item value. + assert f"(({provided}," in foreach_block + + # The page itself stays free of the loop scope. + assert "useScopedValue" not in (page_ctx.output_code or "") + + +def test_memo_wrapper_carries_the_wrapped_component_key() -> None: + """An auto-memo wrapper takes the key of the component it replaces. + + The key belongs to the element the parent renders. Left on the memo body it + does nothing, so a keyed item inside a ``rx.foreach`` would silently fall + back to positional identity once its root became a wrapper. + """ + from reflex.compiler.compiler import compile_memo_components + + ctx, _page_ctx = _compile_single_page( + lambda: rx.box( + rx.foreach( + SpecialFormMemoState.items, + lambda item: rx.el.div( + Bare.create(SpecialFormMemoState.value), key=item + ), + ) + ) + ) + + files, _imports = compile_memo_components( + memos=tuple(ctx.auto_memo_components.values()), + ) + code = "\n".join(memo_code for _path, memo_code in files) + + assert f"jsx(ScopedValues,{{key:item{FIELD_MARKER}," in code diff --git a/tests/units/components/core/test_foreach.py b/tests/units/components/core/test_foreach.py index cec2db1c95a..aecc3a04aff 100644 --- a/tests/units/components/core/test_foreach.py +++ b/tests/units/components/core/test_foreach.py @@ -325,3 +325,79 @@ def test_optional_list(): ForEachState.optional_dict_value, lambda color: text(color[0], color[1]), ) + + +def test_foreach_wraps_each_item_in_a_scope_provider(): + """Each rendered item is wrapped in ``ScopedValues``, keyed by index. + + The provider is the element the ``.map`` yields, so it -- not the item root + -- carries the React key, and it publishes the item and index by name for + descendants that compile into their own components. + """ + component = foreach(ForEachState.colors_list, lambda color: text(color)) + rendered = str(component) + + arg_name = f"color{FIELD_MARKER}" + assert "jsx(ScopedValues,{key:index_" in rendered + assert f"values:{{{arg_name}:{arg_name},index_" in rendered + # The provider sits between the map callback and the item's subtree. + assert rendered.index("jsx(ScopedValues,") < rendered.index("RadixThemesText") + + +def test_foreach_loop_vars_read_from_the_enclosing_scope(): + """Loop vars render as a context read, not as the map callback parameter. + + Regression for reflex-dev/reflex#3210: a loop var used to compile to the + ``.map`` callback's parameter name, which went out of scope the moment + anything referencing it was hoisted into its own function -- a + ``useCallback``'d event handler, or a subtree lifted into its own memo + module. Reading by name from context works wherever the compiler puts the + consumer. + """ + tag = foreach( + ForEachState.colors_list, lambda color, index: text(color, index) + )._render() + + arg_var = tag.get_arg_var() + index_var = tag.get_index_var() + + # The hook declares the same identifier the map callback binds, so the + # parameter shadows it inside the loop body and the context read applies + # everywhere else. + for var, name in ( + (arg_var, f"color{FIELD_MARKER}"), + (index_var, f"index{FIELD_MARKER}"), + ): + assert str(var) == name + var_data = var._get_all_var_data() + assert var_data is not None + assert list(var_data.hooks) == [f'const {name} = useScopedValue("{name}")'] + assert dict(var_data.imports).keys() == {"$/utils/client_state"} + + +@pytest.mark.parametrize( + ("key", "expected"), + [ + (lambda color: color, f"color{FIELD_MARKER}"), + (lambda _color: "literal", '"literal"'), + (lambda _color: 7, "7"), + ], + ids=["var", "string", "int"], +) +def test_foreach_lifts_an_explicit_item_key_to_the_provider(key, expected): + """An explicit ``key`` on the item becomes the provider's key. + + The provider is what React reconciles the list by, so a key left on the + item root would give the list positional identity and the explicit key + would do nothing. It is rendered as a JS value, not pasted in as source: a + plain string key emitted bare would be a reference to an undefined name. + + Args: + key: Builds the key to pass, from the loop var. + expected: The JS the provider's key must render as. + """ + component = foreach( + ForEachState.colors_list, lambda color: text(color, key=key(color)) + ) + + assert f"jsx(ScopedValues,{{key:{expected}," in str(component) From fca7ce7e9c19f45cf17c9ae483eaeb3a1b5e9725 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 20:49:48 +0000 Subject: [PATCH 09/15] fix(client_state): carry a Var default's hooks and imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rx.client_state(initial_value)` where the default is a Var -- the obvious way to seed per-item state from a loop index -- emitted `useClientState(ix_rx_state_, "cs3")` in every consumer module with nothing declaring `ix_rx_state_` and no `useScopedValue` import, so each item seeded from `undefined`. `ClientStateVar.create` read `default_var._var_data`, the var's own field. A derived or cast default keeps its hooks and imports on the var it wraps, reachable only through `_get_all_var_data()` -- a loop var is `scoped_loop_var(...).guess_type()`, whose cast wrapper has no var data of its own. Not loop-specific: a state var default lost its `useContext(StateContexts…)` the same way. Ordering holds by construction -- `VarData.merge` builds hooks in argument order and the pair travels inside one `VarData`, so the declaration cannot land after the line that reads it. The default is a seed, read once when the scope claims the name, so it does not track the var afterwards. Documented, along with reading an enclosing loop's item from a nested body, which works as long as the inner loop does not reuse the name -- the rule Python already imposes by shadowing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- docs/library/dynamic-rendering/foreach.md | 32 ++++- docs/wrapping-react/overview.md | 9 +- .../src/reflex_base/client_state.py | 6 +- .../integration/tests_playwright/test_memo.py | 109 ++++++++++++++++++ tests/units/compiler/test_memoize_plugin.py | 44 +++++++ tests/units/reflex_base/test_client_state.py | 55 +++++++++ 6 files changed, 251 insertions(+), 4 deletions(-) diff --git a/docs/library/dynamic-rendering/foreach.md b/docs/library/dynamic-rendering/foreach.md index e8d39adc65d..6e28f7bd830 100644 --- a/docs/library/dynamic-rendering/foreach.md +++ b/docs/library/dynamic-rendering/foreach.md @@ -249,11 +249,41 @@ Client state works the same way: an unnamed `rx.client_state` var in a def expandable_row(item: rx.Var[str]) -> rx.Component: expanded = rx.client_state(False) # one per rendered row return rx.vstack( - rx.button(item, on_click=expanded.set(~expanded.value)), + rx.button(item, on_click=expanded.set(lambda prev: ~prev)), rx.cond(expanded.value, rx.text(f"details for {item}")), ) ``` +The default can be the loop item or index, which seeds each row from its own +value: + +```python +def counter_row(item: rx.Var[str], index: rx.Var[int]) -> rx.Component: + count = rx.client_state(index) # row N starts at N + return rx.hstack( + rx.text(item), + rx.heading(count.value), + rx.button("+", on_click=count.set(lambda prev: prev + 1)), + ) +``` + +A default is a *seed*: it is read once, when the row first claims the slot, so a +later change to the var does not reset a row that has already been edited. To +push a new value in, set it explicitly -- `on_mount=count.set(index)`, or on a +`rx.fragment(key=..., on_mount=...)` when you want the reset keyed to something. + +Loops nest, and each level gets its own scope. A nested body can read an +*enclosing* loop's item and index, as long as it does not reuse their names -- +the same rule Python already imposes, since an inner argument of the same name +shadows the outer one: + +```python +rx.foreach( + State.rows, + lambda row: rx.foreach(row, lambda cell: rx.text(f"{row[0]}/{cell}")), +) +``` + By default each item is keyed by its position in the list. Pass `key=` on the item to key by identity instead, which is what preserves a row's DOM state (a typed-in value, focus, an in-flight animation) when the list is reordered: diff --git a/docs/wrapping-react/overview.md b/docs/wrapping-react/overview.md index 0f78a83483e..3fd90248507 100644 --- a/docs/wrapping-react/overview.md +++ b/docs/wrapping-react/overview.md @@ -171,17 +171,22 @@ rx.vstack(counter(), counter(), counter()) # three independent counters ``` Each item rendered by `rx.foreach` is its own scope too, so an unnamed var used in a -loop body is per item: +loop body is per item, and can be seeded from the loop item or index: ```python -def row(item: rx.Var[str]) -> rx.Component: +def row(item: rx.Var[str], index: rx.Var[int]) -> rx.Component: expanded = rx.client_state(False) # one per rendered row + count = rx.client_state(index) # row N starts at N ... rx.foreach(State.items, row) ``` +A default is read once, when the scope first claims the name, so it seeds the state +rather than tracking the var. Set the value explicitly (`on_mount=count.set(index)`) +when you need it to follow. + Pass `prefix=` to make generated names readable in the compiled output: `rx.client_state(0, prefix="counter")`. diff --git a/packages/reflex-base/src/reflex_base/client_state.py b/packages/reflex-base/src/reflex_base/client_state.py index 6400fbcde5d..2f01d7f5cb3 100644 --- a/packages/reflex-base/src/reflex_base/client_state.py +++ b/packages/reflex-base/src/reflex_base/client_state.py @@ -347,7 +347,11 @@ def create( _is_global=is_global, _var_type=default_var._var_type, _var_data=VarData.merge( - default_var._var_data, + # ``_get_all_var_data``, not ``._var_data``: a derived or cast + # default (a loop var, a state var read) keeps its hooks and + # imports on the var it wraps, and dropping them compiles the + # default's identifier into a dangling reference. + default_var._get_all_var_data(), VarData( hooks=hooks, imports=_CLIENT_STATE_IMPORT, diff --git a/tests/integration/tests_playwright/test_memo.py b/tests/integration/tests_playwright/test_memo.py index 8c1d5dd624d..14d0a601d18 100644 --- a/tests/integration/tests_playwright/test_memo.py +++ b/tests/integration/tests_playwright/test_memo.py @@ -29,6 +29,7 @@ class TreeNode(TypedDict): class MemoState(rx.State): last_value: str = "" order: list[str] = ["row-a", "row-b", "row-c"] + grid: list[list[str]] = [["a0", "a1"], ["b0", "b1"]] tree: TreeNode = TreeNode( name="root", children=[ @@ -98,6 +99,9 @@ def scoped_row(item: rx.Var[str], position: rx.Var[int]) -> rx.Component: # read into its own memo -- so each one only works if it can reach the # loop item from the scope the loop provides around the item. opened = rx.client_state(False, prefix="opened") + # Seeded from a loop var: the default is a cast Var whose declaration + # has to travel into every module that reads the slot. + count = rx.client_state(position, prefix="seeded") return rx.hstack( rx.form( rx.el.button("submit", type="submit"), @@ -113,6 +117,41 @@ def scoped_row(item: rx.Var[str], position: rx.Var[int]) -> rx.Component: rx.cond(opened.value, f"open:{item}", f"closed:{item}"), id=f"scoped-status-{position}", ), + rx.text(count.value, id=f"scoped-count-{position}"), + rx.el.button( + "bump", + id=f"scoped-bump-{position}", + on_click=count.set(lambda prev: prev + 1), + ), + ) + + def nested_grid() -> rx.Component: + # Distinct names, so the leaf reads the outer item by walking out past + # the inner loop's provider. + return rx.box( + rx.foreach( + MemoState.grid, + lambda row: rx.foreach( + row, + lambda cell: rx.text(f"{row[0]}/{cell}", class_name="nested-cell"), + ), + ), + id="nested-grid", + ) + + def shadowed_grid() -> rx.Component: + # Both loops name their arg the same. Python shadows the outer binding + # inside the inner lambda, and the compiled output has to shadow it the + # same way: each level renders its own value. + return rx.box( + rx.foreach( + MemoState.grid, + lambda v: rx.box( + rx.text(v[0], class_name="shadowed-head"), + rx.foreach(v, lambda v: rx.text(v, class_name="shadowed-cell")), + ), + ), + id="shadowed-grid", ) def index() -> rx.Component: @@ -144,6 +183,8 @@ def index() -> rx.Component: rx.foreach(MemoState.order, scoped_row), id="scoped-rows", ), + nested_grid(), + shadowed_grid(), ) app = rx.App() @@ -343,3 +384,71 @@ def test_foreach_item_client_state_is_per_item( # The other rows are untouched: each item owns its own slot. expect(page.locator("#scoped-status-0")).to_have_text("closed:row-a") expect(page.locator("#scoped-status-2")).to_have_text("closed:row-c") + + +def test_foreach_item_client_state_seeded_from_the_loop_index( + memo_app: AppHarness, page: Page +) -> None: + """A client state var defaulting to a loop var is seeded per item. + + The default is a cast ``Var`` whose ``useScopedValue`` declaration lives on + the var it wraps; dropping it compiled every consumer to + ``useClientState(, …)`` with nothing declaring ````, so each row + seeded from ``undefined``. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + _load_page(page, memo_app) + + for position in range(3): + expect(page.locator(f"#scoped-count-{position}")).to_have_text(str(position)) + + page.locator("#scoped-bump-1").click() + + expect(page.locator("#scoped-count-1")).to_have_text("2") + # Seeded per item, and independent of each other. + expect(page.locator("#scoped-count-0")).to_have_text("0") + expect(page.locator("#scoped-count-2")).to_have_text("2") + + +def test_nested_foreach_leaf_reads_both_loop_scopes( + memo_app: AppHarness, page: Page +) -> None: + """A leaf in a nested loop reaches the outer item by walking outward. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + _load_page(page, memo_app) + + cells = page.locator("#nested-grid .nested-cell") + expect(cells).to_have_count(4) + expect(cells).to_have_text(["a0/a0", "a0/a1", "b0/b0", "b0/b1"]) + + +def test_nested_foreach_with_shadowed_names_renders_each_level( + memo_app: AppHarness, page: Page +) -> None: + """Nested loops reusing one parameter name each render their own values. + + Reusing the name is only expressible in Python by shadowing the outer + binding, and the scope chain has to resolve it the same way: a read inside + the inner loop binds to the inner provider, and the outer row head -- which + sits above that provider -- keeps binding to the outer one. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + _load_page(page, memo_app) + + expect(page.locator("#shadowed-grid .shadowed-head")).to_have_text(["a0", "b0"]) + expect(page.locator("#shadowed-grid .shadowed-cell")).to_have_text([ + "a0", + "a1", + "b0", + "b1", + ]) diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index c2a9b1d1bb0..d4ce5f3ced4 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -2582,3 +2582,47 @@ def test_memo_wrapper_carries_the_wrapped_component_key() -> None: code = "\n".join(memo_code for _path, memo_code in files) assert f"jsx(ScopedValues,{{key:item{FIELD_MARKER}," in code + + +def test_client_state_seeded_from_a_loop_var_declares_it_in_every_consumer() -> None: + """A ``Var`` client state default reaches each consumer module it seeds. + + A loop var is a cast wrapper whose hooks live on the var it wraps, so the + default used to compile into a bare identifier with nothing declaring it -- + ``useClientState(ix_rx_state_, "cs0")`` above no ``useScopedValue`` line. + """ + from reflex.compiler.compiler import compile_memo_components + + def counter(initial: Any) -> Component: + count = rx.client_state(initial, prefix="seeded") + return rx.hstack( + rx.el.button("-", on_click=count.set(lambda v: v - 1)), + Bare.create(count.value), + ) + + ctx, page_ctx = _compile_single_page( + lambda: rx.box( + rx.foreach(SpecialFormMemoState.items, lambda _x, ix: counter(ix)) + ) + ) + + files, _imports = compile_memo_components( + memos=tuple(ctx.auto_memo_components.values()), + ) + consumers = [ + block + for _path, code in files + for block in code.split("export const ") + if "useClientState(" in block + ] + assert consumers, "no memo module read the client state var" + for block in consumers: + read = re.search(r'const (\w+) = useScopedValue\("(\w+)"\)', block) + assert read is not None, f"nothing declares the seed\n{block}" + local, provided = read.groups() + assert local == provided + assert f"useClientState({local}," in block + # Declared before it is read, since hooks emit in order. + assert block.index(local) < block.index("useClientState(") + + assert "useScopedValue" not in (page_ctx.output_code or "") diff --git a/tests/units/reflex_base/test_client_state.py b/tests/units/reflex_base/test_client_state.py index f125fc63ed5..8fc2e8465bf 100644 --- a/tests/units/reflex_base/test_client_state.py +++ b/tests/units/reflex_base/test_client_state.py @@ -29,6 +29,27 @@ def _hook(cs: ClientStateVar) -> str: return hooks[0] +def _hooks_ending_with_client_state(cs: ClientStateVar) -> list[str]: + """Get every hook a client state var contributes, its own hook last. + + A var whose default is itself a Var contributes that default's hooks too; + they have to be declared before the ``useClientState`` line that reads them. + + Args: + cs: The client state var. + + Returns: + The hook source lines, in emission order. + """ + var_data = cs._get_all_var_data() + assert var_data is not None + hooks = list(var_data.hooks) + assert "useClientState(" in hooks[-1], ( + f"expected the client state hook to come last, got {hooks}" + ) + return hooks + + def _app_wraps(var_data: VarData | None) -> list[tuple[int, str]]: """Summarize the app wraps a VarData carries. @@ -470,6 +491,40 @@ def test_var_default_is_used_directly() -> None: assert cs._var_type is int +def test_derived_var_default_brings_its_hook_and_import() -> None: + """A default whose var data is only reachable through the operation graph. + + ``scoped_loop_var(...).guess_type()`` returns a cast wrapper whose own + ``_var_data`` is ``None`` -- the hook and import live on the var it wraps. + Reading the field directly dropped them, compiling the default's identifier + into a dangling reference. + """ + from reflex_base.components.tags.iter_tag import scoped_loop_var + + cs = client_state(scoped_loop_var("ix_rx_state_", int), name="seeded") + + # The declaration comes first; ``useClientState`` reading it comes last. + assert _hooks_ending_with_client_state(cs) == [ + 'const ix_rx_state_ = useScopedValue("ix_rx_state_")', + 'const [seededRxClientState, setSeeded] = useClientState(ix_rx_state_, "seeded", true)', + ] + var_data = cs._get_all_var_data() + assert var_data is not None + assert "useScopedValue" in str(dict(var_data.imports)) + + +def test_state_var_default_brings_its_state_wiring() -> None: + """A state var default carries its own hook too, not just loop vars.""" + + class ClientStateDefaultState(rx.State): + seed: str = "from state" + + cs = client_state(ClientStateDefaultState.seed, name="seeded_from_state") + + hooks = _hooks_ending_with_client_state(cs) + assert any("useContext(StateContexts" in hook for hook in hooks[:-1]) + + def test_retrieve_with_callback_serializes_the_handler() -> None: """``retrieve(callback)`` embeds the queued-events callback in the payload.""" From 362efa43a9e200854ac80a0c5d0015fa6497ecfc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 18:07:45 +0000 Subject: [PATCH 10/15] refactor(client_state): give the registry key one definition per language The `refs` key the provider publishes its store on was spelled out three independent times in source: the `CLIENT_STATE_REF` constant in `client_state.js`, two hardcoded literals in `state.js`, and the Python expression `client_state.py` emits. A partial rename would have disconnected the backend event handlers from the mounted provider. There are now exactly two definitions, one per language, because neither can import the other's: `CLIENT_STATE_REF` in `client_state.js` and `CLIENT_STATE_REF` in `reflex_base.constants.state`. `state.js` imports the frontend constant instead of respelling it -- the cycle its old comment warned about no longer exists, since the provider takes the registry as a prop rather than importing `refs`. A vitest case asserts the two constants are equal by reading the Python source, so a rename on either side fails loudly, and a second one keeps `state.js` from regressing to a hardcoded copy. Both directions verified by mutation. Also ports the late-mount regression test from #6824 (issue #6823): a consumer mounting after a value has been pushed reads the live value rather than seeding a copy from the default and then sitting stuck when the value returns to that default. This holds by construction here -- there is one slot per name and a late consumer binds to it -- and the test fails if slot claiming re-seeds. The unit tests from that PR are not ported: they assert hook strings from the per-component `useState` design this branch replaces. Adds changelog fragments, and skips lockfiles in codespell so the new `tests/js/package-lock.json` integrity hashes do not trip it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- news/6936.bugfix.md | 1 + news/6936.feature.md | 1 + packages/reflex-base/news/6936.bugfix.md | 1 + packages/reflex-base/news/6936.feature.md | 1 + .../reflex_base/.templates/web/utils/state.js | 10 +-- .../src/reflex_base/client_state.py | 7 +- .../src/reflex_base/constants/state.py | 6 ++ .../news/6936.bugfix.md | 1 + pyi_hashes.json | 2 +- pyproject.toml | 2 +- .../tests_playwright/test_client_state.py | 90 +++++++++++++++++++ tests/js/client_state.test.js | 21 ++++- tests/js/vitest.config.js | 14 ++- 13 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 news/6936.bugfix.md create mode 100644 news/6936.feature.md create mode 100644 packages/reflex-base/news/6936.bugfix.md create mode 100644 packages/reflex-base/news/6936.feature.md create mode 100644 packages/reflex-components-core/news/6936.bugfix.md diff --git a/news/6936.bugfix.md b/news/6936.bugfix.md new file mode 100644 index 00000000000..2f32f187470 --- /dev/null +++ b/news/6936.bugfix.md @@ -0,0 +1 @@ +`rx.foreach` loop values now reach event handlers and memoized descendants. The item and index used to compile to the `.map` callback's parameters, which went out of scope as soon as anything referencing them was lifted into its own function -- an `on_submit` hoisted into a `useCallback` threw `ReferenceError: index is not defined` ([#3210](https://github.com/reflex-dev/reflex/issues/3210)). Each rendered item now publishes its value and index to its subtree, so a consumer reads them wherever the compiler places it. diff --git a/news/6936.feature.md b/news/6936.feature.md new file mode 100644 index 00000000000..75eeac374d4 --- /dev/null +++ b/news/6936.feature.md @@ -0,0 +1 @@ +`rx.client_state` is now the stable API for client-only state. Naming a var (`rx.client_state("", name="query")`) makes it global -- readable and writable from any component and from the backend via `push`/`retrieve`. Leaving it unnamed scopes it to the component tree that first uses it, so each `@rx.memo` instance and each `rx.foreach` item gets its own value, the way React's `useState` does. Writes are per-var, so setting one var no longer re-renders components subscribed only to another. `.set` accepts a value, a `FunctionVar`, or a Python lambda traced at compile time (`count.set(lambda v: v + 1)`), and replaces the old `.set_value`. The previous API stays available at `rx._x.client_state` with a deprecation warning. diff --git a/packages/reflex-base/news/6936.bugfix.md b/packages/reflex-base/news/6936.bugfix.md new file mode 100644 index 00000000000..2f32f187470 --- /dev/null +++ b/packages/reflex-base/news/6936.bugfix.md @@ -0,0 +1 @@ +`rx.foreach` loop values now reach event handlers and memoized descendants. The item and index used to compile to the `.map` callback's parameters, which went out of scope as soon as anything referencing them was lifted into its own function -- an `on_submit` hoisted into a `useCallback` threw `ReferenceError: index is not defined` ([#3210](https://github.com/reflex-dev/reflex/issues/3210)). Each rendered item now publishes its value and index to its subtree, so a consumer reads them wherever the compiler places it. diff --git a/packages/reflex-base/news/6936.feature.md b/packages/reflex-base/news/6936.feature.md new file mode 100644 index 00000000000..75eeac374d4 --- /dev/null +++ b/packages/reflex-base/news/6936.feature.md @@ -0,0 +1 @@ +`rx.client_state` is now the stable API for client-only state. Naming a var (`rx.client_state("", name="query")`) makes it global -- readable and writable from any component and from the backend via `push`/`retrieve`. Leaving it unnamed scopes it to the component tree that first uses it, so each `@rx.memo` instance and each `rx.foreach` item gets its own value, the way React's `useState` does. Writes are per-var, so setting one var no longer re-renders components subscribed only to another. `.set` accepts a value, a `FunctionVar`, or a Python lambda traced at compile time (`count.set(lambda v: v + 1)`), and replaces the old `.set_value`. The previous API stays available at `rx._x.client_state` with a deprecation warning. 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 edd68efb261..f1fbf21a8a7 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 @@ -17,6 +17,7 @@ import { state_name, exception_state_name, } from "$/utils/context"; +import { CLIENT_STATE_REF } from "$/utils/client_state"; import debounce from "$/utils/helpers/debounce"; import throttle from "$/utils/helpers/throttle"; import { uploadFiles } from "$/utils/helpers/upload"; @@ -377,11 +378,10 @@ 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`. + // The store is reached through `refs` rather than by importing the provider: + // this runs outside the React tree, so there is no context to read. if (event.name == "_client_state_set") { - const store = refs["__client_state"]; + const store = refs[CLIENT_STATE_REF]; if (store === undefined) { console.error( `Cannot set client state "${event.payload.var_name}": no ClientStateProvider is mounted.`, @@ -393,7 +393,7 @@ export const applyEvent = async (event, socket, navigate, params) => { } if (event.name == "_client_state_get") { - const store = refs["__client_state"]; + const store = refs[CLIENT_STATE_REF]; if (store === undefined) { // Still call back, with undefined: the handler awaiting this result would // otherwise wait for a value that is never coming. diff --git a/packages/reflex-base/src/reflex_base/client_state.py b/packages/reflex-base/src/reflex_base/client_state.py index 2f01d7f5cb3..68228b64c15 100644 --- a/packages/reflex-base/src/reflex_base/client_state.py +++ b/packages/reflex-base/src/reflex_base/client_state.py @@ -5,6 +5,7 @@ import dataclasses import inspect import itertools +import json import re from collections.abc import Callable from typing import TYPE_CHECKING, Any @@ -14,6 +15,7 @@ from reflex_base.constants.state import ( CAMEL_CASE_CLIENT_STATE_MARKER, CAMEL_CASE_MEMO_MARKER, + CLIENT_STATE_REF, FIELD_MARKER, ) from reflex_base.event import ( @@ -70,10 +72,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`. +# devtools handle for inspecting client state. _client_state_store_ref = Var( - _js_expr='refs["__client_state"]', + _js_expr=f"refs[{json.dumps(CLIENT_STATE_REF)}]", _var_data=VarData( imports={f"$/{Dirs.STATE_PATH}": [ImportVar(tag="refs")]}, ), diff --git a/packages/reflex-base/src/reflex_base/constants/state.py b/packages/reflex-base/src/reflex_base/constants/state.py index b26a440aa20..36ac2ec8629 100644 --- a/packages/reflex-base/src/reflex_base/constants/state.py +++ b/packages/reflex-base/src/reflex_base/constants/state.py @@ -17,3 +17,9 @@ class StateManagerMode(str, Enum): # Suffix on the JS identifier a ClientStateVar binds its value to, so a user-chosen # name can never collide with a JS reserved word (`class`, `const`, ...). CAMEL_CASE_CLIENT_STATE_MARKER = "RxClientState" +# Key on the frontend `refs` object that the mounted ClientStateProvider +# publishes its store on. This is the one Python-side definition; the frontend's +# is `CLIENT_STATE_REF` in `.templates/web/utils/client_state.js`, which every +# other frontend reader imports. The two are asserted equal by +# `tests/js/client_state.test.js`, so a rename on either side fails loudly. +CLIENT_STATE_REF = "__client_state" diff --git a/packages/reflex-components-core/news/6936.bugfix.md b/packages/reflex-components-core/news/6936.bugfix.md new file mode 100644 index 00000000000..90e265f1266 --- /dev/null +++ b/packages/reflex-components-core/news/6936.bugfix.md @@ -0,0 +1 @@ +`rx.foreach` loop values now reach event handlers and memoized descendants. The item and index used to compile to the `.map` callback's parameters, which went out of scope as soon as anything referencing them was lifted into its own function -- an `on_submit` hoisted into a `useCallback` threw `ReferenceError: index is not defined` ([#3210](https://github.com/reflex-dev/reflex/issues/3210)). Each item's subtree is now wrapped in a provider carrying its value and index, and `Foreach` no longer seals its subtree from auto-memoization, so descendants compile into their own components. diff --git a/pyi_hashes.json b/pyi_hashes.json index 2729ac036db..32f2394e392 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -120,5 +120,5 @@ "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f170ac685b6ba5892370166c80684db3", "reflex/__init__.pyi": "6a1a667017c016e586c3af7f8486f329", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", - "reflex/experimental/memo.pyi": "bc8b48357bef580e70a5881b65d3d3f7" + "reflex/experimental/memo.pyi": "e73fd8bffa1c5bcb72478ef84534bd05" } diff --git a/pyproject.toml b/pyproject.toml index 2638de9c41b..9114061dd6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -265,7 +265,7 @@ asyncio_default_fixture_loop_scope = "function" asyncio_mode = "auto" [tool.codespell] -skip = "*.html, examples, *.pyi, */nba.csv, */olympic-winners.json, uv.lock, */bun.lock, node_modules" +skip = "*.html, examples, *.pyi, */nba.csv, */olympic-winners.json, uv.lock, */bun.lock, */package-lock.json, node_modules" ignore-words-list = "te, TreeE, selectin" diff --git a/tests/integration/tests_playwright/test_client_state.py b/tests/integration/tests_playwright/test_client_state.py index fe0b300c317..eed46ae7ddf 100644 --- a/tests/integration/tests_playwright/test_client_state.py +++ b/tests/integration/tests_playwright/test_client_state.py @@ -230,3 +230,93 @@ def test_writing_one_var_leaves_other_readers_untouched(page: Page) -> None: expect(page.locator("#shared-a")).to_have_text("churn") expect(page.locator("#other-value")).to_have_text("untouched") + + +def ClientStateLateMountApp(): + """App exercising a consumer that mounts after the value has moved.""" + import asyncio + + import reflex as rx + + flag = rx.client_state("", name="flag") + + class LateMountState(rx.State): + mounted: bool = False + + @rx.event(background=True) + async def go(self): + async with self: + self.mounted = False + yield flag.push("busy") + await asyncio.sleep(0.2) + async with self: + self.mounted = True + await asyncio.sleep(0.2) + yield flag.push("") + + def index() -> rx.Component: + return rx.el.div( + rx.input( + value=LateMountState.router.session.client_token, + read_only=True, + id="token", + ), + rx.el.button("go", on_click=LateMountState.go, id="go"), + rx.el.div(flag.value, id="always"), + rx.cond(LateMountState.mounted, rx.el.div(flag.value, id="late")), + ) + + app = rx.App() + app.add_page(index, route="/") + + +@pytest.fixture(scope="module") +def client_state_late_mount_app( + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[AppHarness, None, None]: + """Run the late-mount app. + + Args: + tmp_path_factory: Pytest fixture for creating temporary directories. + + Yields: + The running harness. + """ + with AppHarness.create( + root=tmp_path_factory.mktemp("client_state_late_mount_app"), + app_source=ClientStateLateMountApp, + ) as harness: + yield harness + + +def test_late_mounted_consumer_reads_the_current_value( + client_state_late_mount_app: AppHarness, page: Page +) -> None: + """A consumer mounting after a push reads the live value, not the default. + + Ported from reflex-dev/reflex#6824 (issue #6823). Under the old design every + consumer held its own ``useState(default)``, so one that mounted after a + push initialized to the default and then *stayed* there: pushing the value + back to the default was a no-op for its setter, leaving it permanently out + of sync. There is now one slot per name, and a late consumer binds to it + rather than seeding a copy, so this holds by construction. + + Args: + client_state_late_mount_app: Running app harness. + page: Playwright page. + """ + assert client_state_late_mount_app.frontend_url is not None + page.goto(client_state_late_mount_app.frontend_url) + expect(page.locator("#token")).not_to_have_value("") + + expect(page.locator("#always")).to_have_text("") + expect(page.locator("#late")).to_have_count(0) + + page.click("#go") + + # The late consumer appears already holding the pushed value ... + expect(page.locator("#always")).to_have_text("busy") + expect(page.locator("#late")).to_have_text("busy") + # ... and follows the push back to the default. + expect(page.locator("#always")).to_have_text("") + expect(page.locator("#late")).to_have_text("") diff --git a/tests/js/client_state.test.js b/tests/js/client_state.test.js index d096579ecff..6cb4d9f61f7 100644 --- a/tests/js/client_state.test.js +++ b/tests/js/client_state.test.js @@ -695,10 +695,23 @@ describe("scoped values", () => { }); }); -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. +test("CLIENT_STATE_REF matches the Python constant", () => { + // The key has exactly two definitions -- this one and the Python constant the + // compiler emits `refs[...]` from -- because neither language can import the + // other's. Everything else on either side references its own, so this is the + // only seam left where a rename can drift. + const constantsPy = readFileSync(__PY_CONSTANTS__, "utf8"); + const declared = constantsPy.match(/^CLIENT_STATE_REF = "(.*)"$/m); + + expect(declared, "no CLIENT_STATE_REF in constants/state.py").not.toBeNull(); + expect(declared[1]).toBe(CLIENT_STATE_REF); +}); + +test("state.js reads the store through the shared constant", () => { + // Guards a regression back to a hardcoded copy: the reader outside the React + // tree has to go through the constant, not re-spell the key. const stateJs = readFileSync(`${__WEB_ROOT__}utils/state.js`, "utf8"); - expect(stateJs).toContain(`refs["${CLIENT_STATE_REF}"]`); + expect(stateJs).toContain("refs[CLIENT_STATE_REF]"); + expect(stateJs).not.toContain(`"${CLIENT_STATE_REF}"`); }); diff --git a/tests/js/vitest.config.js b/tests/js/vitest.config.js index 0c352b92848..3c4254d6ce3 100644 --- a/tests/js/vitest.config.js +++ b/tests/js/vitest.config.js @@ -18,6 +18,15 @@ const webRoot = fileURLToPath( ), ); +// The Python side of the client-state registry key. Asserted equal to the +// frontend constant, since these are the two definitions that can drift. +const pyConstants = fileURLToPath( + new URL( + "../../packages/reflex-base/src/reflex_base/constants/state.py", + import.meta.url, + ), +); + export default defineConfig({ test: { environment: "jsdom", @@ -25,7 +34,10 @@ export default defineConfig({ }, // 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) }, + define: { + __WEB_ROOT__: JSON.stringify(webRoot), + __PY_CONSTANTS__: JSON.stringify(pyConstants), + }, resolve: { alias: [ { find: /^\$\//, replacement: webRoot }, From 70b34c5c1dad959a8c8c0b2ad1eca5ca8cb3fa5a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 18:41:27 +0000 Subject: [PATCH 11/15] perf(memo): stop analyzing one memo's params twice `create_passthrough_component_memo` analyzes the passthrough's params to evaluate a preview body for the tag, then `_create_component_definition` analyzed the same function's params again. Analyzing resolves type hints, and the compiler builds one of these per auto-memo wrapper, so the second pass was pure waste. `_create_component_definition` now takes the already-analyzed params, and the one caller that has them passes them through. Measured on the `_stateful_page` compile benchmark, which this branch regressed by widening auto-memoization into `rx.foreach` subtrees. Judged by cProfile call counts, which are exactly reproducible -- wall clock on the dev box is useless at this delta (interleaved A/B mins overlap completely): before 46,195 calls/compile get_type_hints x26 after 43,348 calls/compile get_type_hints x13 (-6.2%) `_create_component_definition` drops from 12.8% to 4.8% of compile cumtime. The generated output is byte-identical -- page plus all 13 memo modules diffed across the change. This is a partial offset, not a fix for the whole regression. Profiling puts the remaining cost in the memo tag hash: `_compute_memo_tag` is ~35% of compile cumtime, dominated by `_update_deterministic_hash` at ~1,140 recursive calls and ~3,000 hashlib updates per compile. Reusing the already-evaluated preview as the definition body was also measured (a further -3.1%) and deliberately not taken: it makes the definition body the same object `_compute_memo_tag` rendered, and `render` can mutate, so the coupling is subtle rather than absent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- .../src/reflex_base/components/memo.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index 8b0dba8cb56..37495a65e5b 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -1414,6 +1414,7 @@ def _create_component_definition( fn: Callable[..., Any], return_annotation: Any, source_module: str | None = None, + params: tuple[MemoParam, ...] | None = None, ) -> MemoComponentDefinition: """Create a definition for a component-returning memo. @@ -1421,6 +1422,10 @@ def _create_component_definition( fn: The function to analyze. return_annotation: The return annotation. source_module: The user-app Python module that defined the memo. + params: Already-analyzed parameters for ``fn``. Analyzing them resolves + type hints, which is a measurable share of compile time, so a caller + that has already done it for the same function passes them through + rather than paying twice. Returns: The component memo definition. @@ -1428,7 +1433,8 @@ def _create_component_definition( Raises: TypeError: If the function does not return a component. """ - params = _analyze_params(fn, for_component=True) + if params is None: + params = _analyze_params(fn, for_component=True) return MemoComponentDefinition( fn=fn, python_name=fn.__name__, @@ -1836,9 +1842,11 @@ def passthrough(children: Var[Component]) -> Component: return new_component # Evaluate once to compute the tag from the rendered memo body shape. - # ``_create_component_definition`` evaluates again internally; that second - # pass appends another, identical hole to ``captured_hole_child``, and the - # ``captured_hole_child[0]`` read below picks up the first. + # ``_create_component_definition`` evaluates the body again internally; that + # second pass appends another, identical hole to ``captured_hole_child``, and + # the ``captured_hole_child[0]`` read below picks up the first. The analyzed + # params are shared with it, since resolving type hints twice for one + # function is pure waste. params = _analyze_params(passthrough, for_component=True) preview = _normalize_component_return(_evaluate_memo_function(passthrough, params)) if preview is None: @@ -1853,7 +1861,9 @@ def passthrough(children: Var[Component]) -> Component: passthrough.__qualname__ = passthrough.__name__ passthrough.__module__ = __name__ - definition = _create_component_definition(passthrough, Component, source_module) + definition = _create_component_definition( + passthrough, Component, source_module, params=params + ) replacements: dict[str, Any] = {} if definition.export_name != tag: replacements["export_name"] = tag From 8a4fb6e5bcb4f3a924de109516782a7a2b7bbe5a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 19:06:08 +0000 Subject: [PATCH 12/15] test(client_state): pin down what frees a client state slot Audited the registry for retained values and added the guards. No leak found, but the invariants that make that true were untested, and two of them are easy to break. What holds a slot: a scope's `owned` map. Scopes point *up* to their parent and a parent keeps no list of children, so an unmounted `ClientStateScope` -- one `rx.foreach` item, one `@rx.memo` instance -- takes its map and every slot in it out of reach. Verified a loop adds nothing to the root scope as its list churns, so the store cannot grow with the number of items ever rendered. Root-scope slots are the deliberate exception: they outlive their consumers, because a named var is app-wide and the backend can push to it with nothing mounted -- releasing on last unmount would undo the late-mount behavior ported from #6824. Their number is fixed at compile time by the named and page-level `rx.client_state` call sites, so the retention is bounded, not unbounded. That exception makes one thing load-bearing: a listener on a root slot that outlived its component would pin that component's React internals for the life of the page. Tested by wrapping the slot's subscribe and asserting the count returns to zero on unmount. The remaining behavior is React's, and it is worth stating: keys decide what a row's state belongs to. Under positional keys -- what `rx.foreach` emits by default -- changing the list re-renders rows in place rather than unmounting them, so a row's client state stays with the position, not the item. Keyed by identity the old rows unmount and their state is released. Both directions are now tested, and the docs say so, since a loop item could not hold state before this branch. All three invariants fail under mutation: a no-op unsubscribe, a `ScopedValues` that stops opening a scope, and an item scope that resolves to the root. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- docs/library/dynamic-rendering/foreach.md | 11 +- tests/js/client_state.test.js | 139 ++++++++++++++++++++++ 2 files changed, 148 insertions(+), 2 deletions(-) diff --git a/docs/library/dynamic-rendering/foreach.md b/docs/library/dynamic-rendering/foreach.md index 6e28f7bd830..b0334dbd5a1 100644 --- a/docs/library/dynamic-rendering/foreach.md +++ b/docs/library/dynamic-rendering/foreach.md @@ -285,13 +285,20 @@ rx.foreach( ``` 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: +item to key by identity instead: ```python rx.foreach(TodoState.items, lambda item: todo_row(item, key=item)) ``` +The key decides what a row's state belongs to. Under the default positional +keys, changing the list re-renders the existing rows in place rather than +mounting new ones, so anything a row is holding -- a typed-in value, focus, an +in-flight animation, a `rx.client_state` var -- stays with the *position*. Row 3 +of the old list keeps its expanded/selected state as row 3 of the new one. Key +by identity and the old rows unmount instead, releasing their state, and a row +that reappears starts fresh. + ## API Reference diff --git a/tests/js/client_state.test.js b/tests/js/client_state.test.js index 6cb4d9f61f7..c4c7cf07fbc 100644 --- a/tests/js/client_state.test.js +++ b/tests/js/client_state.test.js @@ -39,6 +39,13 @@ const mount = (element, { strict = false } = {}) => { }); return { container, + // Re-render into the same root, so React reconciles rather than remounting + // -- which is what the tests about list churn need to observe. + rerender: (next) => { + act(() => { + root.render(strict ? createElement(StrictMode, null, next) : next); + }); + }, unmount: () => { act(() => root.unmount()); container.remove(); @@ -46,11 +53,26 @@ const mount = (element, { strict = false } = {}) => { }; }; +/** Values every rendered `ItemState` has reported, oldest first. */ +const itemValues = []; + +/** Setter belonging to the most recently rendered `ItemState`. */ +let lastItemSetter; + +/** An unnamed client state var, as a loop body would declare one. */ +const ItemState = () => { + const [value, set] = useClientState("default", "item_cs0"); + itemValues.push(value); + lastItemSetter = set; + return null; +}; + /** A stand-in for the global object the app publishes the store on. */ let registry; beforeEach(() => { registry = {}; + itemValues.length = 0; }); describe("store slots", () => { @@ -695,6 +717,123 @@ describe("scoped values", () => { }); }); +describe("slot lifetime", () => { + test("a consumer unsubscribes from its slot when it unmounts", () => { + // Root-scope slots live for the page, so a listener that outlived its + // component would pin that component's React internals forever. + const slot = getClientStore().root.own("lifetime_global", "seed"); + let live = 0; + const realSubscribe = slot.subscribe; + slot.subscribe = (onStoreChange) => { + live += 1; + const off = realSubscribe(onStoreChange); + return () => { + live -= 1; + off(); + }; + }; + + const Reader = () => { + useClientState("seed", "lifetime_global", true); + return null; + }; + const { unmount } = mount( + createElement(ClientStateProvider, { registry }, createElement(Reader)), + ); + + expect(live).toBe(1); + + unmount(); + + expect(live).toBe(0); + slot.subscribe = realSubscribe; + }); + + test("per-item state is claimed in the item's scope, never at the root", () => { + // Otherwise a loop would append a root entry per rendered item and the + // store would grow without bound as the list churned. + const root = getClientStore().root; + const before = root.owned.size; + const rows = (list) => + createElement( + ClientStateProvider, + { registry }, + ...list.map((item) => + createElement( + ScopedValues, + { key: item, values: { item0: item } }, + createElement(ItemState), + ), + ), + ); + const { rerender, unmount } = mount(rows(["a", "b", "c"])); + + expect(root.owned.size).toBe(before); + + rerender(rows(["d", "e", "f"])); + expect(root.owned.size).toBe(before); + + unmount(); + expect(root.owned.size).toBe(before); + }); + + test("an item's state is released once that item unmounts", () => { + // Keying by identity means a changed list unmounts the old rows, so their + // scopes -- and the slots those scopes own -- become unreachable. A new row + // reaching the same name has to start from the default, not inherit. + const rows = (list) => + createElement( + ClientStateProvider, + { registry }, + ...list.map((item) => + createElement( + ScopedValues, + { key: item, values: { item0: item } }, + createElement(ItemState), + ), + ), + ); + const { rerender, unmount } = mount(rows(["a", "b", "c"])); + + act(() => lastItemSetter("written")); + expect(itemValues.at(-1)).toBe("written"); + + itemValues.length = 0; + rerender(rows(["d", "e", "f"])); + + expect(itemValues).toEqual(["default", "default", "default"]); + + unmount(); + }); + + test("under positional keys an item keeps its state across a list change", () => { + // The counterpart, and the reason `key=` matters now that a loop item can + // hold state: index keys reuse the component, so nothing unmounts and the + // state stays with the position rather than the item. + const rows = (list) => + createElement( + ClientStateProvider, + { registry }, + ...list.map((item, index) => + createElement( + ScopedValues, + { key: index, values: { item0: item } }, + createElement(ItemState), + ), + ), + ); + const { rerender, unmount } = mount(rows(["a", "b", "c"])); + + act(() => lastItemSetter("written")); + itemValues.length = 0; + rerender(rows(["d", "e", "f"])); + + expect(itemValues).toEqual(["default", "default", "written"]); + + unmount(); + }); +}); + test("CLIENT_STATE_REF matches the Python constant", () => { // The key has exactly two definitions -- this one and the Python constant the // compiler emits `refs[...]` from -- because neither language can import the From f01da5060b95bc05e9bd4e5af331643f549bb0d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 19:20:23 +0000 Subject: [PATCH 13/15] test(foreach): pin what a list change does to a row's values and state A row's loop values and a row's client state behave differently when the list contents are replaced, and the difference is easy to mistake for a bug, so both are now asserted against a running app. The loop item and index update. They resolve through the scope the loop provides on every render rather than being captured at mount, so replacing ["a","b","c"] with ["d","e","f"] moves them even though the default positional key means React re-renders the existing rows instead of mounting new ones. Verified by mutation: freezing the provided values fails this test. A row's client state does not. The default seeds the slot when the row first claims it and is not re-read, and a positional key means the row never unmounts, so nothing re-claims -- `useState(props.item)` semantics. This holds for a state the row was given and for one seeded from the item, and the test asserts it deliberately: re-seeding whenever the default changed would silently throw away whatever the user had typed into the row. `key=` on the item is the way to tie state to the item instead. Keyed by identity the old rows unmount, releasing their scopes, and the new rows seed from the new items. Covered by the third test. Also considered and rejected: resetting a row's scope when its provided values change, instead of relying on keys. For a list of dicts every unrelated state update yields fresh object references, so scopes would reset continuously and all per-item state would evaporate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- .../integration/tests_playwright/test_memo.py | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/tests/integration/tests_playwright/test_memo.py b/tests/integration/tests_playwright/test_memo.py index 14d0a601d18..64a65bfadcf 100644 --- a/tests/integration/tests_playwright/test_memo.py +++ b/tests/integration/tests_playwright/test_memo.py @@ -30,6 +30,7 @@ class MemoState(rx.State): last_value: str = "" order: list[str] = ["row-a", "row-b", "row-c"] grid: list[list[str]] = [["a0", "a1"], ["b0", "b1"]] + mutable: list[str] = ["a", "b", "c"] tree: TreeNode = TreeNode( name="root", children=[ @@ -56,6 +57,10 @@ def replace_tree(self): def reverse_order(self): self.order = list(reversed(self.order)) + @rx.event + def replace_mutable(self): + self.mutable = ["d", "e", "f"] + @rx.event def record_submit(self, item: str, position: int): self.last_value = f"{item}@{position}" @@ -139,6 +144,35 @@ def nested_grid() -> rx.Component: id="nested-grid", ) + def mutable_row(item: rx.Var[str], index: rx.Var[int]) -> rx.Component: + # Three things that could go stale when the list content changes, with + # the loop keyed by position (the default -- no `key=` here). + seeded = rx.client_state(item, prefix="seeded") + fixed = rx.client_state("untouched", prefix="fixed") + return rx.hstack( + rx.text(item, id=f"mut-item-{index}"), + rx.text(index, id=f"mut-index-{index}"), + rx.text(seeded.value, id=f"mut-seeded-{index}"), + rx.text(fixed.value, id=f"mut-fixed-{index}"), + rx.el.button( + "mark", + id=f"mut-mark-{index}", + on_click=fixed.set("marked"), + ), + ) + + def mutable_list() -> rx.Component: + return rx.box(rx.foreach(MemoState.mutable, mutable_row), id="mutable-list") + + def identity_keyed_row(item: rx.Var[str], index: rx.Var[int]) -> rx.Component: + seeded = rx.client_state(item, prefix="keyedseed") + return rx.text(seeded.value, id=f"keyed-seeded-{index}", key=item) + + def keyed_list() -> rx.Component: + return rx.box( + rx.foreach(MemoState.mutable, identity_keyed_row), id="keyed-list" + ) + 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 @@ -185,6 +219,11 @@ def index() -> rx.Component: ), nested_grid(), shadowed_grid(), + rx.el.button( + "replace", id="replace-mutable", on_click=MemoState.replace_mutable + ), + mutable_list(), + keyed_list(), ) app = rx.App() @@ -452,3 +491,92 @@ def test_nested_foreach_with_shadowed_names_renders_each_level( "b0", "b1", ]) + + +def test_mutating_a_list_updates_the_rendered_loop_values( + memo_app: AppHarness, page: Page +) -> None: + """Replacing a list's contents updates what each row renders. + + The loop keys by position here (no ``key=``), so React re-renders the + existing rows rather than mounting new ones. The item and index still have + to follow the data: they resolve through the scope the loop provides on + every render, not from anything captured at mount. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + _load_page(page, memo_app) + + for index, item in enumerate(("a", "b", "c")): + expect(page.locator(f"#mut-item-{index}")).to_have_text(item) + expect(page.locator(f"#mut-index-{index}")).to_have_text(str(index)) + + page.click("#replace-mutable") + + for index, item in enumerate(("d", "e", "f")): + expect(page.locator(f"#mut-item-{index}")).to_have_text(item) + expect(page.locator(f"#mut-index-{index}")).to_have_text(str(index)) + + +def test_positionally_keyed_row_keeps_its_client_state_across_a_list_change( + memo_app: AppHarness, page: Page +) -> None: + """A row's client state belongs to the position when the loop keys by position. + + This is ``useState`` semantics: the default seeds the slot when the row + first claims it and is not re-read afterwards, and a positional key means + the row never unmounts, so nothing re-claims. Both the state a row was + given (``fixed``) and a state seeded from the item (``seeded``) therefore + survive the list being replaced. + + Asserted rather than assumed, because the alternative -- re-seeding when the + default changes -- would silently discard whatever the user had put in the + row. ``key=`` is the way to tie state to the item instead; the next test + covers that. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + _load_page(page, memo_app) + + for index, item in enumerate(("a", "b", "c")): + expect(page.locator(f"#mut-seeded-{index}")).to_have_text(item) + expect(page.locator(f"#mut-fixed-{index}")).to_have_text("untouched") + + page.click("#mut-mark-1") + expect(page.locator("#mut-fixed-1")).to_have_text("marked") + + page.click("#replace-mutable") + + # The rows now show d/e/f (previous test), but their state stayed put. + expect(page.locator("#mut-item-1")).to_have_text("e") + expect(page.locator("#mut-fixed-1")).to_have_text("marked") + for index, seed in enumerate(("a", "b", "c")): + expect(page.locator(f"#mut-seeded-{index}")).to_have_text(seed) + + +def test_identity_keyed_row_reseeds_its_client_state_from_the_new_item( + memo_app: AppHarness, page: Page +) -> None: + """``key=`` on the item ties a row's state to the item, not the position. + + Keying by identity unmounts the old rows when the list is replaced, which + releases their scopes, so the new rows claim fresh slots and a state seeded + from the item reflects the new item. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + _load_page(page, memo_app) + + for index, item in enumerate(("a", "b", "c")): + expect(page.locator(f"#keyed-seeded-{index}")).to_have_text(item) + + page.click("#replace-mutable") + + for index, item in enumerate(("d", "e", "f")): + expect(page.locator(f"#keyed-seeded-{index}")).to_have_text(item) From 5abe947406d821059868dcc449725747884635a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 20:37:44 +0000 Subject: [PATCH 14/15] docs(foreach): spell out the stale-seed caveat under positional keys `rx.foreach` keys rows by position, and that stays the default: a list of interchangeable slots wants "row 3 is expanded" to persist as data flows through, and keying by identity needs a unique key expression, which Reflex cannot guarantee for an arbitrary list. The cost of that default was under-documented. A client state default is a seed, read once when the row claims its slot, so a row seeded from its item keeps the *old* seed when the list's contents change -- the row never unmounts, so nothing re-claims. The item itself follows the data; only the seeded state lags, which makes the two easy to confuse: State.items: ["a", "b", "c"] -> ["d", "e", "f"] rx.text(item) renders d, e, f rx.client_state(item).value renders a, b, c Both docs now show that side by side and point at `key=` for tying state to the item, or an explicit `on_mount` set for tracking a var while staying editable in between. Also says when each keying choice is the right one, and that identity keys need unique keys. Behavior is unchanged: seeding once is what keeps a re-render from discarding what the user typed into a row, and both directions are already covered by integration tests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017DE4XVgDNtq2i89CMVspJ9 --- docs/library/dynamic-rendering/foreach.md | 41 ++++++++++++++++++++--- docs/wrapping-react/overview.md | 9 +++-- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/docs/library/dynamic-rendering/foreach.md b/docs/library/dynamic-rendering/foreach.md index b0334dbd5a1..a4fa3d36f18 100644 --- a/docs/library/dynamic-rendering/foreach.md +++ b/docs/library/dynamic-rendering/foreach.md @@ -267,10 +267,35 @@ def counter_row(item: rx.Var[str], index: rx.Var[int]) -> rx.Component: ) ``` -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. +A default is a *seed*, read once when the row first claims the slot. It is not a +binding: a later change to the var does not reset a row, which is what keeps a +row from losing what the user typed into it every time the list re-renders. + +That has a consequence worth knowing before you reach for it, and it is the same +one `useState(props.value)` has in React. Rows are keyed by position by default +(see below), so replacing the list's contents re-renders the existing rows +instead of mounting new ones -- and a state seeded from the item keeps the *old* +item's seed: + +```python +rx.foreach(State.items, lambda item: rx.text(rx.client_state(item).value)) +# State.items: ["a", "b", "c"] -> ["d", "e", "f"] +# rx.text(item) renders d, e, f <- follows the data +# rx.client_state(item).value renders a, b, c <- seeded once, per position +``` + +The item itself always follows the data; only the seeded state lags. Pass `key=` +on the item when you want the state to belong to the item, so replacing the list +unmounts the old rows and the new ones seed themselves: + +```python +rx.foreach(State.items, lambda item: row(item, key=item)) +``` + +If you want a row's state to track a var while staying editable in between, seed +it and then push updates explicitly with `on_mount=count.set(index)`, or on a +`rx.fragment(key=..., on_mount=...)` to tie the reset to something of your own +choosing. 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 -- @@ -299,6 +324,14 @@ of the old list keeps its expanded/selected state as row 3 of the new one. Key by identity and the old rows unmount instead, releasing their state, and a row that reappears starts fresh. +Neither is the right default for every list, which is why the choice is yours. +Positional keys suit a list whose rows are interchangeable slots, where you want +"row 3 is expanded" to persist as the data flows through. Identity keys suit a +list of distinct things each carrying its own state, where a row's state should +travel with its item across reorders and disappear with it. Identity keys need +the key expression to be unique within the list -- duplicate keys make React +reconcile the wrong rows together. + ## API Reference diff --git a/docs/wrapping-react/overview.md b/docs/wrapping-react/overview.md index 3fd90248507..21d93ca2494 100644 --- a/docs/wrapping-react/overview.md +++ b/docs/wrapping-react/overview.md @@ -184,8 +184,13 @@ 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. +rather than tracking the var. That means a row seeded from its item keeps the *old* +seed when the list's contents change, because `rx.foreach` keys rows by position and +a row that does not unmount never re-claims its slot. The item itself still follows +the data -- only the seeded state lags. Pass `key=` on the item to tie a row's state +to the item instead, or set the value explicitly (`on_mount=count.set(index)`) when +you want it to follow. See +[foreach](/docs/library/dynamic-rendering/foreach) for the worked example. Pass `prefix=` to make generated names readable in the compiled output: `rx.client_state(0, prefix="counter")`. From 5efdd80b1191496b50c04440794c904aefa4c20b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 21:55:24 +0000 Subject: [PATCH 15/15] Merge remote-tracking branch 'origin/main' into claude/clientstatevar-context-refactor-jv3pig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conflicts resolved as unions: - ``components/memo.py``: ``MemoComponentDefinition`` keeps ``is_instance_boundary`` alongside main's ``auto_memo_wrapper`` and ``display_name``; ``create_passthrough_component_memo`` keeps the pre-analyzed ``params`` pass-through together with main's replacements. - ``tests_playwright/test_memo.py``: main's ``framed`` memo page section plus the scoped/nested/mutable/keyed sections from this branch. - ``pyi_hashes.json``: regenerated. ``test_user_memo_inside_foreach_is_not_independently_memoized`` asserted a premise main's auto-memoization of stateful user memos supersedes: a user ``@rx.memo`` with a loop-var prop now gets its own wrapper module rather than being inlined into the foreach body. Retargeted to the behavior that still matters — the wrapper resolves the loop var through ``useScopedValue`` and lands beside the foreach snapshot, never on the page. --- docs/library/other/memo.md | 12 + news/+static-dynamic-route-conflict.bugfix.md | 1 + news/6593.bugfix.md | 1 + news/6945.feature.md | 1 + news/6949.performance.md | 1 + .../news/+referrer-param-env-var.feature.md | 1 + packages/reflex-base/news/6593.bugfix.md | 1 + packages/reflex-base/news/6944.bugfix.md | 1 + packages/reflex-base/news/6945.feature.md | 1 + packages/reflex-base/news/6949.performance.md | 1 + .../.templates/web/utils/react-theme.js | 1 + .../src/reflex_base/compiler/templates.py | 78 ++- .../src/reflex_base/components/component.py | 6 +- .../src/reflex_base/components/memo.py | 47 +- .../src/reflex_base/environment.py | 4 + .../src/reflex_base/event/__init__.py | 26 + .../event/processor/event_processor.py | 161 +++-- .../src/reflex_base/event/processor/future.py | 4 + .../src/reflex_base/utils/types.py | 164 ++++- .../reflex-base/src/reflex_base/vars/base.py | 4 + .../news/+badge-referrer-param.feature.md | 1 + .../reflex-components-core/pyproject.toml | 2 +- .../src/reflex_components_core/core/sticky.py | 17 +- .../news/6945.feature.md | 1 + .../src/reflex_components_plotly/plotly.py | 3 +- packages/reflex-hosting-cli/news/6866.misc.md | 2 +- .../reflex-hosting-cli/news/6918.breaking.md | 1 + .../reflex-hosting-cli/news/6918.bugfix.md | 1 + .../reflex-hosting-cli/news/6918.feature.md | 1 + packages/reflex-hosting-cli/news/6918.misc.md | 1 + .../reflex-hosting-cli/news/6948.feature.md | 1 + packages/reflex-hosting-cli/pyproject.toml | 4 - .../src/reflex_cli/constants/base.py | 15 +- .../src/reflex_cli/constants/log_level.py | 115 ++++ .../src/reflex_cli/utils/console.py | 156 ++++- .../src/reflex_cli/utils/hosting.py | 317 +++++++-- .../src/reflex_cli/utils/log.py | 131 ++++ .../src/reflex_cli/v2/apps.py | 3 +- .../src/reflex_cli/v2/auth.py | 261 +++++++ .../src/reflex_cli/v2/cli.py | 3 +- .../src/reflex_cli/v2/deployments.py | 9 + .../src/reflex_cli/v2/gcp.py | 3 +- .../src/reflex_cli/v2/project.py | 3 +- .../src/reflex_cli/v2/providers.py | 3 +- .../src/reflex_cli/v2/scan.py | 3 +- .../src/reflex_cli/v2/secrets.py | 3 +- .../src/reflex_cli/v2/vmtypes_regions.py | 3 +- packages/reflex-release/README.md | 77 ++- .../news/+dev-pin-upgrades.feature.md | 1 + .../news/+publish-skip-propagation.bugfix.md | 1 + .../reflex-release/src/reflex_release/cli.py | 14 +- .../src/reflex_release/commands.py | 183 ++++- .../src/reflex_release/devpins.py | 615 ++++++++++++++++- .../templates/workflows/dispatch_release.yml | 9 + .../templates/workflows/publish.yml | 15 +- .../workflows/release_from_changelog.yml | 36 +- pyi_hashes.json | 2 +- reflex/app.py | 23 +- reflex/compiler/compiler.py | 7 +- reflex/compiler/plugins/memoize.py | 6 + reflex/compiler/utils.py | 1 + reflex/state.py | 3 + .../integration/tests_playwright/test_memo.py | 40 ++ tests/units/compiler/test_compiler.py | 136 ++++ tests/units/compiler/test_memoize_plugin.py | 250 +++++++ tests/units/components/test_memo.py | 55 ++ .../event/processor/test_event_processor.py | 255 +++++++ tests/units/reflex_base/utils/test_types.py | 29 +- tests/units/reflex_base/vars/test_base.py | 98 ++- tests/units/reflex_cli/conftest.py | 24 + .../reflex_cli/test_min_reflex_support.py | 174 +++++ tests/units/reflex_cli/utils/test_hosting.py | 637 +++++++++++++++++- tests/units/reflex_cli/utils/test_log.py | 294 ++++++++ tests/units/reflex_cli/v2/test_auth.py | 463 +++++++++++++ .../reflex_cli/v2/test_vmtypes_regions.py | 8 - .../units/reflex_components_core/__init__.py | 0 .../reflex_components_core/core/__init__.py | 0 .../core/test_sticky.py | 39 ++ tests/units/reflex_release/test_commands.py | 165 ++++- tests/units/reflex_release/test_devpins.py | 414 +++++++++++- tests/units/reflex_release/test_scaffold.py | 105 +++ tests/units/test_route.py | 5 + tests/units/test_state.py | 83 +++ uv.lock | 2 - 84 files changed, 5584 insertions(+), 264 deletions(-) create mode 100644 news/+static-dynamic-route-conflict.bugfix.md create mode 100644 news/6593.bugfix.md create mode 100644 news/6945.feature.md create mode 100644 news/6949.performance.md create mode 100644 packages/reflex-base/news/+referrer-param-env-var.feature.md create mode 100644 packages/reflex-base/news/6593.bugfix.md create mode 100644 packages/reflex-base/news/6944.bugfix.md create mode 100644 packages/reflex-base/news/6945.feature.md create mode 100644 packages/reflex-base/news/6949.performance.md create mode 100644 packages/reflex-components-core/news/+badge-referrer-param.feature.md create mode 100644 packages/reflex-components-plotly/news/6945.feature.md create mode 100644 packages/reflex-hosting-cli/news/6918.breaking.md create mode 100644 packages/reflex-hosting-cli/news/6918.bugfix.md create mode 100644 packages/reflex-hosting-cli/news/6918.feature.md create mode 100644 packages/reflex-hosting-cli/news/6918.misc.md create mode 100644 packages/reflex-hosting-cli/news/6948.feature.md create mode 100644 packages/reflex-hosting-cli/src/reflex_cli/constants/log_level.py create mode 100644 packages/reflex-hosting-cli/src/reflex_cli/utils/log.py create mode 100644 packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py create mode 100644 packages/reflex-release/news/+dev-pin-upgrades.feature.md create mode 100644 packages/reflex-release/news/+publish-skip-propagation.bugfix.md create mode 100644 tests/units/reflex_cli/test_min_reflex_support.py create mode 100644 tests/units/reflex_cli/utils/test_log.py create mode 100644 tests/units/reflex_cli/v2/test_auth.py create mode 100644 tests/units/reflex_components_core/__init__.py create mode 100644 tests/units/reflex_components_core/core/__init__.py create mode 100644 tests/units/reflex_components_core/core/test_sticky.py diff --git a/docs/library/other/memo.md b/docs/library/other/memo.md index 25a64852571..9786d059f7d 100644 --- a/docs/library/other/memo.md +++ b/docs/library/other/memo.md @@ -68,6 +68,18 @@ def index(): ) ``` +Binding state to a prop at the call site does not pull the state into the page. +The compiler moves that call into a generated wrapper component that holds the +state hooks the prop needs, so the page itself keeps no dependency on the state. +When the state changes, the wrapper re-renders and React's `memo` stops there +unless the prop's value actually changed. The page function itself never re-runs, +so nothing in it re-renders except the components that read the changed state +themselves — each inside its own wrapper, the `rx.input` above included. + +That makes the call site the place to punch a single dependency through to an +expensive component: pass exactly the Vars it needs, and it re-renders for those +and nothing else, however much the rest of the state churns. + ## Using with `rx.foreach` To render a memoized component for each item of a list Var, wrap the call in a diff --git a/news/+static-dynamic-route-conflict.bugfix.md b/news/+static-dynamic-route-conflict.bugfix.md new file mode 100644 index 00000000000..605d8e5cbe3 --- /dev/null +++ b/news/+static-dynamic-route-conflict.bugfix.md @@ -0,0 +1 @@ +Adding a page no longer raises a spurious `RouteValueError` when a static segment lines up with another route's dynamic segment (e.g. `/posts/all/[x]` alongside `/posts/[id]`). React Router resolves such siblings in favor of the static one, so only two differently named dynamic segments at the same position conflict. The check was also order-dependent: it only tripped when the bracket-carrying route was added second. diff --git a/news/6593.bugfix.md b/news/6593.bugfix.md new file mode 100644 index 00000000000..8079c6c4e89 --- /dev/null +++ b/news/6593.bugfix.md @@ -0,0 +1 @@ +Stale `on_load` work no longer blocks or outlives a page navigation: a newer navigation for the same client now cancels the previous page's unfinished `on_load` event chain. diff --git a/news/6945.feature.md b/news/6945.feature.md new file mode 100644 index 00000000000..b7569208a1d --- /dev/null +++ b/news/6945.feature.md @@ -0,0 +1 @@ +The compiled frontend now names what React DevTools shows. Every memoized component carries a `displayName` taken from the Python component class or `@rx.memo` function it was generated from, instead of rendering as `Anonymous`; every generated context (`ColorModeContext`, `UploadFilesContext`, `DispatchContext`, `EventLoopContext`, `ThemeContext`, and one per state) is named, so the provider stack reads as `StateContext(reflex___state____state.my_state).Provider` rather than an unlabelled `Context.Provider`; each page is labelled with its route (`Component(blog/[slug])`) instead of a bare `Component`; and client-only (`NoSSRComponent`) wrappers render as `ClientSide()`. diff --git a/news/6949.performance.md b/news/6949.performance.md new file mode 100644 index 00000000000..4aadd08a79c --- /dev/null +++ b/news/6949.performance.md @@ -0,0 +1 @@ +`@rx.memo` components with props bound to state are now auto-memoized at the call site: the state hooks (and event-handler callbacks) those props need compile into a generated wrapper component instead of the page module. A state change re-renders that wrapper rather than the whole page, and React's `memo` stops there unless one of the prop values actually changed — so binding a Var at the call site scopes an expensive component to exactly the state it reads, instead of coupling it to the page. diff --git a/packages/reflex-base/news/+referrer-param-env-var.feature.md b/packages/reflex-base/news/+referrer-param-env-var.feature.md new file mode 100644 index 00000000000..cc89d0fe737 --- /dev/null +++ b/packages/reflex-base/news/+referrer-param-env-var.feature.md @@ -0,0 +1 @@ +Add the `REFLEX_REFERRER_PARAM` environment variable, read at compile time to append a `ref` query parameter to the "Built with Reflex" badge link. diff --git a/packages/reflex-base/news/6593.bugfix.md b/packages/reflex-base/news/6593.bugfix.md new file mode 100644 index 00000000000..c5cccfe8680 --- /dev/null +++ b/packages/reflex-base/news/6593.bugfix.md @@ -0,0 +1 @@ +Event handlers marked with `@rx.event(supersedes=True)` now use latest-wins semantics: enqueuing a new invocation cancels the previous unfinished event chain for the same client token. `on_load_internal` uses this to cancel stale `on_load` chains on navigation. diff --git a/packages/reflex-base/news/6944.bugfix.md b/packages/reflex-base/news/6944.bugfix.md new file mode 100644 index 00000000000..e1d389781e1 --- /dev/null +++ b/packages/reflex-base/news/6944.bugfix.md @@ -0,0 +1 @@ +Resolve `TypeAliasType` annotations (PEP 695 `type` statements and the `typing_extensions` backport) to their underlying value in `Var.guess_type`, so state vars annotated with an alias like `type Key = Literal["day", "week"]` compile instead of raising `TypeError: Unsupported type ... for guess_type`. Parameterized generic aliases (`Keys[str]` for `type Keys[T] = list[T]`) and aliases nested in unions (`Key | None`) are resolved as well. diff --git a/packages/reflex-base/news/6945.feature.md b/packages/reflex-base/news/6945.feature.md new file mode 100644 index 00000000000..b7569208a1d --- /dev/null +++ b/packages/reflex-base/news/6945.feature.md @@ -0,0 +1 @@ +The compiled frontend now names what React DevTools shows. Every memoized component carries a `displayName` taken from the Python component class or `@rx.memo` function it was generated from, instead of rendering as `Anonymous`; every generated context (`ColorModeContext`, `UploadFilesContext`, `DispatchContext`, `EventLoopContext`, `ThemeContext`, and one per state) is named, so the provider stack reads as `StateContext(reflex___state____state.my_state).Provider` rather than an unlabelled `Context.Provider`; each page is labelled with its route (`Component(blog/[slug])`) instead of a bare `Component`; and client-only (`NoSSRComponent`) wrappers render as `ClientSide()`. diff --git a/packages/reflex-base/news/6949.performance.md b/packages/reflex-base/news/6949.performance.md new file mode 100644 index 00000000000..35130a73f45 --- /dev/null +++ b/packages/reflex-base/news/6949.performance.md @@ -0,0 +1 @@ +`MemoComponent` instances no longer opt out of compiler auto-memoization wholesale. Only the passthrough wrappers the auto-memoize pass generates do, tracked by the new `auto_memo_wrapper` flag on `MemoComponentDefinition`, so state-bound props and event handlers on a `@rx.memo` call site compile their hooks into a generated wrapper instead of the enclosing page. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/react-theme.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/react-theme.js index d94717ae39e..e8d629b6117 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/react-theme.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/react-theme.js @@ -17,6 +17,7 @@ const ThemeContext = createContext({ resolvedTheme: defaultColorMode !== "system" ? defaultColorMode : "light", setTheme: () => {}, }); +ThemeContext.displayName = "ThemeContext"; export function ThemeProvider({ children, defaultTheme = "system" }) { const [theme, setTheme] = useState(defaultTheme); diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index c91915eb85e..d4d6c53eefa 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -309,6 +309,15 @@ def context_template( for state_name in initial_state ]) + # React DevTools labels a context provider from the context's + # ``displayName``; without it every state provider in the tree renders as + # ``Context.Provider``. Name each one after the Python state it carries. + state_context_display_names_str = "\n".join( + f"StateContexts.{format_state_name(state_name)}.displayName = " + f'"StateContext({state_name})";' + for state_name in initial_state + ) + state_str = ( rf""" export const state_name = "{state_name}" @@ -418,6 +427,12 @@ def context_template( export const EventLoopContext = createContext(null); export const clientStorage = {"{}" if client_storage is None else json.dumps(client_storage)} +ColorModeContext.displayName = "ColorModeContext"; +UploadFilesContext.displayName = "UploadFilesContext"; +DispatchContext.displayName = "DispatchContext"; +EventLoopContext.displayName = "EventLoopContext"; +{state_context_display_names_str} + {state_str} export const isDevMode = {json.dumps(is_dev_mode)}; @@ -454,8 +469,10 @@ def context_template( ); }} -export function ClientSide(component) {{ - return ({{ children, ...props }}) => {{ +// ``displayName`` is what React DevTools shows for the wrapper; without it +// every client-only component in the tree renders as ``Anonymous``. +export function ClientSide(component, name) {{ + function ClientSideComponent({{ children, ...props }}) {{ const [Component, setComponent] = useState(null); useEffect(() => {{ async function load() {{ @@ -465,7 +482,9 @@ def context_template( load(); }}, []); return Component ? jsx(Component, props, children) : null; - }}; + }} + ClientSideComponent.displayName = name ? `ClientSide(${{name}})` : "ClientSide"; + return ClientSideComponent; }} export function EventLoopProvider({{ children }}) {{ @@ -521,15 +540,34 @@ def page_template( custom_codes: Iterable[str], hooks: dict[str, VarData | None], render: dict[str, Any], + route: str = "", ): """Template for a single react page. + Every page compiles to a component named ``Component``, so the route is + carried in its ``displayName`` — otherwise React DevTools shows the same + ``Component`` label for whichever page is mounted. + + The function is declared, named, and only then exported. React Router's + ``decorateComponentExportsWithProps`` rewrites an exported function + *declaration* into a function *expression* wrapped in + ``UNSAFE_withComponentProps``, leaving no module-scope binding behind: a + trailing ``Component.displayName = ...`` would then throw + ``ReferenceError: Component is not defined`` when the route module loads. + Exporting the identifier instead keeps the declaration in module scope, and + the wrapper renders ``Component`` as a child, so the name still shows. + Args: imports: List of import statements. dynamic_imports: List of dynamic import statements. custom_codes: List of custom code snippets. hooks: Dictionary of hooks. render: Render function for the component. + route: The route this page is compiled for, used as its display name. + Defaults to empty, which omits the ``displayName`` assignment + entirely — ``page_template`` ships in ``reflex-base``, so an + out-of-tree caller predating the parameter keeps working and gets + the pre-existing unnamed ``Component``. Returns: Rendered React page component as string. @@ -539,19 +577,27 @@ def page_template( dynamic_imports_str = "\n".join(dynamic_imports) hooks_str = _render_hooks(hooks) + display_name_str = ( + f"Component.displayName = {json.dumps(f'Component({route})')};\n" + if route + else "" + ) return f"""{imports_str} {dynamic_imports_str} {custom_code_str} -export default function Component() {{ +function Component() {{ {hooks_str} return ( {_RenderUtils.render(render)} ) -}}""" +}} +{display_name_str} +export default Component; +""" def package_json_template( @@ -799,10 +845,16 @@ def dynamic_components_module_template( def _render_memo_component(component: dict[str, Any]) -> str: """Render the ``export const`` statement for one memoized component. + The exported symbol carries a ``displayName`` so React DevTools labels the + memo with the name of the Python component it came from. Without it, the + wrapped arrow function is anonymous and every memo in the tree shows up as + ``Anonymous``; ``memo()`` also drops the inferred name of the function it + wraps, so the assignment is needed even for readable symbols. + Args: - component: The component render dict (name, signature, render, hooks, - and the optional ``wrapper`` JS expression the function component - is wrapped in). + component: The component render dict (name, display_name, signature, + render, hooks, and the optional ``wrapper`` JS expression the + function component is wrapped in). Returns: Rendered component export as string. @@ -817,7 +869,15 @@ def _render_memo_component(component: dict[str, Any]) -> str: if wrapper and not _MEMO_WRAPPER_CALLEE_RE.fullmatch(wrapper): wrapper = f"({wrapper})" export_expr = f"{wrapper}{function_expr}" if wrapper else function_expr - return f"\nexport const {component['name']} = {export_expr};\n" + name = component["name"] + # ``display_name`` is resolved by the caller (``compile_experimental_component_memo``), + # which is the layer that knows the memo's clean export name — the JS symbol + # here carries a module hash and would make a poor label. + display_name = json.dumps(component["display_name"]) + return ( + f"\nexport const {name} = {export_expr};\n" + f"{name}.displayName = {display_name};\n" + ) def memo_components_template( diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index c94e0198c68..80345c22e59 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -8,6 +8,7 @@ import dataclasses import enum import functools +import json import logging import operator import typing @@ -2339,11 +2340,12 @@ def _get_dynamic_imports(self) -> str: if not self.is_default else ".then((mod) => mod.default.default ?? mod.default)" ) + name = self.alias or self.tag return ( - f"const {self.alias or self.tag} = ClientSide(() => " + f"const {name} = ClientSide(() => " + library_import + mod_import - + ")" + + f", {json.dumps(name)})" ) diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index 37495a65e5b..c81a1fce2a9 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -329,6 +329,15 @@ class MemoComponentDefinition(MemoDefinition): # optimizer's wrappers leave it False so they stay semantically invisible # -- notably to client state, which opens a scope per instance boundary. is_instance_boundary: bool = False + # Set for definitions the compiler's auto-memoize pass creates (see + # ``create_passthrough_component_memo``). Instances of such a definition + # are the auto-memo boundary itself, so the pass must not wrap them again. + auto_memo_wrapper: bool = False + # The name React DevTools shows for this memo. ``export_name`` (derived + # from the decorated function) is already readable for ``@rx.memo``, but + # auto-memoized wrappers carry a hash-suffixed tag, so the plugin sets this + # to the wrapped component's Python class name instead. + display_name: str | None = None @property def component(self) -> Component: @@ -341,10 +350,23 @@ def component(self) -> Component: class MemoComponent(Component): - """A rendered instance of a memo component.""" + """A rendered instance of a memo component. + + Instances take part in compiler auto-memoization like any other component. + A call site binding state Vars (or event handlers) to props *must* be + wrapped, so those hooks compile into the generated wrapper instead of the + page module: otherwise every state change re-renders the whole page, and + React's ``memo`` on this component only spares its own subtree. With the + wrapper in place, the page holds no state hook, the wrapper absorbs the + re-render, and this component re-renders only when a bound prop value + actually changes. + + Wrappers the auto-memoize pass generates are themselves ``MemoComponent`` + instances; they opt out via ``MemoizationDisposition.NEVER`` (see + :func:`_get_memo_component_class`) since they already are the boundary. + """ library = f"$/{constants.Dirs.COMPONENTS_PATH}" - _memoization_mode = MemoizationMode(disposition=MemoizationDisposition.NEVER) # The user-authored component class this wrapper stands in for. Populated # on the dynamic subclass by ``_get_memo_component_class`` so @@ -390,6 +412,7 @@ def _get_memo_component_class( export_name: str, wrapped_component_type: type[Component] = Component, source_module: str | None = None, + auto_memo_wrapper: bool = False, ) -> type[MemoComponent]: """Get the component subclass for a memo export. @@ -407,6 +430,11 @@ def _get_memo_component_class( source_module: The user-app Python module that defined this memo. When set, the wrapper imports from a path mirroring that module instead of the per-name ``utils/components/`` path. + auto_memo_wrapper: Whether the export is a wrapper generated by the + compiler's auto-memoize pass. Such wrappers already are the memo + boundary, so they opt out of being auto-memoized themselves; + user-authored ``@rx.memo`` components do not, so their stateful + props land in a generated wrapper instead of the page module. Returns: A cached component subclass with the tag set at class definition time. @@ -421,6 +449,10 @@ def _get_memo_component_class( "library": library, "_wrapped_component_type": wrapped_component_type, } + if auto_memo_wrapper: + attrs["_memoization_mode"] = MemoizationMode( + disposition=MemoizationDisposition.NEVER + ) if ( wrapped_component_type._get_app_wrap_components is not Component._get_app_wrap_components @@ -1723,6 +1755,7 @@ def __call__(self, *children: Any, **props: Any) -> MemoComponent: definition.export_name, type(component), definition.source_module, + definition.auto_memo_wrapper, )._create( children=list(children), memo_definition=definition, @@ -1864,13 +1897,17 @@ def passthrough(children: Var[Component]) -> Component: definition = _create_component_definition( passthrough, Component, source_module, params=params ) - replacements: dict[str, Any] = {} + # ``export_name`` is the content-hashed tag, which reads as noise in the + # React DevTools tree. Name the memo after the Python class it wraps. + replacements: dict[str, Any] = { + "auto_memo_wrapper": True, + "display_name": type(component).__qualname__, + } if definition.export_name != tag: replacements["export_name"] = tag if captured_hole_child: replacements["passthrough_hole_child"] = captured_hole_child[0] - if replacements: - definition = dataclasses.replace(definition, **replacements) + definition = dataclasses.replace(definition, **replacements) return _create_component_wrapper(definition), definition diff --git a/packages/reflex-base/src/reflex_base/environment.py b/packages/reflex-base/src/reflex_base/environment.py index e1556dc6a97..587bba04026 100644 --- a/packages/reflex-base/src/reflex_base/environment.py +++ b/packages/reflex-base/src/reflex_base/environment.py @@ -759,6 +759,10 @@ class EnvironmentVariables: # Extra plugins to append to the config's plugins list. REFLEX_EXTRA_PLUGINS: EnvVar[list[type[Plugin]]] = env_var([]) + # Referrer identifier appended (urlencoded) to the "Built with Reflex" + # badge link as https://reflex.dev/?ref=. Read at compile time. + REFLEX_REFERRER_PARAM: EnvVar[str | None] = env_var(None) + environment = EnvironmentVariables() diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 74fcdb9b4ad..6f0f0d9238b 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -293,6 +293,7 @@ def _scan_detach(value: Any, memo: dict[int, Any], active: set[int]) -> Any: BACKGROUND_TASK_MARKER = "_reflex_background_task" +SUPERSEDES_MARKER = "_reflex_supersedes" EVENT_ACTIONS_MARKER = "_rx_event_actions" UPLOAD_FILES_CLIENT_HANDLER = "uploadFiles" @@ -581,6 +582,21 @@ def is_background(self) -> bool: """ return getattr(self.fn, BACKGROUND_TASK_MARKER, False) + @property + def supersedes(self) -> bool: + """Whether a newer chain-root invocation supersedes an older one. + + When True, enqueuing this handler as a chain root cancels the previous + unfinished event chain rooted at the same handler for the same client + token. Cancellation is cooperative: a handler that never yields to the + event loop runs to completion, and only its not-yet-started chained + events are skipped. + + Returns: + True if the event handler is marked as superseding. + """ + return getattr(self.fn, SUPERSEDES_MARKER, False) + def __call__(self, *args: Any, **kwargs: Any) -> "EventSpec": """Pass arguments to the handler to get an event spec. @@ -2916,6 +2932,7 @@ class EventNamespace: # Constants BACKGROUND_TASK_MARKER = BACKGROUND_TASK_MARKER + SUPERSEDES_MARKER = SUPERSEDES_MARKER EVENT_ACTIONS_MARKER = EVENT_ACTIONS_MARKER _EVENT_FIELDS = _EVENT_FIELDS FORM_DATA = FORM_DATA @@ -2941,6 +2958,7 @@ def __new__( func: None = None, *, background: bool | None = None, + supersedes: bool | None = None, stop_propagation: bool | None = None, prevent_default: bool | None = None, throttle: int | None = None, @@ -2956,6 +2974,7 @@ def __new__( func: "Callable[[BASE_STATE, Unpack[P]], Any]", *, background: bool | None = None, + supersedes: bool | None = None, stop_propagation: bool | None = None, prevent_default: bool | None = None, throttle: int | None = None, @@ -2968,6 +2987,7 @@ def __new__( func: "Callable[[BASE_STATE, Unpack[P]], Any] | None" = None, *, background: bool | None = None, + supersedes: bool | None = None, stop_propagation: bool | None = None, prevent_default: bool | None = None, throttle: int | None = None, @@ -2979,6 +2999,10 @@ def __new__( Args: func: The function to wrap. background: Whether the event should be run in the background. Defaults to False. + supersedes: Whether enqueuing the event cancels the previous unfinished + chain of the same event for the same client token (latest-wins). + Cancellation is cooperative, so a handler that never yields to the + event loop is not interrupted. Defaults to False. stop_propagation: Whether to stop the event from bubbling up the DOM tree. prevent_default: Whether to prevent the default behavior of the event. throttle: Throttle the event handler to limit calls (in milliseconds). @@ -3030,6 +3054,8 @@ def wrapper( msg = "Background task must be async function or generator." raise TypeError(msg) setattr(func, BACKGROUND_TASK_MARKER, True) + if supersedes is True: + setattr(func, SUPERSEDES_MARKER, True) if getattr(func, "__name__", "").startswith("_"): msg = "Event handlers cannot be private." raise ValueError(msg) diff --git a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py index 71625ebc427..fcdc90eade5 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/event_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/event_processor.py @@ -124,6 +124,11 @@ class EventProcessor: _futures: dict[str, EventFuture] = dataclasses.field( default_factory=dict, init=False ) + # Latest-wins tracking for superseding handlers: (event name, token) -> the + # currently active chain root future. + _superseded: dict[tuple[str, str], EventFuture] = dataclasses.field( + default_factory=dict, init=False + ) _token_queues: dict[ str, collections.deque[tuple[EventQueueEntry, RegisteredEventHandler]], @@ -322,6 +327,7 @@ async def stop(self, graceful_shutdown_timeout: float | None = None) -> None: self._queue_task = None # Discard any pending per-token queue entries. self._token_queues.clear() + self._superseded.clear() # Cancel any remaining unresolved futures. for future in self._futures.values(): if not future.done(): @@ -383,6 +389,8 @@ async def enqueue( Returns: An EventFuture that resolves to the result of the associated task. + If the event was chained from an already-cancelled chain, the + returned future is already cancelled and the event is dropped. """ if ev_ctx is None: try: @@ -410,7 +418,14 @@ async def enqueue( tracked.add_done_callback(self._on_future_done) # If this context has a parent, register as a child of the parent's future. if parent_future is not None: + if parent_future.cancelled(): + # The chain this event belongs to was cancelled, so cancel the + # tracker since this event will never enter the queue. + tracked.cancel() + return tracked parent_future.add_child(tracked) + if parent_future is None: + self._supersede_previous(token=token, event=event, tracked=tracked) await queue.put(EventQueueEntry(event=event, ctx=ev_ctx)) return tracked @@ -506,14 +521,57 @@ def _try_clean_future(self, future: EventFuture) -> None: # type: ignore[overri """ if not future.done(): return + if future.cancelled() and future.txid in self._tasks: + # The cancelled handler task is still unwinding; keep the future so + # late-chained events can find their cancelled parent. Failed + # futures are not retained, so a backend exception handler task + # reusing the txid can chain recovery events normally. + return # Not checking future.all_done() to avoid waiting for grandchildren here. if not all(c.done() for c in future.children): return parent = future.parent self._futures.pop(future.txid, None) + if ( + (key := future.supersede_key) is not None + and self._superseded.get(key) is future + and future.all_done() + ): + del self._superseded[key] if parent is not None and parent.txid: self._try_clean_future(parent) + def _supersede_previous( + self, *, token: str, event: Event, tracked: EventFuture + ) -> None: + """Cancel the previous unfinished chain of a superseding event handler. + + Root handlers marked with ``supersedes`` (e.g. ``on_load_internal``) + use latest-wins semantics: enqueuing a new invocation cancels the + previous unfinished event chain for the same handler and client token. + + Args: + token: The client token associated with the event. + event: The event being enqueued. + tracked: The future of the event being enqueued. + """ + try: + registered = RegistrationContext.get().event_handlers.get(event.name) + except LookupError: + return + if registered is None or not registered.handler.supersedes: + return + key = (event.name, token) + previous = self._superseded.get(key) + if previous is not None and not previous.all_done(): + logger.debug( + f"Cancelling the previous unfinished {event.name} chain for token " + f"{token}, superseded by a newer invocation." + ) + previous.cancel() + self._superseded[key] = tracked + tracked.supersede_key = key + def _on_future_done(self, future: EventFuture) -> None: # type: ignore[override] """Callback invoked when an enqueued future completes. @@ -640,10 +698,12 @@ def _dispatch_next_for_token(self, token: str) -> None: if not token_queue: return entry, registered_handler = token_queue[0] - # Skip cancelled futures. + # Skip cancelled futures. Before a task exists, the only way a future + # can be done is cancellation, and its _try_clean_future done callback + # removes it from _futures, so a missing future also means the entry + # was cancelled. future = self._futures.get(entry.ctx.txid) - if future is not None and future.cancelled(): - self._try_clean_future(future) + if future is None or future.cancelled(): token_queue.popleft() if token_queue: self._dispatch_next_for_token(token) @@ -660,10 +720,10 @@ async def _process_queue(self): with contextlib.suppress(*_QUEUE_SHUTDOWN_ERRORS): while True: entry = await queue.get() - if ( - future := self._futures.get(entry.ctx.txid) - ) is not None and future.cancelled(): - self._try_clean_future(future) + # A missing future means the entry was cancelled and already + # cleaned up (see _dispatch_next_for_token). + future = self._futures.get(entry.ctx.txid) + if future is None or future.cancelled(): queue.task_done() continue try: @@ -721,8 +781,6 @@ def _finish_task(self, task: asyncio.Task): Args: task: The task that finished. """ - from reflex.utils import telemetry - if sys.version_info < (3, 12): # py3.11 compat task_ctx = task._event_ctx # type: ignore[attr-defined] @@ -738,40 +796,59 @@ def _finish_task(self, task: asyncio.Task): else: del self._token_queues[task_ctx.token] future = self._futures.get(task_ctx.txid) - if task.done(): - try: - result = task.result() - except asyncio.CancelledError: - if future is not None and not future.done(): - future.cancel() - except Exception as ex: - if future is not None and not future.done(): - future.set_exception(ex) - with contextlib.suppress(BaseException): - # Trigger the future to avoid warnings if the caller didn't wait. - future.result() - telemetry.send_error(ex, context="backend") - if ( - not task.get_name().startswith("reflex_backend_exception_handler|") - and self.backend_exception_handler is not None - ): - # Create a new task in the same context to invoke the exception handler. - t = self._tasks[task_ctx.txid] = asyncio.create_task( - self._handle_backend_exception(ex, ev_ctx=task_ctx), - name=f"reflex_backend_exception_handler|task=[{task.get_name()}]|{time.time()}", - ) - if sys.version_info < (3, 12): - t._event_ctx = task_ctx # pyright: ignore[reportAttributeAccessIssue] - t.add_done_callback(self._finish_task) - return - logger.exception( - rich.markup.escape( - f"Error in {task.get_name()} [txid={task_ctx.txid}]:" - ) + if task.done() and self._resolve_future(task, task_ctx, future): + return + if future is not None: + # The task is gone; clean up now in case the future resolved + # earlier (e.g. external cancellation) and cleanup was deferred. + self._try_clean_future(future) + + def _resolve_future( + self, task: asyncio.Task, task_ctx: EventContext, future: EventFuture | None + ) -> bool: + """Propagate a finished task's outcome to its tracked future. + + Args: + task: The finished task. + task_ctx: The event context the task ran in. + future: The future tracking the task, if still registered. + + Returns: + True if a backend exception handler task was spawned and now owns + the future's lifecycle, False otherwise. + """ + from reflex.utils import telemetry + + try: + result = task.result() + except asyncio.CancelledError: + if future is not None and not future.done(): + future.cancel() + except Exception as ex: + if future is not None and not future.done(): + future.set_exception(ex) + with contextlib.suppress(BaseException): + # Trigger the future to avoid warnings if the caller didn't wait. + future.result() + telemetry.send_error(ex, context="backend") + if ( + not task.get_name().startswith("reflex_backend_exception_handler|") + and self.backend_exception_handler is not None + ): + # Create a new task in the same context to invoke the exception handler. + t = self._tasks[task_ctx.txid] = asyncio.create_task( + self._handle_backend_exception(ex, ev_ctx=task_ctx), + name=f"reflex_backend_exception_handler|task=[{task.get_name()}]|{time.time()}", ) - else: - if future is not None and not future.done(): - future.set_result(result) + if sys.version_info < (3, 12): + t._event_ctx = task_ctx # pyright: ignore[reportAttributeAccessIssue] + t.add_done_callback(self._finish_task) + return True + logger.exception(f"Error in {task.get_name()} [txid={task_ctx.txid}]:") + else: + if future is not None and not future.done(): + future.set_result(result) + return False __all__ = [ diff --git a/packages/reflex-base/src/reflex_base/event/processor/future.py b/packages/reflex-base/src/reflex_base/event/processor/future.py index 01d27fbdcef..2eca7b585c4 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/future.py +++ b/packages/reflex-base/src/reflex_base/event/processor/future.py @@ -31,6 +31,10 @@ class EventFuture(asyncio.Future): default_factory=asyncio.get_running_loop, repr=False ) + # Key under which this future is registered for latest-wins supersession + # in the EventProcessor, if any. + supersede_key: tuple[str, str] | None = dataclasses.field(default=None, repr=False) + def __post_init__(self) -> None: """Call Future.__init__ for the EventFuture.""" super(EventFuture, self).__init__(loop=self.loop) diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index d3d7551080d..0fe1db9c604 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -6,6 +6,7 @@ import logging import sys import types +import typing from collections.abc import Callable, Iterable, Mapping, Sequence from enum import Enum from functools import cached_property, lru_cache @@ -36,7 +37,7 @@ from typing import get_type_hints as get_type_hints_og from typing_extensions import Self as Self -from typing_extensions import TypeAliasType +from typing_extensions import TypeAliasType, TypeVarTuple from typing_extensions import override as override from reflex_base import constants @@ -49,6 +50,27 @@ # Potential Union types for isinstance checks. UnionTypes = (Union, types.UnionType) +# Potential TypeAliasType classes for isinstance checks. On 3.12+ the native +# typing.TypeAliasType (produced by the `type` statement) and the +# typing_extensions backport are distinct classes. +TypeAliasTypes: tuple[type, ...] = ( + (TypeAliasType, typing.TypeAliasType) + if sys.version_info >= (3, 12) + else (TypeAliasType,) +) + +# Potential TypeVarTuple classes for isinstance checks (native on 3.11+, +# typing_extensions backport otherwise). +TypeVarTuples: tuple[type, ...] = ( + (TypeVarTuple, typing.TypeVarTuple) + if sys.version_info >= (3, 11) + else (TypeVarTuple,) +) + +# Potential type parameter classes for isinstance checks. The typing_extensions +# ParamSpec instantiates the native class, so it needs no separate entry. +TypeParams: tuple[type, ...] = (TypeVar, typing.ParamSpec, *TypeVarTuples) + # Union of generic types. GenericType = type | _GenericAlias @@ -351,6 +373,146 @@ def is_classvar(a_type: Any) -> bool: ) +def _match_type_args( + type_params: tuple[Any, ...], args: tuple[Any, ...] +) -> dict[Any, Any]: + """Match subscription arguments to type parameters. + + A TypeVarTuple absorbs the middle arguments (mapped to a tuple); plain + parameters before and after it match positionally from either end. + + Args: + type_params: The alias's type parameters. + args: The subscription arguments. + + Returns: + A mapping from each type parameter to its argument(s). + """ + tvt_index = next( + (i for i, p in enumerate(type_params) if isinstance(p, TypeVarTuples)), None + ) + if tvt_index is None: + return dict(zip(type_params, args, strict=False)) + n_after = len(type_params) - tvt_index - 1 + substitution: dict[Any, Any] = dict( + zip(type_params[:tvt_index], args[:tvt_index], strict=False) + ) + substitution[type_params[tvt_index]] = args[tvt_index : len(args) - n_after] + if n_after: + substitution.update(zip(type_params[-n_after:], args[-n_after:], strict=False)) + return substitution + + +def _unpacked_type_var_tuple(arg: Any) -> Any | None: + """Get the TypeVarTuple an unpacked argument (``*Ts``) refers to. + + Args: + arg: The argument to inspect. + + Returns: + The TypeVarTuple, or None if the argument does not unpack one. + """ + if isinstance(arg, TypeVarTuples): + return arg + args = get_args(arg) + return args[0] if len(args) == 1 and isinstance(args[0], TypeVarTuples) else None + + +def _substitute_type_params( + cls: GenericType, substitution: dict[Any, Any] +) -> GenericType: + """Substitute type parameters by rebuilding the type, expanding unpacked TypeVarTuples. + + Args: + cls: The type to substitute into. + substitution: Mapping from type parameter to argument(s). + + Returns: + The type with its parameters replaced. + """ + if isinstance(cls, TypeParams): + return substitution.get(cls, cls) + if not getattr(cls, "__parameters__", ()): + return cls + args: list[Any] = [] + for arg in get_args(cls): + if (tvt := _unpacked_type_var_tuple(arg)) is not None: + args.extend(substitution.get(tvt, (arg,))) + elif isinstance(arg, list): # a Callable's parameter list + args.append([_substitute_type_params(a, substitution) for a in arg]) + else: + args.append(_substitute_type_params(arg, substitution)) + if is_union(cls): + return unionize(*args) + return get_origin(cls)[tuple(args)] + + +def _apply_type_params( + value: GenericType, params: tuple[Any, ...], substitution: dict[Any, Any] +) -> GenericType: + """Replace the type parameters of a generic type with their arguments. + + Args: + value: The generic type to subscript. + params: The parameters of value, in appearance order. + substitution: Mapping from type parameter to argument(s). + + Returns: + The type with its parameters replaced. + """ + flattened: list[Any] = [] + for param in params: + if isinstance(param, TypeVarTuples): + flattened.extend(substitution.get(param, (param,))) + else: + flattened.append(substitution.get(param, param)) + try: + return value[tuple(flattened)] # pyright: ignore[reportIndexIssue] + except TypeError: + # Python 3.10 subscription predates PEP 646, and 3.11 rejects a ParamSpec + # next to an unpacked TypeVarTuple, so substitute by hand instead. + return _substitute_type_params(value, substitution) + + +def resolve_type_alias(cls: GenericType) -> GenericType: + """Resolve a TypeAliasType (PEP 695 ``type`` statement) to its underlying value. + + Handles bare aliases, subscripted generic aliases (``Keys[str]`` for + ``type Keys[T] = list[T]``, substituting the type parameters into the + alias value), and aliases appearing as members of a union. + + Args: + cls: The type to resolve. + + Returns: + The resolved type, or the original type if it contains no alias. + """ + origin = get_origin(cls) + # The subscripted case is checked first: on Python 3.10 ``types.GenericAlias`` + # proxies ``__class__`` to its origin, so ``Keys[str]`` passes an isinstance + # check against TypeAliasType and would lose its arguments. + if isinstance(origin, TypeAliasTypes): + value = resolve_type_alias(origin.__value__) + if params := getattr(value, "__parameters__", ()): + value = _apply_type_params( + value, + params, + _match_type_args(origin.__type_params__, get_args(cls)), + ) + return resolve_type_alias(value) + if isinstance(cls, TypeAliasTypes): + return resolve_type_alias(cls.__value__) + if is_union(cls): + args = get_args(cls) + resolved_args = tuple(resolve_type_alias(arg) for arg in args) + if any( + resolved is not arg + for resolved, arg in zip(resolved_args, args, strict=True) + ): + return unionize(*resolved_args) + return cls + + def value_inside_optional(cls: GenericType) -> GenericType: """Get the value inside an Optional type or the original type. diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index b2adb434405..676bf16a8d6 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -1077,6 +1077,10 @@ def guess_type(self) -> Var: if var_type is NoReturn: return self.to(Any) + resolved_type = types.resolve_type_alias(var_type) + if resolved_type is not var_type: + return dataclasses.replace(self, _var_type=resolved_type).guess_type() + var_type = types.value_inside_optional(var_type) if var_type is Any: diff --git a/packages/reflex-components-core/news/+badge-referrer-param.feature.md b/packages/reflex-components-core/news/+badge-referrer-param.feature.md new file mode 100644 index 00000000000..6e222f7b5ee --- /dev/null +++ b/packages/reflex-components-core/news/+badge-referrer-param.feature.md @@ -0,0 +1 @@ +The "Built with Reflex" badge appends a urlencoded `ref` query parameter to its reflex.dev link when the `REFLEX_REFERRER_PARAM` environment variable is set at compile time. diff --git a/packages/reflex-components-core/pyproject.toml b/packages/reflex-components-core/pyproject.toml index 841ae536d51..aa98c9f391b 100644 --- a/packages/reflex-components-core/pyproject.toml +++ b/packages/reflex-components-core/pyproject.toml @@ -8,7 +8,7 @@ authors = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] maintainers = [{ name = "Khaleel Al-Adhami", email = "khaleel@reflex.dev" }] requires-python = ">=3.10" dependencies = [ - "reflex-base >= 0.9.7", + "reflex-base >= 0.9.8.post2.dev0", "reflex-components-lucide >= 0.9.0", "reflex-components-sonner >= 0.9.0", "python_multipart >= 0.0.21", diff --git a/packages/reflex-components-core/src/reflex_components_core/core/sticky.py b/packages/reflex-components-core/src/reflex_components_core/core/sticky.py index 3881d77e0dd..f4f7312e341 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/sticky.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/sticky.py @@ -1,6 +1,9 @@ """Components for displaying the Reflex sticky logo.""" +import urllib.parse + from reflex_base.components.component import ComponentNamespace +from reflex_base.environment import environment from reflex_base.style import Style from reflex_components_core.core.colors import color @@ -69,6 +72,18 @@ def add_style(self): }) +def _badge_href() -> str: + """Compute the badge link, appending the referrer param when set. + + Returns: + The badge destination URL. + """ + referrer = environment.REFLEX_REFERRER_PARAM.get() + if referrer: + return f"https://reflex.dev/?ref={urllib.parse.quote(referrer, safe='')}" + return "https://reflex.dev" + + class StickyBadge(A): """A badge that displays the Reflex sticky logo.""" @@ -82,7 +97,7 @@ def create(cls): return super().create( StickyLogo.create(), desktop_only(StickyLabel.create()), - href="https://reflex.dev", + href=_badge_href(), target="_blank", width="auto", padding="0.375rem", diff --git a/packages/reflex-components-plotly/news/6945.feature.md b/packages/reflex-components-plotly/news/6945.feature.md new file mode 100644 index 00000000000..6b2b5999ab2 --- /dev/null +++ b/packages/reflex-components-plotly/news/6945.feature.md @@ -0,0 +1 @@ +The generated client-only wrapper for each plotly component now carries the component's name, so React DevTools shows `ClientSide(Plot)` instead of an anonymous wrapper. diff --git a/packages/reflex-components-plotly/src/reflex_components_plotly/plotly.py b/packages/reflex-components-plotly/src/reflex_components_plotly/plotly.py index 6867e7df78c..79389b32bb9 100644 --- a/packages/reflex-components-plotly/src/reflex_components_plotly/plotly.py +++ b/packages/reflex-components-plotly/src/reflex_components_plotly/plotly.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import logging from typing import TYPE_CHECKING, Any, TypedDict, TypeVar @@ -376,7 +377,7 @@ def dynamic_plotly_import(name: str, package: str) -> str: return f""" const {name} = ClientSide(() => {library_import}{mod_import} -) +, {json.dumps(name)}) """ diff --git a/packages/reflex-hosting-cli/news/6866.misc.md b/packages/reflex-hosting-cli/news/6866.misc.md index 4b2ada8700f..0e083f973e6 100644 --- a/packages/reflex-hosting-cli/news/6866.misc.md +++ b/packages/reflex-hosting-cli/news/6866.misc.md @@ -1 +1 @@ -The hosting CLI's forked console module and `LogLevel` enum are now shims over `reflex-base` (new dependency), and its logging goes through standard python `logging`. Debug output renders purple (was blue), errors go to stderr (was stdout), and success messages are hidden at `--loglevel warning`. +The hosting CLI's logging goes through standard python `logging`. On reflex 0.9 and up it shares the `reflex-base` console and `LogLevel`; on earlier reflex, where `reflex-base` is not installed, the CLI renders the same output itself. Debug output renders purple (was blue), errors go to stderr (was stdout), and success messages are hidden at `--loglevel warning`. diff --git a/packages/reflex-hosting-cli/news/6918.breaking.md b/packages/reflex-hosting-cli/news/6918.breaking.md new file mode 100644 index 00000000000..e6aa870a4d4 --- /dev/null +++ b/packages/reflex-hosting-cli/news/6918.breaking.md @@ -0,0 +1 @@ +`REFLEX_ACCESS_TOKEN` now takes precedence over the token stored by `reflex login`. Previously the stored token won and the environment variable was consulted only when no token was stored, so exporting it to run a script against a different account had no effect on a machine that had ever logged in — silently, and with no way to tell which credential was in use. Exporting the variable is an explicit choice for that invocation; the config file is ambient state left behind by an earlier login. This changes behavior only when both are present and differ. `reflex cloud whoami` reports which source is in use. diff --git a/packages/reflex-hosting-cli/news/6918.bugfix.md b/packages/reflex-hosting-cli/news/6918.bugfix.md new file mode 100644 index 00000000000..8d47f28591a --- /dev/null +++ b/packages/reflex-hosting-cli/news/6918.bugfix.md @@ -0,0 +1 @@ +The hosting config file (`hosting_v1.json`) is now written atomically. `save_token_to_config` and `delete_token_from_config` opened it with mode `"w"`, truncating it before writing, so a failed write — a full disk, an I/O error, an interrupted process — left an empty file and destroyed the stored access token and selected project. Neither helper reports write failures to the caller (`save_token_to_config` logs a warning, `delete_token_from_config` only a debug message), so this was easy to miss. Both now serialize to a temporary file alongside the target and move it into place, leaving the existing credentials untouched when a write fails. A config that exists but cannot be read is no longer treated as empty either, so `delete_token_from_config` leaves a malformed file alone instead of replacing it; `save_token_to_config` still starts fresh from one, so a corrupt config cannot block re-authenticating. This also covers `reflex login` and `reflex logout`, which share these helpers. diff --git a/packages/reflex-hosting-cli/news/6918.feature.md b/packages/reflex-hosting-cli/news/6918.feature.md new file mode 100644 index 00000000000..bc6302fd6fd --- /dev/null +++ b/packages/reflex-hosting-cli/news/6918.feature.md @@ -0,0 +1 @@ +Added `reflex cloud whoami` and `reflex cloud token`. `whoami` reports the account, org, tier and token source that the CLI is authenticating as, resolving the token against the control plane without ever starting a browser login and without printing the token — it shows a non-reversible fingerprint instead, so two machines can be compared without anyone sharing a secret. `reflex cloud token` takes exactly one of `--print`, `--set TOKEN` or `--clear`: `--print` writes the raw token to stdout for capture (`export REFLEX_ACCESS_TOKEN=$(reflex cloud token --print)`), `--set` validates the token with the control plane before storing it and leaves the previous one in place if it is rejected, and `--clear` removes the stored token, noting when `REFLEX_ACCESS_TOKEN` remains set and will take over. diff --git a/packages/reflex-hosting-cli/news/6918.misc.md b/packages/reflex-hosting-cli/news/6918.misc.md new file mode 100644 index 00000000000..484e9ffde4c --- /dev/null +++ b/packages/reflex-hosting-cli/news/6918.misc.md @@ -0,0 +1 @@ +`reflex cloud token --set` accepts the token on stdin — pass `-`, or omit the value entirely — so live credentials need not appear in shell history or the process list. When stdin is a terminal it prompts without echoing. `reflex cloud whoami` writes its output directly rather than through the shared console, which applies rich markup and wraps to the terminal width: identifiers now print in full instead of being truncated to fit, and `--json` stays on one line so it can be piped. diff --git a/packages/reflex-hosting-cli/news/6948.feature.md b/packages/reflex-hosting-cli/news/6948.feature.md new file mode 100644 index 00000000000..b7f4da88e7a --- /dev/null +++ b/packages/reflex-hosting-cli/news/6948.feature.md @@ -0,0 +1 @@ +`reflex cloud deploy` now reports why a deploy failed instead of exiting on a status string: the recorded reason, whether the failure was in your app or on Reflex's side, and the end of the build log when that is what explains it. diff --git a/packages/reflex-hosting-cli/pyproject.toml b/packages/reflex-hosting-cli/pyproject.toml index 86d985e79f1..b354a01472a 100644 --- a/packages/reflex-hosting-cli/pyproject.toml +++ b/packages/reflex-hosting-cli/pyproject.toml @@ -18,13 +18,9 @@ dependencies = [ "httpx >=0.25.1,<1.0", "packaging >=24.2", "platformdirs >=3.10.0,<5.0", - "reflex-base >= 0.9.8.post19.dev0", "rich >=13,<16", ] -[tool.uv.sources] -reflex-base = { workspace = true } - [tool.hatch.version] source = "uv-dynamic-versioning" diff --git a/packages/reflex-hosting-cli/src/reflex_cli/constants/base.py b/packages/reflex-hosting-cli/src/reflex_cli/constants/base.py index 612e8d42589..d6f9f2b760f 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/constants/base.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/constants/base.py @@ -3,9 +3,22 @@ from __future__ import annotations from types import SimpleNamespace +from typing import TYPE_CHECKING from platformdirs import PlatformDirs -from reflex_base.constants.base import LogLevel as LogLevel + +if TYPE_CHECKING: + # The two enums are interchangeable, so the shared one is what gets + # type-checked; reflex_cli.constants.log_level is checked on its own. + from reflex_base.constants.base import LogLevel as LogLevel +else: + try: + # reflex-base only exists from reflex 0.9 on, and the hosting CLI + # supports older reflex too, so its LogLevel is shared when available + # and forked otherwise. + from reflex_base.constants.base import LogLevel as LogLevel + except ImportError: + from reflex_cli.constants.log_level import LogLevel as LogLevel class Reflex(SimpleNamespace): diff --git a/packages/reflex-hosting-cli/src/reflex_cli/constants/log_level.py b/packages/reflex-hosting-cli/src/reflex_cli/constants/log_level.py new file mode 100644 index 00000000000..559fe90f242 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/constants/log_level.py @@ -0,0 +1,115 @@ +"""The hosting CLI's own LogLevel, used when reflex-base is not installed. + +reflex-base only exists from reflex 0.9 on, but the hosting CLI supports older +reflex too. :mod:`reflex_cli.constants.base` prefers the reflex-base enum when +it is importable and falls back to this one otherwise; the two are +interchangeable, with the same members and the same string values. +""" + +from __future__ import annotations + +import logging +from enum import Enum + + +class LogLevel(str, Enum): + """The log levels.""" + + DEBUG = "debug" + DEFAULT = "default" + INFO = "info" + WARNING = "warning" + ERROR = "error" + CRITICAL = "critical" + + @classmethod + def from_string(cls, level: str | None) -> LogLevel | None: + """Convert a string to a log level. + + Args: + level: The log level as a string. + + Returns: + The log level, or None if the string names no level. + """ + if not level: + return None + try: + return cls[level.upper()] + except KeyError: + return None + + def to_logging_level(self) -> int: + """Map this level to a stdlib logging level number. + + DEFAULT acts as a threshold equivalent to INFO. + + Returns: + The stdlib logging level. + """ + return _LOGGING_LEVELS[self] + + def subprocess_level(self) -> LogLevel: + """Return the log level to hand to a subprocess. + + Returns: + This level, or WARNING when it is DEFAULT. + """ + return self if self != LogLevel.DEFAULT else LogLevel.WARNING + + # The str mixin supplies alphabetical comparisons, so all four operators + # must be overridden to compare by verbosity rank instead. + def __lt__(self, other: LogLevel) -> bool: + """Compare log levels. + + Args: + other: The other log level. + + Returns: + True if the log level is less verbose than the other log level. + """ + return _LOG_LEVEL_RANK[self] < _LOG_LEVEL_RANK[other] + + def __le__(self, other: LogLevel) -> bool: + """Compare log levels. + + Args: + other: The other log level. + + Returns: + True if the log level is less than or equal to the other log level. + """ + return _LOG_LEVEL_RANK[self] <= _LOG_LEVEL_RANK[other] + + def __gt__(self, other: LogLevel) -> bool: + """Compare log levels. + + Args: + other: The other log level. + + Returns: + True if the log level is more verbose-restrictive than the other. + """ + return _LOG_LEVEL_RANK[self] > _LOG_LEVEL_RANK[other] + + def __ge__(self, other: LogLevel) -> bool: + """Compare log levels. + + Args: + other: The other log level. + + Returns: + True if the log level is greater than or equal to the other. + """ + return _LOG_LEVEL_RANK[self] >= _LOG_LEVEL_RANK[other] + + +_LOG_LEVEL_RANK = {level: rank for rank, level in enumerate(LogLevel)} +_LOGGING_LEVELS = { + LogLevel.DEBUG: logging.DEBUG, + LogLevel.DEFAULT: logging.INFO, + LogLevel.INFO: logging.INFO, + LogLevel.WARNING: logging.WARNING, + LogLevel.ERROR: logging.ERROR, + LogLevel.CRITICAL: logging.CRITICAL, +} diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/console.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/console.py index ddea7fb8a75..b3e4e3c21f8 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/utils/console.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/console.py @@ -1,26 +1,136 @@ -"""Functions to communicate to the user via console (shared with reflex-base).""" +"""Interactive console helpers, shared with reflex-base when it is installed. + +Level-gated messages go through :mod:`logging` (see :mod:`reflex_cli.utils.log`); +what remains here are the rich features that are output rather than logging: +prompts, tables, spinners and plain prints. +""" from __future__ import annotations -from reflex_base.constants.base import LogLevel -from reflex_base.utils import log as _log -from reflex_base.utils.console import PoorProgress as PoorProgress -from reflex_base.utils.console import ask as ask -from reflex_base.utils.console import debug as debug -from reflex_base.utils.console import deprecate as deprecate -from reflex_base.utils.console import error as error -from reflex_base.utils.console import info as info -from reflex_base.utils.console import is_debug as is_debug -from reflex_base.utils.console import log as log -from reflex_base.utils.console import print as print -from reflex_base.utils.console import print_table as print_table -from reflex_base.utils.console import progress as progress -from reflex_base.utils.console import rule as rule -from reflex_base.utils.console import set_log_level as _set_log_level -from reflex_base.utils.console import status as status -from reflex_base.utils.console import success as success -from reflex_base.utils.console import timing as timing -from reflex_base.utils.console import warn as warn +from collections.abc import Sequence +from typing import overload + +from reflex_cli.constants.base import LogLevel +from reflex_cli.utils.log import HAS_REFLEX_BASE, is_json_mode +from reflex_cli.utils.log import set_log_level as _set_log_level + +if HAS_REFLEX_BASE: + from reflex_base.utils.console import ask as ask + from reflex_base.utils.console import print as print + from reflex_base.utils.console import print_table as print_table + from reflex_base.utils.console import progress as progress + from reflex_base.utils.console import rule as rule + from reflex_base.utils.console import status as status +else: + from rich.console import Console, OverflowMethod + from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn + from rich.prompt import Prompt + from rich.table import Table + + _console = Console(highlight=False) + + def print(msg: str, **kwargs): + """Print a message. + + Args: + msg: The message to print. + kwargs: Keyword arguments to pass to the print function. + """ + _console.print(msg, **kwargs) + + def print_table( + tabular_data: list[list[str]], + headers: Sequence[str] = (), + overflow: OverflowMethod = "ellipsis", + ) -> None: + """Print a table to the console. + + Args: + tabular_data: The data to print in tabular format. + headers: The headers for the table. + overflow: What to do with a cell too wide for its column. The + default cuts it short; pass "fold" for values a user has to + read in full, such as an email or an identifier. + """ + table = Table() + + for column in headers: + table.add_column(column, overflow=overflow) + + for row in tabular_data: + table.add_row(*row) + + _console.print(table) + + def rule(title: str, **kwargs): + """Print a horizontal rule with a title. + + Args: + title: The title of the rule. + kwargs: Keyword arguments to pass to the print function. + """ + _console.rule(title, **kwargs) + + @overload + def ask( + question: str, + choices: list[str] | None = None, + *, + show_choices: bool = True, + ) -> str: ... + + @overload + def ask( + question: str, + choices: list[str] | None = None, + default: str = ..., + show_choices: bool = True, + ) -> str: ... + + def ask( + question: str, + choices: list[str] | None = None, + default: str | None = None, + show_choices: bool = True, + ) -> str | None: + """Ask the user a question, optionally with a list of choices. + + Args: + question: The question to ask the user. + choices: A list of choices to select from. + default: The default option selected. + show_choices: Whether to show the choices. + + Returns: + A string with the user input. + """ + return Prompt.ask( + question, choices=choices, default=default, show_choices=show_choices + ) + + def progress(): + """Create a new progress bar. + + Returns: + A new progress bar. + """ + return Progress( + *Progress.get_default_columns()[:-1], + MofNCompleteColumn(), + TimeElapsedColumn(), + ) + + def status(*args, **kwargs): + """Create a status with a spinner. + + Args: + *args: Args to pass to the status. + **kwargs: Kwargs to pass to the status. + + Returns: + A new status. + """ + return _console.status(*args, **kwargs) def set_log_level(log_level: LogLevel | str): @@ -38,8 +148,8 @@ def transfer_progress(): """Create a progress bar measured in bytes rather than in steps. Lives here rather than beside ``progress`` in reflex-base because only the - deploy upload wants it: a new name over there would raise this package's - reflex-base floor, and the CLI is released on its own schedule. + deploy upload wants it, and the CLI has to render it whether or not + reflex-base is installed. Returns: A new progress bar, sized and paced for a file transfer. @@ -59,5 +169,5 @@ def transfer_progress(): DownloadColumn(), TransferSpeedColumn(), TimeElapsedColumn(), - disable=_log.is_json_mode(), + disable=is_json_mode(), ) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py index 7dfa0cad592..6663a03e952 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py @@ -7,10 +7,12 @@ import importlib.metadata import json import logging +import os import platform import re import subprocess import sys +import tempfile import threading import time import uuid @@ -25,11 +27,10 @@ from urllib.parse import urljoin import click -from reflex_base.utils import log import reflex_cli.constants as constants from reflex_cli.core.config import Config, RegionOption -from reflex_cli.utils import console, dependency +from reflex_cli.utils import console, dependency, log from reflex_cli.utils.dependency import is_valid_url from reflex_cli.utils.exceptions import ( ArchiveUploadError, @@ -372,33 +373,56 @@ def open(self, url: str, new: int = 0, autoraise: bool = True): webbrowser.BackgroundBrowser = SilentBackgroundBrowser -def get_existing_access_token() -> str: - """Fetch the access token from the existing config if applicable. +class TokenSource(str, Enum): + """Where an access token was loaded from.""" + + CONFIG = "config file" + ENVIRONMENT = "REFLEX_ACCESS_TOKEN environment variable" + OPTION = "--token option" + NONE = "none" + + +def get_existing_access_token_with_source() -> tuple[str, TokenSource]: + """Fetch the access token from the environment or existing config, and say where it came from. + + ``REFLEX_ACCESS_TOKEN`` takes precedence: exporting it is an explicit + choice for this invocation, while the config file is ambient state left + behind by an earlier ``reflex login``. Returns: - The access token. - If not found, return empty string for it instead. + The access token and the source it was loaded from. + If not found, return empty string and ``TokenSource.NONE`` instead. """ - import os + access_token = os.environ.get("REFLEX_ACCESS_TOKEN", "") + if access_token: + logger.debug("Using REFLEX_ACCESS_TOKEN from environment") + return access_token, TokenSource.ENVIRONMENT logger.debug("Fetching token from existing config...") - access_token = "" try: - with constants.Hosting.HOSTING_JSON.open() as config_file: - hosting_config = json.load(config_file) - access_token = hosting_config.get("access_token", "") - except Exception as ex: + access_token = stored_access_token() + except (OSError, ValueError) as ex: logger.debug( f"Unable to fetch token from {constants.Hosting.HOSTING_JSON} due to: {ex}" ) + return "", TokenSource.NONE - if not access_token: - access_token = os.environ.get("REFLEX_ACCESS_TOKEN", "") - if access_token: - logger.debug("Using REFLEX_ACCESS_TOKEN from environment") + if access_token: + return access_token, TokenSource.CONFIG - return access_token + return "", TokenSource.NONE + + +def get_existing_access_token() -> str: + """Fetch the access token from the existing config if applicable. + + Returns: + The access token. + If not found, return empty string for it instead. + + """ + return get_existing_access_token_with_source()[0] def is_reflex_enterprise_installed() -> bool: @@ -490,23 +514,95 @@ def validate_token(token: str) -> dict[str, Any]: raise TokenValidationError("internal errors", request_id=request_id) from ex +def _read_hosting_config() -> dict[str, Any]: + """Read the hosting config file. + + A config that exists but cannot be read is reported rather than treated as + empty, so callers do not overwrite entries they were unable to see. + + Returns: + The stored config, or an empty dict if the file does not exist. + + Raises: + OSError: If the config exists but cannot be read. + ValueError: If the config exists but does not hold a JSON object. + + """ + try: + with constants.Hosting.HOSTING_JSON.open(encoding="utf-8") as config_file: + hosting_config = json.load(config_file) + except FileNotFoundError: + return {} + # Valid JSON is not necessarily the object every caller indexes into. + if not isinstance(hosting_config, dict): + msg = f"{constants.Hosting.HOSTING_JSON} does not hold a JSON object" + raise ValueError(msg) + return hosting_config + + +def stored_access_token() -> str: + """Read the access token held in the config file. + + Unlike ``get_existing_access_token`` this ignores ``REFLEX_ACCESS_TOKEN`` + and reports read failures, so callers can tell "no token stored" apart from + "cannot tell what is stored". + + Returns: + The stored token, or an empty string if the config holds none. + + Raises: + OSError: If the config exists but cannot be read. + ValueError: If the config exists but does not hold valid JSON. + + """ + return _read_hosting_config().get("access_token", "") + + +def _write_hosting_config(hosting_config: dict[str, Any]): + """Write the hosting config file atomically. + + The config is written to a temporary file alongside the target and moved + into place, so a failed or interrupted write leaves the previous + credentials intact rather than truncating them. + + Args: + hosting_config: The config to persist. + + """ + target = constants.Hosting.HOSTING_JSON + target.parent.mkdir(parents=True, exist_ok=True) + # Close the handle before replacing: Windows cannot rename an open file. + temp_fd, temp_name = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.") + temp_path = Path(temp_name) + try: + with os.fdopen(temp_fd, "w", encoding="utf-8") as config_file: + json.dump(hosting_config, config_file) + config_file.flush() + os.fsync(config_file.fileno()) + temp_path.replace(target) + except BaseException: + temp_path.unlink(missing_ok=True) + raise + + def delete_token_from_config(): """Delete the invalid token from the config file if applicable.""" if constants.Hosting.HOSTING_JSON.exists(): try: - with constants.Hosting.HOSTING_JSON.open("r") as config_file: - hosting_config = json.load(config_file) + hosting_config = _read_hosting_config() hosting_config.pop("access_token", None) - with constants.Hosting.HOSTING_JSON.open("w") as config_file: - json.dump(hosting_config, config_file) + _write_hosting_config(hosting_config) except Exception as ex: # Best efforts removing invalid token is OK logger.debug( f"Unable to delete the invalid token from config file, err: {ex}" ) - # Delete the previous hosting service data if present. - if constants.Hosting.HOSTING_JSON_V0.exists(): - constants.Hosting.HOSTING_JSON_V0.unlink() + # Delete the previous hosting service data if present. Best efforts, like + # the rest of this function: the legacy file holds no token the CLI reads. + try: + constants.Hosting.HOSTING_JSON_V0.unlink(missing_ok=True) + except OSError as ex: + logger.debug(f"Unable to remove {constants.Hosting.HOSTING_JSON_V0}: {ex}") def save_token_to_config(token: str): @@ -517,18 +613,17 @@ def save_token_to_config(token: str): """ try: - if not Path(constants.Reflex.DIR).exists(): - Path(constants.Reflex.DIR).mkdir(parents=True, exist_ok=True) - hosting_config: dict[str, str] = {} - if constants.Hosting.HOSTING_JSON.exists(): - try: - with constants.Hosting.HOSTING_JSON.open("r") as config_file: - hosting_config = json.load(config_file) - except (OSError, ValueError): - hosting_config = {} + try: + hosting_config = _read_hosting_config() + except (OSError, ValueError) as ex: + # An unreadable config must not block re-authenticating; the token + # is what makes the file useful, so start over from an empty one. + logger.debug( + f"Discarding unreadable {constants.Hosting.HOSTING_JSON}: {ex}" + ) + hosting_config = {} hosting_config["access_token"] = token - with constants.Hosting.HOSTING_JSON.open("w") as config_file: - json.dump(hosting_config, config_file) + _write_hosting_config(hosting_config) except Exception as ex: logger.warning( f"Unable to save token to {constants.Hosting.HOSTING_JSON} due to: {ex}" @@ -2145,7 +2240,7 @@ def upload_archives( f"could not upload the build to storage: HTTP {ex.response.status_code}" ) from ex if attempt < UPLOAD_ATTEMPTS - 1: - console.warn("the upload window expired; reserving another one") + logger.warning("the upload window expired; reserving another one") except httpx.HTTPError as ex: raise ArchiveUploadError(f"could not upload the build: {ex}") from ex else: @@ -2772,6 +2867,145 @@ def _get_deployment_status(deployment_id: str, token: str) -> str: return response.json() +# Terminal control sequences, which a build log is not entitled to emit into +# somebody's terminal. Ordered so a full sequence is consumed before the bare +# ESC that starts it: OSC first (it runs until its own terminator and is the +# one that writes the clipboard and forges hyperlinks), then CSI, then the +# two-character escapes, then anything left over. +_TERMINAL_CONTROL_RE = re.compile( + r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC 8 hyperlinks, OSC 52 clipboard + r"|\x1b\[[0-?]*[ -/]*[@-~]" # CSI: colour, cursor moves, line erases + # Every other escape sequence, in the general ECMA-48 shape: optional + # intermediates then one final byte in 0x30-0x7E. Narrower classes leave + # the final byte behind once the catch-all below eats the ESC -- `\x1b7` + # (DECSC) printing a stray "7", `\x1bc` (full terminal reset) a stray "c". + r"|\x1b[ -/]*[0-~]" + r"|[\x00-\x08\x0b-\x1f\x7f-\x9f]" # bare controls, keeping tab and newline +) + + +def _strip_terminal_controls(text: str) -> str: + """*text* with terminal control sequences removed. + + A build log is the output of building the user's own app, dependencies + included, and this excerpt is printed without anyone asking for it -- on + any failed deploy, rather than only when `reflex cloud apps build-logs` is + run. Colour is not worth carrying for that: the same sequences let the + output erase the lines above it, forge a hyperlink, or write the + clipboard, and none of that should be reachable from a dependency's build + script. `markup=False` stops rich reading the text as its own markup and + does nothing about escape sequences. + + Args: + text: The text to strip. + + Returns: + The text with terminal control sequences removed. + + """ + return _TERMINAL_CONTROL_RE.sub("", text) + + +def _get_deployment_failure(deployment_id: str, token: str) -> dict | None: + """Why a deployment failed, in fields, or None when that cannot be had. + + None covers every way of not getting an answer, and they are one case to + the caller: a control plane predating this endpoint 404s, an older + self-hosted one may not route it at all, and the network may simply be + down. All three mean the same thing here -- report the failure the way the + CLI always has, from the status string. + + Args: + deployment_id: The ID of the deployment. + token: The authentication token. + + Returns: + The failure report, or None if it could not be read. + + """ + import httpx + + try: + response = httpx.get( + urljoin( + constants.Hosting.HOSTING_SERVICE, + f"/api/v1/deployments/{deployment_id}/failure", + ), + headers=authorization_header(token), + timeout=constants.Hosting.TIMEOUT, + ) + response.raise_for_status() + report = response.json() + # Wider than json.JSONDecodeError, because a malformed body has more than + # one way to fail: an undecodable encoding raises UnicodeDecodeError (a + # ValueError) and a deeply nested document raises RecursionError (a + # RuntimeError). Either escaping would abort the watch over an answer this + # function is contracted to treat as no answer at all. + except (httpx.RequestError, httpx.HTTPStatusError, ValueError, RecursionError): + return None + return report if isinstance(report, dict) else None + + +def _report_deployment_failure( + deployment_id: str, + token: str, + status: str, + *, + offer_build_logs: bool, +) -> None: + """Tell the user why their deploy failed and what to do about it. + + The build log is offered only where the control plane says it is the + answer. A failure in our pipeline reported as a build failure sends + somebody hunting for a bug in an app that does not have one, which is the + more expensive of the two mistakes and the reason the fault is asked about + at all. + + Args: + deployment_id: The ID of the deployment. + token: The authentication token. + status: The status string the watch loop ended on. + offer_build_logs: Whether to point at the build log when no structured + report can be read, preserving what this arm printed before. + + """ + report = _get_deployment_failure(deployment_id, token) + if report is None: + logger.warning(status) + if offer_build_logs: + logger.warning( + f"to see the build logs:\n reflex cloud apps build-logs {deployment_id}" + ) + return + + logger.error(report.get("reason") or status) + if guidance := report.get("guidance"): + logger.warning(guidance) + + excerpt = report.get("build_log_excerpt") + # Typed as a string by the endpoint, checked because this one is not ours: + # the CLI is versioned apart from the control plane and talks to + # self-hosted ones, so a non-string here would raise in the sanitiser and + # take down a report that had already read fine. + if not excerpt or not isinstance(excerpt, str): + # A log the server holds but could not read is not a build that + # produced none, and saying nothing here reads as the latter. + if report.get("build_log_unreadable"): + logger.warning( + "the build log could not be read right now; try again with:\n" + f" reflex cloud apps build-logs {deployment_id}" + ) + return + # Raw build output: paths, versions and tracebacks, all of which rich would + # read as markup given the chance, plus whatever escape sequences the + # build printed. + console.print("\nthe end of the build log:") + console.print(_strip_terminal_controls(excerpt), markup=False) + console.print( + f"\nfor the whole log:\n reflex cloud apps build-logs {deployment_id}" + ) + + def watch_deployment_status(deployment_id: str, client: AuthenticatedClient) -> bool: """Continuously watch the status of a specific deployment. @@ -2805,16 +3039,19 @@ def watch_deployment_status(deployment_id: str, client: AuthenticatedClient) -> ) break if "build error" in status: - logger.warning(status) - logger.warning( - f"to see the build logs:\n reflex cloud apps build-logs {deployment_id}" + _report_deployment_failure( + deployment_id, client.token, status, offer_build_logs=True ) return False if "unable to find status for given id" in status: + # Not a failed deployment but an id that resolves to nothing, + # so there is no row to report on and nothing to ask for. logger.error(status) return False if "error" in status: - logger.warning(status) + _report_deployment_failure( + deployment_id, client.token, status, offer_build_logs=False + ) return False if "bad response" in status: logger.warning(status) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/log.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/log.py new file mode 100644 index 00000000000..1cb1b9466cd --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/log.py @@ -0,0 +1,131 @@ +"""Logging pipeline for the hosting CLI, shared with reflex-base when present. + +The CLI logs through plain ``logging.getLogger(__name__)`` loggers either way. +Under reflex 0.9 and up, ``reflex_base.utils.log`` already parents ``reflex_cli`` +under the ``reflex`` logger and owns the sinks, so this module only forwards to +it. Under older reflex there is no reflex-base to forward to, so the fallback +below renders the ``reflex_cli`` logger itself with the same styles. +""" + +from __future__ import annotations + +import logging + +from reflex_cli.constants.base import LogLevel + +try: + from reflex_base.utils.log import SUCCESS as SUCCESS + from reflex_base.utils.log import is_json_mode as is_json_mode + from reflex_base.utils.log import set_log_level as set_log_level + + HAS_REFLEX_BASE = True + +except ImportError: + from rich.console import Console + + HAS_REFLEX_BASE = False + + # Level between INFO and WARNING for user-facing success messages. + SUCCESS = 25 + logging.addLevelName(SUCCESS, "SUCCESS") + + # (style, prefix) per level, matching the reflex-base console handler. + _LEVEL_STYLES: dict[int, tuple[str, str]] = { + logging.DEBUG: ("purple", "Debug: "), + logging.INFO: ("cyan", "Info: "), + SUCCESS: ("green", "Success: "), + logging.WARNING: ("orange1", "Warning: "), + logging.ERROR: ("red", ""), + logging.CRITICAL: ("red", ""), + } + + _console = Console(highlight=False) + _console_stderr = Console(stderr=True, highlight=False) + + # Formatter kept only for its exception rendering, which is stateless. + _EXC_FORMATTER = logging.Formatter() + + _CLI_LOGGER = logging.getLogger("reflex_cli") + + def is_json_mode() -> bool: + """Check whether logs should be emitted as JSON records. + + Returns: + False: machine-readable output is a reflex-base feature, driven by + REFLEX_LOG_JSON, and there is no pipeline here to emit it. + """ + return False + + def _style_for_level(levelno: int) -> tuple[str, str]: + """Resolve the rich style and message prefix for a log level. + + Args: + levelno: The stdlib logging level number. + + Returns: + A (style, prefix) tuple. + """ + levelno = min(logging.CRITICAL, max(logging.DEBUG, levelno)) + # Round down to the nearest known level. + while levelno not in _LEVEL_STYLES: + levelno -= 1 + return _LEVEL_STYLES[levelno] + + class RichConsoleHandler(logging.Handler): + """Render log records with rich, matching the reflex-base look.""" + + def emit(self, record: logging.LogRecord): + """Print a record to the terminal. + + Args: + record: The log record. + """ + try: + style, prefix = _style_for_level(record.levelno) + console = ( + _console_stderr if record.levelno >= logging.ERROR else _console + ) + # Markup is opt-in per record (``extra={"rich": True}``); plain + # messages keep their literal brackets. + markup = bool(getattr(record, "rich", False)) + console.print( + f"{prefix}{record.getMessage()}", + style=style, + end=getattr(record, "end", "\n"), + markup=markup, + ) + if record.exc_info and record.exc_info[0] is not None: + # Tracebacks may contain user data; never parse them as + # markup. Never word-wrap them either: that breaks paths. + console.print( + _EXC_FORMATTER.formatException(record.exc_info), + style=style, + markup=False, + soft_wrap=True, + ) + except Exception: + self.handleError(record) + + _handler = RichConsoleHandler() + + def set_log_level(log_level: LogLevel | None): + """Set the log level and attach the CLI's console sink. + + Args: + log_level: The log level to set, or None to leave it unchanged. + + Raises: + TypeError: If the log level is not a LogLevel enum value. + """ + if log_level is None: + return + if not isinstance(log_level, LogLevel): + msg = f"log_level must be a LogLevel enum value, got {log_level} of type {type(log_level)} instead." + raise TypeError(msg) + _handler.setLevel(log_level.to_logging_level()) + _CLI_LOGGER.setLevel(log_level.to_logging_level()) + # Cut propagation while the sink is attached, so an application-side + # basicConfig cannot double-emit the CLI's records. addHandler is a + # no-op when the handler is already attached, so this stays idempotent. + _CLI_LOGGER.propagate = False + _CLI_LOGGER.addHandler(_handler) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/apps.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/apps.py index 369b9a41ca7..7ca6337d5f3 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/apps.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/apps.py @@ -7,11 +7,10 @@ from typing import Any import click -from reflex_base.utils import log from reflex_cli import constants from reflex_cli.core.config import Config -from reflex_cli.utils import console +from reflex_cli.utils import console, log from reflex_cli.utils.exceptions import ( ConfigInvalidFieldValueError, GetAppError, diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py new file mode 100644 index 00000000000..e96d80bb79d --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py @@ -0,0 +1,261 @@ +"""Authentication inspection commands for the Reflex Cloud CLI.""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import sys + +import click + +from reflex_cli import constants +from reflex_cli.utils import console, log +from reflex_cli.utils.exceptions import TokenValidationError + +logger = logging.getLogger(__name__) + +# Identity fields copied from the control plane response, in display order. +_IDENTITY_FIELDS = ("email", "user_id", "org_id", "tier", "is_service_account") + + +def token_fingerprint(token: str) -> str: + """Derive a non-reversible identifier for an access token. + + The same token always produces the same fingerprint, so two machines can be + compared without revealing the token itself. + + Args: + token: The access token to fingerprint. + + Returns: + A short sha256-derived fingerprint, or an empty string if there is no token. + + """ + if not token: + return "" + return f"sha256:{hashlib.sha256(token.encode()).hexdigest()[:16]}" + + +# Sentinel --set value meaning "read the token from stdin". +_STDIN = "-" + + +def _resolve_set_token(value: str) -> str: + """Resolve the `--set` value, reading from stdin when asked to. + + A token passed on the command line lands in shell history and is readable + from the process list, so `-` (or a bare `--set`) takes it from stdin, or + prompts without echo when stdin is a terminal. + + Args: + value: The raw value given to `--set`. + + Returns: + The token to validate and store. + + Raises: + UsageError: If the resolved token is empty. + + """ + if value == _STDIN: + value = ( + # err=True keeps the prompt off stdout. + click.prompt("Access token", hide_input=True, err=True) + if sys.stdin.isatty() + else sys.stdin.readline() + ) + token = value.strip() + if not token: + raise click.UsageError("--set was given an empty token.") + return token + + +_loglevel_option = click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) + + +@click.command() +@click.option("--token", help="The authentication token.") +@_loglevel_option +@click.option( + "--json/--no-json", + "-j", + "as_json", + is_flag=True, + help="Whether to output the result in json format.", +) +def whoami_command(token: str | None, loglevel: str, as_json: bool): + """Show which account the Reflex Cloud CLI is authenticating as. + + Reports the identity the control plane resolves the access token to, along + with where that token was loaded from. Never starts a browser login and + never prints the token itself. + """ + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + if token: + access_token, source = token, hosting.TokenSource.OPTION + else: + access_token, source = hosting.get_existing_access_token_with_source() + + if not access_token: + logger.error("Not logged in. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) + + try: + validated_info = hosting.validate_token(access_token) + except TokenValidationError as err: + logger.error( + f"The access token from the {source.value} was rejected: {err} " + f"(auth request id: {err.request_id})" + ) + raise click.exceptions.Exit(1) from err + + identity = { + field: validated_info[field] + for field in _IDENTITY_FIELDS + if field in validated_info + } + identity["token_source"] = source.value + identity["token_fingerprint"] = token_fingerprint(access_token) + + # Both paths bypass the console: it applies rich markup and wraps at the + # terminal width, which corrupts JSON and truncates the identifiers this + # command exists to hand back. + if as_json: + click.echo(json.dumps(identity)) + return + + width = max(map(len, identity)) + for field, value in identity.items(): + click.echo(f"{field:<{width}} {value}") + + +@click.command() +@click.option( + "--print", + "print_token", + is_flag=True, + help="Print the active access token to stdout.", +) +@click.option( + "--set", + "set_token", + metavar="TOKEN", + is_flag=False, + flag_value=_STDIN, + default=None, + help=( + "Validate TOKEN and store it as the access token. Pass `-`, or omit " + "the value, to read the token from stdin instead of the command line." + ), +) +@click.option("--clear", is_flag=True, help="Remove the stored access token.") +@_loglevel_option +def token_command(print_token: bool, set_token: str | None, clear: bool, loglevel: str): + """Inspect or replace the stored Reflex Cloud access token. + + Exactly one of --print, --set or --clear must be given. --print writes the + raw token to stdout so it can be captured, e.g. + `export REFLEX_ACCESS_TOKEN=$(reflex cloud token --print)`; stdout carries + the token or nothing at all, so use `reflex cloud whoami` to inspect where + the token came from. + """ + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + requested = [ + name + for name, chosen in ( + ("--print", print_token), + # `--set ""` is a malformed --set, not an absent one. + ("--set", set_token is not None), + ("--clear", clear), + ) + if chosen + ] + if len(requested) != 1: + raise click.UsageError( + f"Specify exactly one of --print, --set or --clear (got {', '.join(requested) or 'none'})." + ) + + if print_token: + # The shared console writes everything below ERROR to stdout, which + # would land inside `$(reflex cloud token --print)` alongside the + # token. Errors still go to stderr, so stdout stays exact either way. + console.set_log_level(constants.LogLevel.ERROR) + access_token, _ = hosting.get_existing_access_token_with_source() + if not access_token: + logger.error("No access token stored. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) + # Bypass the console so the token is never wrapped or styled. + click.echo(access_token) + return + + if set_token is not None: + set_token = _resolve_set_token(set_token) + try: + validated_info = hosting.validate_token(set_token) + except TokenValidationError as err: + logger.error( + f"Token rejected, nothing was saved: {err} " + f"(auth request id: {err.request_id})" + ) + raise click.exceptions.Exit(1) from err + + hosting.save_token_to_config(set_token) + # Verify against the config alone: the resolution order prefers + # REFLEX_ACCESS_TOKEN, which would mask the write we are confirming. + try: + stored = hosting.stored_access_token() + except (OSError, ValueError) as err: + logger.error( + f"Unable to confirm the token was written to " + f"{constants.Hosting.HOSTING_JSON}: {err}" + ) + raise click.exceptions.Exit(1) from err + if stored != set_token: + logger.error( + f"Unable to persist the token to {constants.Hosting.HOSTING_JSON}." + ) + raise click.exceptions.Exit(1) + + owner = validated_info.get("email") or validated_info.get("user_id") + logger.log( + log.SUCCESS, + f"Saved the access token for {owner} ({token_fingerprint(set_token)}).", + ) + return + + hosting.delete_token_from_config() + # delete_token_from_config swallows filesystem errors, so confirm the token + # is really gone rather than reporting an unverified success. A config that + # cannot be read is not evidence of removal either. + try: + remaining = hosting.stored_access_token() + except (OSError, ValueError) as err: + logger.error( + f"Unable to confirm the token was removed from " + f"{constants.Hosting.HOSTING_JSON}: {err}" + ) + raise click.exceptions.Exit(1) from err + if remaining: + logger.error( + f"Unable to remove the access token from {constants.Hosting.HOSTING_JSON}." + ) + raise click.exceptions.Exit(1) + + logger.log(log.SUCCESS, "Cleared the stored access token.") + if os.environ.get("REFLEX_ACCESS_TOKEN"): + logger.info( + "REFLEX_ACCESS_TOKEN is still set; the CLI will authenticate with it." + ) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py index 68f9958855d..b4f8e238326 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py @@ -15,10 +15,9 @@ import click from packaging import version -from reflex_base.utils import log from reflex_cli import constants -from reflex_cli.utils import console +from reflex_cli.utils import console, log from reflex_cli.utils.dependency import extract_domain logger = logging.getLogger(__name__) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py index acc11f15462..08d8ba31106 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py @@ -13,6 +13,7 @@ from reflex_cli import constants from reflex_cli.v2.apps import apps_cli +from reflex_cli.v2.auth import token_command, whoami_command from reflex_cli.v2.gcp import deploy_command as gcp_deploy_command from reflex_cli.v2.project import project_cli from reflex_cli.v2.providers import providers_cli @@ -94,6 +95,14 @@ def hosting_cli(ctx: click.Context) -> None: scan_command, name="scan", ) +hosting_cli.add_command( + whoami_command, + name="whoami", +) +hosting_cli.add_command( + token_command, + name="token", +) for name, command in vm_types_regions_cli.commands.items(): # Add the command to the hosting CLI hosting_cli.add_command(command, name=name) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py index a0c87ad5025..ed42669f730 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/gcp.py @@ -34,10 +34,9 @@ from urllib.parse import urljoin import click -from reflex_base.utils import log from reflex_cli import constants -from reflex_cli.utils import console +from reflex_cli.utils import console, log logger = logging.getLogger(__name__) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/project.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/project.py index cebeedba700..ddfb2dfc3e1 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/project.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/project.py @@ -4,10 +4,9 @@ import logging import click -from reflex_base.utils import log from reflex_cli import constants -from reflex_cli.utils import console +from reflex_cli.utils import console, log from reflex_cli.utils.exceptions import NotAuthenticatedError logger = logging.getLogger(__name__) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/providers.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/providers.py index 5f621a30ac3..8253244d95d 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/providers.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/providers.py @@ -14,10 +14,9 @@ from typing import Any import click -from reflex_base.utils import log from reflex_cli import constants -from reflex_cli.utils import console +from reflex_cli.utils import console, log from reflex_cli.utils.exceptions import NotAuthenticatedError logger = logging.getLogger(__name__) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/scan.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/scan.py index fa4809da2be..4d848d82561 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/scan.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/scan.py @@ -12,10 +12,9 @@ from typing import Any import click -from reflex_base.utils import log from reflex_cli import constants -from reflex_cli.utils import console +from reflex_cli.utils import console, log from reflex_cli.utils.exceptions import NotAuthenticatedError logger = logging.getLogger(__name__) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/secrets.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/secrets.py index 79923fd8842..78434e10951 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/secrets.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/secrets.py @@ -5,10 +5,9 @@ import logging import click -from reflex_base.utils import log from reflex_cli import constants -from reflex_cli.utils import console +from reflex_cli.utils import console, log from reflex_cli.utils.exceptions import NotAuthenticatedError logger = logging.getLogger(__name__) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/vmtypes_regions.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/vmtypes_regions.py index 6e97908ecdc..cc5b1a3b191 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/vmtypes_regions.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/vmtypes_regions.py @@ -4,10 +4,9 @@ import logging import click -from reflex_base.utils import log from reflex_cli import constants -from reflex_cli.utils import console +from reflex_cli.utils import console, log logger = logging.getLogger(__name__) diff --git a/packages/reflex-release/README.md b/packages/reflex-release/README.md index 875b9c7487e..56b7ca3a3c4 100644 --- a/packages/reflex-release/README.md +++ b/packages/reflex-release/README.md @@ -194,6 +194,74 @@ This gives you, for free: `pin-exact` rewrites the requirement in the publishing package's `pyproject.toml` at build time only; it is never committed. +### Dependency pins across a release + +A package that depends on a sibling it is waiting for pins the unreleased +version — `widget-core >= 0.2.0.dev1` — so the workspace resolves while the +sibling is still unpublished. That pin cannot be published: `*.dev` versions +never reach PyPI, so the metadata would be uninstallable. `check-dev-pins` +rejects it at build time, which means someone has to remember to lift it once +the sibling is out. + +Materialization does it instead. When *Dispatch release* plans a release, each +selected package's published dependencies are checked for a floor the release +cannot ship, and the floor is lifted to the **earliest published version that +satisfies the whole requirement**: + +| Floor | Materializing a prerelease | Materializing a final version | +| --- | --- | --- | +| `>= 0.2.0.dev1` | earliest published `0.2.0a1`, `0.2.0`, … | earliest published *final* `0.2.0`, … | +| `>= 0.2.0a1` | left alone — an alpha may ship it | lifted to the earliest published final | +| `>= 0.2.0` | left alone | left alone | + +"Published" means **tagged**: tags are created only after a successful upload, +so the repository's own tags are its record of what is on PyPI — which is why +the release workflows check out with full history and tags. The rewritten +`pyproject.toml` files are part of the release commit, so they land through the +same review as the changelog bump; a package's `pyproject.toml` is staged only +when a pin in it actually moved, and only the copies of a requirement that are +*published* are rewritten — a `[dependency-groups]` entry is left alone, the +same way `check-dev-pins` ignores it. + +The lifted floor is always one the resolved version satisfies — a strict +`> 0.2.0.dev1` becomes `>= 0.2.0`, since `> 0.2.0` would exclude the very +release it resolved to — and the rewrite is verified against the resolved +version before it is written. An **exact** floor (`== 0.2.0.dev1`) is a dead +end rather than a wait: no published version can equal it, so the package is +held back with a note to re-pin it by hand. + +### The lock file + +If the repository has a `uv.lock`, it is re-resolved after the pins move, and +staged with them **when the re-resolution actually changes it**. In a uv +workspace that is usually never: the lock records a workspace member as +`{ name = "mypkg-base", editable = "packages/mypkg-base" }`, with no version +specifier for a lifted pin to show up in. The re-lock matters for the other +layout — where the sibling resolves from an index and the lock does carry its +specifier. + +Two consequences worth knowing. `uv lock` re-resolves against the index, so if +the lock was *already* out of date on the main branch, catching it up is part of +the same commit — the release PR then carries resolution churn that has nothing +to do with the release. And pins and lock move together: if `uv lock` cannot +follow the new pins, the `pyproject.toml` rewrites are rolled back, so a re-run +has the same work to do rather than finding the pins already lifted and skipping +the lock. + +A floor nothing published satisfies has nowhere to go, and the package is +**held back** rather than materialized into a version that could never be +published — auto-selected packages are dropped from the batch (a lockstep group +whole, since its members only release together) and listed in the run summary; +an explicitly selected one fails the dispatch. Release the depended-on package +first and the next release lifts the pin by itself. + +Two things are deliberately left alone: a floor on a lockstep sibling that +`pin-exact` rewrites at build time anyway, and a *prerelease* floor on a +dependency outside the repository, whose releases are not recorded here and +whose pin is somebody's deliberate choice. A `*.dev` floor on an outside +dependency still holds the package back — that pin is unpublishable whoever +owns it. + ## Adding towncrier `init` writes this for you if `[tool.towncrier]` is absent. If you configure it @@ -507,6 +575,10 @@ comma-separated text field; see `dispatch-package-inputs`. | `release-patch` / `-minor` / `-major` | Final version straight from `main`. Opens a PR. | | `release-post` | `1.2.3.post1`, for packaging-only fixes. Opens a PR. | +A package whose dependency pins no published version satisfies is held back and +listed in the run summary — see +[Dependency pins across a release](#dependency-pins-across-a-release). + Release actions open a pull request; **merging it is what publishes.** The push to `main` triggers `release_from_changelog`, which builds every untagged changelog version and waits for the `pypi` approval before uploading. Only then @@ -773,7 +845,7 @@ a flag for running the same command by hand. | `create [--package P] NAME` | Create a news fragment. | | `packages` | List releasable packages. | | `plan` | Compute the next version of each selected package. | -| `materialize` | Name orphan fragments after their PR, run towncrier and (for `release-from-prerelease`) collapse alphas. | +| `materialize` | Name orphan fragments after their PR, run towncrier, lift unshippable dependency pins, collapse alphas. | | `open-release-pr` / `push-prerelease` | Commit the changelogs and deliver them. | | `detect` | List packages whose newest changelog version has no tag. | | `prepare-publish` | Validate a package/version and emit build metadata. | @@ -818,7 +890,8 @@ a flag for running the same command by hand. artifact that was built and validated before the approval. - **Detection fails closed.** A broken lockstep pair, a version the branch may not publish, or a `*.dev` pin stops the batch rather than shipping something - uninstallable. + uninstallable. A pin a published version *can* satisfy is lifted in the + release commit instead, so the same rule does not turn into busywork. - **Every step names its shell.** The generated `run:` steps declare `shell: bash`, so a `defaults.run.shell` added to one of these files — or a runner whose default is not bash — cannot change how a release-critical script is diff --git a/packages/reflex-release/news/+dev-pin-upgrades.feature.md b/packages/reflex-release/news/+dev-pin-upgrades.feature.md new file mode 100644 index 00000000000..356e4773c8a --- /dev/null +++ b/packages/reflex-release/news/+dev-pin-upgrades.feature.md @@ -0,0 +1 @@ +Materialization now lifts dependency pins a release cannot ship. A `*.dev` floor — and, for a final version, a prerelease floor on a sibling package — is rewritten to the earliest published version that satisfies the requirement, `uv.lock` is re-resolved, and both land in the release commit alongside the changelog bump. "Published" means tagged, which is this pipeline's record of what reached PyPI, so a prerelease satisfies a floor only when the version being materialized is itself a prerelease. A floor no published version satisfies has nowhere to go, so the package is held back at plan time — dropped from an auto-selection (a lockstep group whole), an error for an explicit one — rather than materialized into a version that could never be published. diff --git a/packages/reflex-release/news/+publish-skip-propagation.bugfix.md b/packages/reflex-release/news/+publish-skip-propagation.bugfix.md new file mode 100644 index 00000000000..9ad760860a2 --- /dev/null +++ b/packages/reflex-release/news/+publish-skip-propagation.bugfix.md @@ -0,0 +1 @@ +A release using a `[[tool.reflex-release.custom-build]]` entry no longer publishes nothing while reporting success. Exactly one of the built-in `build` job and a custom-build job runs for any given package, so the other is always skipped — and GitHub evaluates the implicit `success()` a job gets when its `if` names no status function over the whole transitive dependency closure, not just the direct `needs`. The skipped build therefore reached `publish` straight through the `collect` written to absorb it, and `tag-and-release` behind it: every build succeeded, the artifacts were verified and checksummed, and then the upload silently never happened. Both jobs now carry an explicit `needs..result == 'success'` guard. `release_from_changelog.yml`'s `report` job, the canonical failure signal for a partial release, was blind to the same shape because it accepted any skipped leg; it now accepts a skipped leg only when detection found nothing for that leg to publish, so a release that publishes nothing — or holds a lockstep package back — is red. diff --git a/packages/reflex-release/src/reflex_release/cli.py b/packages/reflex-release/src/reflex_release/cli.py index 8bbec798a21..cc345823962 100644 --- a/packages/reflex-release/src/reflex_release/cli.py +++ b/packages/reflex-release/src/reflex_release/cli.py @@ -213,6 +213,11 @@ def build_parser() -> argparse.ArgumentParser: help="Branch the workflow was dispatched on.", ) pr.add_argument("--releases", default=_env("RELEASES_JSON"), help="The plan JSON.") + pr.add_argument( + "--repinned", + default=_env("REPINNED_JSON"), + help="Paths the pin upgrade rewrote, as emitted by materialize.", + ) prerelease = sub.add_parser( "push-prerelease", help="Commit the changelogs and push the prerelease branch." @@ -226,6 +231,11 @@ def build_parser() -> argparse.ArgumentParser: prerelease.add_argument( "--releases", default=_env("RELEASES_JSON"), help="The plan JSON." ) + prerelease.add_argument( + "--repinned", + default=_env("REPINNED_JSON"), + help="Paths the pin upgrade rewrote, as emitted by materialize.", + ) push_tag = sub.add_parser("push-tag", help="Push the tag of a published version.") push_tag.add_argument("--tag", default=_env("TAG"), help="The tag to push.") @@ -335,11 +345,11 @@ def dispatch(args: argparse.Namespace, config: Config) -> None: commands.cmd_detect_internal(config, args.base, args.head, args.package) case "open-release-pr": commands.cmd_open_release_pr( - config, args.action, args.ref_name, args.releases + config, args.action, args.ref_name, args.releases, args.repinned ) case "push-prerelease": commands.cmd_push_prerelease( - config, args.action, args.ref_name, args.releases + config, args.action, args.ref_name, args.releases, args.repinned ) case "push-tag": commands.cmd_push_tag(config, args.tag) diff --git a/packages/reflex-release/src/reflex_release/commands.py b/packages/reflex-release/src/reflex_release/commands.py index bdaa9b0721b..8b269ea2bf1 100644 --- a/packages/reflex-release/src/reflex_release/commands.py +++ b/packages/reflex-release/src/reflex_release/commands.py @@ -33,6 +33,13 @@ parse_sections, ) from .config import POST_RELEASE_INPUTS, POST_RELEASE_WORKFLOW_KEY, Config, is_final +from .devpins import ( + LOCK_FILE, + blocker_advice, + blocking_pins, + describe_blockers, + upgrade_dev_pins, +) from .discovery import ( alpha_train_packages, associate_orphan_fragments, @@ -62,7 +69,7 @@ remote_branch_exists, tag_exists, ) -from .versions import ACTIONS, next_version, release_date_today +from .versions import ACTIONS, FINAL_ACTIONS, next_version, release_date_today #: Filename of the scaffolded workflow that publishes untagged changelog versions. RELEASE_WORKFLOW = "release_from_changelog.yml" @@ -238,6 +245,62 @@ def cmd_detect(config: Config, ref_name: str) -> None: fail("lockstep invariant violated; no package was published") +def _drop_unpublishable_pins( + config: Config, packages: list[str], action: str, explicit: bool +) -> tuple[list[str], list[str]]: + """Hold back packages whose dependency pins no published version satisfies. + + Materialization lifts a ``*.dev`` (and, for a final version, a prerelease) + dependency floor to the earliest published version that satisfies it. A + floor nothing published satisfies has nowhere to go, so the package is not + releasable yet — releasing it would either publish an uninstallable pin or + stop at the publish-time gate with the changelog already bumped. + + A lockstep group is held back whole: its members only ever release together. + + Args: + config: The repository configuration. + packages: The selected packages, lockstep groups already expanded. + action: The release action being planned. + explicit: Whether the selection was made by hand. An explicit selection + that cannot be released is an error; an auto-selected package is + simply left out of the batch. + + Returns: + The releasable packages and the human-readable reasons the others were + held back. + """ + blocked = blocking_pins( + config, packages, allow_prereleases=action not in FINAL_ACTIONS + ) + if not blocked: + return packages, [] + + reasons = describe_blockers(blocked) + if explicit: + listing = "\n".join(f" {line}" for line in reasons) + fail( + "the selected package(s) declare dependency pins that no published " + f"version satisfies:\n{listing}\n\n{blocker_advice(blocked)}" + ) + + held = { + member + for package in blocked + for member in (package, *config.lockstep_partners(package)) + } + for line in reasons: + notice(f"held back from this release — {line}") + remaining = [package for package in packages if package not in held] + if not remaining: + fail( + "every auto-selected package declares a dependency pin that no " + "published version satisfies:\n" + + "\n".join(f" {line}" for line in reasons) + ) + return remaining, reasons + + def cmd_plan(config: Config, action: str, selection: str) -> None: """Plan the next version for each selected package. @@ -245,7 +308,8 @@ def cmd_plan(config: Config, action: str, selection: str) -> None: ``$GITHUB_OUTPUT``. An empty selection auto-detects the packages to release: those with pending news fragments — or, for ``release-from-prerelease``, those whose changelog is topped by an alpha (their fragments are already - consumed). + consumed). A package whose dependency pins no published version satisfies is + not eligible either way (see :func:`_drop_unpublishable_pins`). Args: config: The repository configuration. @@ -285,6 +349,10 @@ def cmd_plan(config: Config, action: str, selection: str) -> None: if partner not in packages ) + packages, disqualified = _drop_unpublishable_pins( + config, packages, action, explicit=how == "explicit" + ) + releases: list[dict[str, str]] = [] for package in packages: group = config.lockstep_group(package) @@ -322,6 +390,16 @@ def cmd_plan(config: Config, action: str, selection: str) -> None: for r in releases ], ), + *( + [ + "", + "### Held back", + "", + *(f"- {line}" for line in disqualified), + ] + if disqualified + else [] + ), ]) write_outputs(releases=json.dumps(releases)) @@ -329,11 +407,20 @@ def cmd_plan(config: Config, action: str, selection: str) -> None: def cmd_materialize(config: Config, action: str, releases_json: str) -> None: """Write the planned versions into the changelogs via towncrier. + Also lifts every dependency pin the release cannot ship — a ``*.dev`` floor, + or a prerelease floor when the release is final — to the earliest published + version that satisfies it, and re-locks the repository, so the release + carries pins that resolve instead of failing the publish-time gate. + Orphan fragments are first named after the pull request that added them, so the entries towncrier writes carry a link. For ``release-from-prerelease``, collapses the alpha sections of each changelog into the single final-version section after building it. + Writes ``repinned`` (a JSON array of the paths the pin upgrade rewrote) to + ``$GITHUB_OUTPUT``, which is what the delivery step stages beside the + changelogs — it runs as a separate process and cannot otherwise know. + Args: config: The repository configuration. action: The release action the plan was made for. @@ -342,6 +429,22 @@ def cmd_materialize(config: Config, action: str, releases_json: str) -> None: releases: list[dict[str, str]] = json.loads(releases_json) if not releases: fail("nothing to materialize: the release plan is empty") + + # Before towncrier: a pin that cannot be lifted stops the release while the + # news fragments it would have consumed are still on disk. + repinned = upgrade_dev_pins( + config, + [release["package"] for release in releases], + allow_prereleases=action not in FINAL_ACTIONS, + ) + write_outputs(repinned=json.dumps(repinned)) + if repinned: + write_summary([ + "## Dependency pins lifted", + "", + *(f"- `{path}`" for path in repinned), + ]) + collapse = action == "release-from-prerelease" categories = category_order(config) if collapse else [] heading_format = title_format(config) @@ -731,20 +834,39 @@ def _release_summary(releases: list[dict[str, str]]) -> str: return ", ".join(f"{r['package']}@{r['next']}" for r in releases) -def _commit_changelogs( - config: Config, releases: list[dict[str, str]], message: str +def _repinned_paths(repinned_json: str) -> list[str]: + """Parse the ``repinned`` output of :func:`cmd_materialize`. + + Args: + repinned_json: The JSON array of rewritten paths, or an empty string + when the pin upgrade rewrote nothing. + + Returns: + The repo-relative paths. + """ + return json.loads(repinned_json) if repinned_json.strip() else [] + + +def _commit_materialized( + config: Config, + releases: list[dict[str, str]], + repinned: list[str], + message: str, ) -> None: - """Stage and commit the changelogs materialized for a release. + """Stage and commit everything materialization wrote for a release. Args: config: The repository configuration. releases: The releases that were materialized. + repinned: The paths the pin upgrade rewrote, from :func:`cmd_materialize`. message: The commit message. """ configure_bot_identity(config.root) - # Only the changelogs of the packages being released, so nothing else in the - # worktree can ride along in the release commit. towncrier has already - # staged the deletion of every fragment it consumed. + # Exactly what materialization wrote: the changelogs of the packages being + # released, and the files the pin upgrade reported rewriting. Nothing else + # in the worktree can ride along in the release commit — a package's + # pyproject.toml is staged only when a pin in it actually moved. towncrier + # has already staged the deletion of every fragment it consumed. changelogs = [ path.relative_to(config.root).as_posix() for path in (config.changelog_path(r["package"]) for r in releases) @@ -752,14 +874,37 @@ def _commit_changelogs( ] if not changelogs: fail("materialization produced no changelog; nothing to release") - git_run(["add", "--", *changelogs], config.root) + git_run(["add", "--", *changelogs, *repinned], config.root) if not git(["diff", "--cached", "--name-only"], config.root).strip(): fail("materialization produced no changes; nothing to release") + + # A lifted pin that does not reach the commit is the failure this whole + # feature exists to remove, arriving later and with more to unwind: the + # branch would go out with the old pin and die at the publish-time gate. It + # happens when the workflow predates the `repinned` output, so name that. + owned = { + LOCK_FILE, + *(f"{config.path_prefix(r['package'])}pyproject.toml" for r in releases), + } + if stranded := sorted( + owned.intersection(git(["diff", "--name-only"], config.root).split()) + ): + fail( + f"materialization left {', '.join(stranded)} modified but unstaged. " + "If the scaffolded workflow predates the `repinned` output of " + "`materialize`, re-run `reflex-release sync` and dispatch again; " + "otherwise the working tree holds changes materialization did not " + "make, and a release must not carry them." + ) git_run(["commit", "-m", message], config.root) def cmd_open_release_pr( - config: Config, action: str, ref_name: str, releases_json: str + config: Config, + action: str, + ref_name: str, + releases_json: str, + repinned_json: str, ) -> None: """Commit the materialized changelogs and open the release pull request. @@ -768,8 +913,10 @@ def cmd_open_release_pr( action: The release action that was materialized. ref_name: The branch the workflow was dispatched on. releases_json: The ``releases`` JSON emitted by :func:`cmd_plan`. + repinned_json: The ``repinned`` JSON emitted by :func:`cmd_materialize`. """ releases: list[dict[str, str]] = json.loads(releases_json) + repinned = _repinned_paths(repinned_json) run_id = os.environ.get("GITHUB_RUN_ID", "manual") # Final versions publish from the main branch — except hotfix trains, which # publish directly from their own branch, so the PR targets it instead. @@ -811,8 +958,8 @@ def cmd_open_release_pr( body_file = Path(os.environ.get("RUNNER_TEMP", ".")) / "release_pr_body.md" body_file.write_text(body, encoding="utf-8") - _commit_changelogs( - config, releases, f"Materialize changelogs for {summary} ({action})" + _commit_materialized( + config, releases, repinned, f"Materialize changelogs for {summary} ({action})" ) git_push(f"HEAD:refs/heads/{branch}", config.root) @@ -861,7 +1008,11 @@ def cmd_open_release_pr( def cmd_push_prerelease( - config: Config, action: str, ref_name: str, releases_json: str + config: Config, + action: str, + ref_name: str, + releases_json: str, + repinned_json: str, ) -> None: """Commit the materialized changelogs and push the prerelease branch. @@ -870,8 +1021,10 @@ def cmd_push_prerelease( action: The release action that was materialized. ref_name: The branch the workflow was dispatched on. releases_json: The ``releases`` JSON emitted by :func:`cmd_plan`. + repinned_json: The ``repinned`` JSON emitted by :func:`cmd_materialize`. """ releases: list[dict[str, str]] = json.loads(releases_json) + repinned = _repinned_paths(repinned_json) run_id = os.environ.get("GITHUB_RUN_ID", "manual") summary = _release_summary(releases) @@ -891,8 +1044,8 @@ def cmd_push_prerelease( if remote_branch_exists(config.root, branch): branch = f"{branch}-{run_id}" - _commit_changelogs( - config, releases, f"Materialize changelogs for {summary} ({action})" + _commit_materialized( + config, releases, repinned, f"Materialize changelogs for {summary} ({action})" ) git_push(f"HEAD:refs/heads/{branch}", config.root) diff --git a/packages/reflex-release/src/reflex_release/devpins.py b/packages/reflex-release/src/reflex_release/devpins.py index 5696da3b286..6cba811766d 100644 --- a/packages/reflex-release/src/reflex_release/devpins.py +++ b/packages/reflex-release/src/reflex_release/devpins.py @@ -1,21 +1,41 @@ -"""The development-release dependency pin gate. +"""The dependency pin gate, and the pin upgrades that clear it. Development releases (``*.dev``) are not published to PyPI, so a package whose -published metadata pins one cannot be installed by downstream users. This gate -keeps such pins out of a release. Only each package's *own* published -dependencies are inspected — siblings are not followed — so the usual leaf-first -release flow (publish the depended-on package, then drop the dev pin in the -dependent) is never deadlocked by a pin in another package. +published metadata pins one cannot be installed by downstream users. +:func:`check_dev_pins` is the gate that keeps such pins out of a release. Only +each package's *own* published dependencies are inspected — siblings are not +followed — so the usual leaf-first release flow (publish the depended-on +package, then drop the dev pin in the dependent) is never deadlocked by a pin in +another package. + +Dropping the pin by hand is the step that flow keeps forgetting, so +materialization does it: :func:`upgrade_dev_pins` lifts every lower bound the +release cannot ship to the earliest published version that satisfies it, and +re-locks the repository, landing both in the release commit. A bound that no +published version satisfies has no upgrade, and the package is held back from +the release instead (see :func:`blocking_pins`) rather than materialized into a +version that could never be published. + +"Published" means tagged: tags are created only after a successful upload, so +the repository's own tags are the record of what is on PyPI. A dependency +outside the repository has no such record here, which is why only a *dev* bound +on one blocks a release — that pin is unpublishable whoever owns it — while a +prerelease bound on an outside dependency is left alone. """ from __future__ import annotations +import dataclasses +import re +import subprocess + from packaging.requirements import InvalidRequirement, Requirement from packaging.utils import canonicalize_name from packaging.version import InvalidVersion, Version -from .actions import echo, fail -from .config import Config, load_pyproject +from .actions import ReleaseError, echo, fail +from .config import Config, is_final, load_pyproject +from .gitutil import tag_versions # PEP 440 operators that establish a version floor the resolved version must meet # or match. A development release under one of these is an unpublished @@ -23,6 +43,73 @@ # (``!=``) leaves the requirement resolvable from PyPI, so it is not a dev pin. _LOWER_BOUND_OPERATORS = frozenset({"===", "==", "~=", ">=", ">"}) +# Operators that admit exactly one version, so a floor under one of them cannot +# be lifted onto anything: releasing the dependency never helps. +_EXACT_OPERATORS = frozenset({"===", "=="}) + +#: The lock file re-resolved after a pin upgrade, and the tool that rewrites it. +LOCK_FILE = "uv.lock" + +# One version specifier of a PEP 508 requirement, matched so a single bound can +# be lifted in place without disturbing extras, markers or the other +# specifiers. The operator alternation is longest-first: ``>=`` has to win over +# ``>``, and ``===`` over ``==``. +_SPECIFIER_RE = re.compile( + r"(?P===|==|~=|>=|>)(?P\s*)(?P[^\s,;\]]+)" +) + +# A TOML table header at the start of a line, which is what bounds the region a +# requirement rewrite is allowed to touch. +_TABLE_HEADER_RE = re.compile(r"^\[\[?(?P[^\]]+)\]\]?", re.MULTILINE) + + +def _unshippable_bounds( + parsed: Requirement, allow_prereleases: bool +) -> tuple[str, ...]: + """Return the lower bounds of a requirement a release cannot ship as they are. + + Args: + parsed: The parsed requirement. + allow_prereleases: Whether a prerelease floor is shippable. Dev releases + never are; a prerelease floor is fine for a release that is itself a + prerelease, and unwanted in a final release, whose dependency floors + should not drag users onto an alpha. + + Returns: + The offending versions exactly as written in the requirement, so they can + be found again in the source text. + """ + bounds: list[str] = [] + for specifier in parsed.specifier: + if specifier.operator not in _LOWER_BOUND_OPERATORS: + continue + try: + bound = Version(specifier.version) + except InvalidVersion: + # A prefix match such as ``==1.2.*`` has no concrete version to inspect. + continue + if bound.is_devrelease or (bound.is_prerelease and not allow_prereleases): + bounds.append(specifier.version) + return tuple(bounds) + + +def _is_exact_pin(parsed: Requirement, bounds: tuple[str, ...]) -> bool: + """Return whether every offending bound of a requirement is an exact pin. + + Args: + parsed: The parsed requirement. + bounds: The offending versions, as returned by :func:`_unshippable_bounds`. + + Returns: + True when each one appears only under ``==`` or ``===``, which no + published version other than the pinned one can ever satisfy. + """ + return all( + specifier.operator in _EXACT_OPERATORS + for specifier in parsed.specifier + if specifier.version in bounds + ) + def parse_requirement(requirement: str) -> tuple[str, bool]: """Split a PEP 508 requirement into its canonical name and dev-pin status. @@ -39,17 +126,10 @@ def parse_requirement(requirement: str) -> tuple[str, bool]: parsed = Requirement(requirement) except InvalidRequirement: return "", False - name = canonicalize_name(parsed.name) - for specifier in parsed.specifier: - if specifier.operator not in _LOWER_BOUND_OPERATORS: - continue - try: - if Version(specifier.version).is_devrelease: - return name, True - except InvalidVersion: - # A prefix match such as ``==1.2.*`` has no concrete version to inspect. - continue - return name, False + return ( + canonicalize_name(parsed.name), + bool(_unshippable_bounds(parsed, allow_prereleases=True)), + ) def published_dependencies(project: dict) -> list[str]: @@ -100,3 +180,500 @@ def check_dev_pins(config: Config, packages: list[str]) -> None: "publishing." ) echo(f"No development-release dependency pins found in {len(targets)} package(s).") + + +@dataclasses.dataclass(frozen=True) +class PinUpgrade: + """One dependency lower bound a release has to lift before it can publish. + + Attributes: + package: The package declaring the requirement. + requirement: The requirement string, verbatim as written in + ``pyproject.toml``. + dependency: The canonical distribution name it depends on. + bounds: The offending lower-bound versions, as written. + resolved: The earliest published version that satisfies the requirement, + or None when no published version does — which disqualifies the + package from the release. + reason: Why no published version satisfies it (empty when one does). + exact: Whether the offending bounds are exact pins (``==``/``===``), for + which no published version can ever qualify — so the advice is to + re-pin by hand rather than to release the dependency first. + """ + + package: str + requirement: str + dependency: str + bounds: tuple[str, ...] + resolved: Version | None + reason: str = "" + exact: bool = False + + def rewritten(self) -> str: + """Return the requirement with its offending bounds lifted. + + Returns: + The requirement string to write back, preserving extras, markers, + spacing and every other specifier. + """ + if self.resolved is None: + fail(f"{self.requirement!r} has no published version to lift it to") + # Only the specifier part: a marker such as ``; python_version > '3.10'`` + # holds comparisons of its own that are not version specifiers. + head, separator, marker = self.requirement.partition(";") + version = self.resolved + + def lift(match: re.Match[str]) -> str: + if match["version"] not in self.bounds: + return match[0] + # ``> 0.2.0.dev1`` admits 0.2.0, so 0.2.0 can be what it resolves + # to — but ``> 0.2.0`` would then exclude the very release the + # requirement was lifted onto. A strict floor over an unreleased + # version becomes an inclusive floor over the release above it. + operator = ">=" if match["op"] == ">" else match["op"] + return f"{operator}{match['space']}{version}" + + lifted = _SPECIFIER_RE.sub(lift, head) + separator + marker + # The point of the rewrite is a requirement the resolved version + # satisfies; anything else would publish metadata that resolves to + # something other than what was checked, or to nothing at all. + if not Requirement(lifted).specifier.contains(version, prereleases=True): + fail( + f"lifting {self.requirement!r} produced {lifted!r}, which " + f"{version} does not satisfy; re-pin it by hand" + ) + return lifted + + +def _distribution_index(config: Config) -> dict[str, str]: + """Map every repository package's distribution name to its package name. + + Args: + config: The repository configuration. + + Returns: + Canonical distribution name to package (directory) name, which is what + turns a requirement into the sibling whose tags record its releases. + """ + return { + canonicalize_name(config.distribution_name(package)): package + for package in config.all_packages() + } + + +def _pin_upgrades( + config: Config, package: str, allow_prereleases: bool, index: dict[str, str] +) -> list[PinUpgrade]: + """List the dependency lower bounds a package must lift to be releasable. + + Args: + config: The repository configuration. + package: The package whose published dependencies to inspect. + allow_prereleases: Whether published prereleases count as releases — + true when materializing a prerelease, so an alpha may depend on a + sibling's alpha, and false for a final version. + index: The repository's distribution index, built once by the caller + because every package in the repository has to be read to build it. + + Returns: + One entry per offending requirement, each carrying either the version to + lift it to or the reason there is none. An empty list means the package's + published metadata is releasable as it stands. + """ + project = load_pyproject(config.package_path(package) / "pyproject.toml").get( + "project", {} + ) + # Lockstep siblings pinned exactly are rewritten to the released version at + # build time by pin-lockstep, so whatever they say here is not shipped. + exact = { + canonicalize_name(config.distribution_name(target)) + for target in config.exact_pin_targets(package) + } + + upgrades: list[PinUpgrade] = [] + for requirement in published_dependencies(project): + try: + parsed = Requirement(requirement) + except InvalidRequirement: + continue + name = canonicalize_name(parsed.name) + if name in exact: + continue + sibling = index.get(name) + # A prerelease floor is lifted only for siblings: the releases of an + # outside dependency are not recorded here, and pinning one is a + # deliberate choice this tool has no business overriding. A *dev* floor + # is unpublishable whoever owns the dependency, so it always counts. + bounds = _unshippable_bounds(parsed, allow_prereleases or sibling is None) + if not bounds: + continue + # An exact pin on an unshippable version is a dead end, not a wait: no + # published version equals it, so no release of the dependency will ever + # make it satisfiable. Say so instead of sending the operator in a + # circle, and do not bother consulting the tags. + if _is_exact_pin(parsed, bounds): + upgrades.append( + PinUpgrade( + package, + requirement, + name, + bounds, + None, + "an exact pin on an unpublished version can never be " + "satisfied by a release; re-pin it by hand", + exact=True, + ) + ) + continue + if sibling is None: + upgrades.append( + PinUpgrade( + package, + requirement, + name, + bounds, + None, + f"{parsed.name} is not a package in this repository, so its " + "published versions are not known here; re-pin it by hand", + ) + ) + continue + candidates = [ + version + for version in tag_versions(config, sibling) + if allow_prereleases or is_final(version) + ] + satisfying = [ + version + for version in candidates + if parsed.specifier.contains(version, prereleases=True) + ] + if satisfying: + upgrades.append( + PinUpgrade(package, requirement, name, bounds, min(satisfying)) + ) + continue + kind = "" if allow_prereleases else "final " + upgrades.append( + PinUpgrade( + package, + requirement, + name, + bounds, + None, + f"no {kind}release of {sibling} satisfies it " + + ( + f"(newest tagged: {max(candidates)})" + if candidates + else f"({sibling} has no {kind}releases yet)" + ), + ) + ) + return upgrades + + +def pin_upgrades( + config: Config, package: str, allow_prereleases: bool +) -> list[PinUpgrade]: + """List the dependency lower bounds a package must lift to be releasable. + + Args: + config: The repository configuration. + package: The package whose published dependencies to inspect. + allow_prereleases: Whether published prereleases count as releases — + true when materializing a prerelease, so an alpha may depend on a + sibling's alpha, and false for a final version. + + Returns: + One entry per offending requirement, each carrying either the version to + lift it to or the reason there is none. An empty list means the package's + published metadata is releasable as it stands. + """ + return _pin_upgrades( + config, package, allow_prereleases, _distribution_index(config) + ) + + +def _upgrades_for( + config: Config, packages: list[str], allow_prereleases: bool +) -> list[PinUpgrade]: + """Collect the pin upgrades of a whole release batch. + + Args: + config: The repository configuration. + packages: The packages being considered for a release. + allow_prereleases: Whether published prereleases count as releases. + + Returns: + Every offending requirement across the batch, in package order. + """ + index = _distribution_index(config) + return [ + upgrade + for package in packages + for upgrade in _pin_upgrades(config, package, allow_prereleases, index) + ] + + +def blocking_pins( + config: Config, packages: list[str], allow_prereleases: bool +) -> dict[str, list[PinUpgrade]]: + """Group the pin upgrades that have no published version to lift them to. + + Args: + config: The repository configuration. + packages: The packages being considered for a release. + allow_prereleases: Whether published prereleases count as releases. + + Returns: + Package name to its unresolvable pins, for the packages that have any. + Those packages cannot be released until the pins are satisfiable. + """ + blocked: dict[str, list[PinUpgrade]] = {} + for upgrade in _upgrades_for(config, packages, allow_prereleases): + if upgrade.resolved is None: + blocked.setdefault(upgrade.package, []).append(upgrade) + return blocked + + +def describe_blockers(blocked: dict[str, list[PinUpgrade]]) -> list[str]: + """Render one human-readable line per unresolvable pin. + + Args: + blocked: The mapping returned by :func:`blocking_pins`. + + Returns: + The lines, in package order. + """ + return [ + f"{package}: {upgrade.requirement!r} — {upgrade.reason}" + for package, upgrades in blocked.items() + for upgrade in upgrades + ] + + +def blocker_advice(blocked: dict[str, list[PinUpgrade]]) -> str: + """Return what to do about a set of unresolvable pins. + + Args: + blocked: The mapping returned by :func:`blocking_pins`. + + Returns: + The closing sentence(s) for the failure message. Waiting for a release + only helps a pin a future version could satisfy; an exact pin has to be + rewritten by hand, so saying otherwise sends the operator in a circle. + """ + upgrades = [upgrade for entries in blocked.values() for upgrade in entries] + advice: list[str] = [] + if any(not upgrade.exact for upgrade in upgrades): + advice.append( + "Release the depended-on package(s) first; the next release lifts " + "those pins automatically." + ) + if any(upgrade.exact for upgrade in upgrades): + advice.append( + "An exact pin has no published version to move to, whatever is " + "released: re-pin it by hand." + ) + return " ".join(advice) + + +def _toml_basic(value: str) -> str: + """Render a string as a TOML basic (double-quoted) value. + + Args: + value: The string as parsed back out of the document. + + Returns: + The quoted spelling, with the two characters a requirement can plausibly + carry escaped. A basic string cannot hold a bare ``"``, so this — not + the parsed value — is what a requirement with a double-quoted marker + looks like in the file. + """ + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + +def _project_regions(text: str) -> list[tuple[int, int]]: + """Return the spans of the ``[project]`` table and its subtables. + + Args: + text: A ``pyproject.toml`` document. + + Returns: + ``(start, end)`` offsets covering the tables that hold published + requirements. Everything else — ``[dependency-groups]`` above all, which + :func:`published_dependencies` deliberately ignores — is outside, so a + copy of a requirement that is never published is neither counted nor + rewritten. + """ + headers = list(_TABLE_HEADER_RE.finditer(text)) + regions: list[tuple[int, int]] = [] + for index, header in enumerate(headers): + name = header["name"].strip() + if name != "project" and not name.startswith("project."): + continue + end = headers[index + 1].start() if index + 1 < len(headers) else len(text) + regions.append((header.end(), end)) + return regions + + +def _replace_requirement( + text: str, original: str, replacement: str, expected: int +) -> str: + """Rewrite a package's published copies of one requirement string. + + The requirement is matched as the whole quoted TOML value it was read from, + so nothing else that happens to contain the same substring is touched. Both + ways TOML can spell it are tried: a basic string, whose quotes and + backslashes are escaped, and a literal string, which cannot escape anything. + + Args: + text: The file content. + original: The requirement as parsed from the file. + replacement: The requirement to write instead. + expected: How many published copies the parser found — the same string + in ``dependencies`` and in an optional-dependency group is two + requirements, and both are published, so both are rewritten. + + Returns: + The updated file content. + """ + spellings = [ + (_toml_basic(original), _toml_basic(replacement)), + (f"'{original}'", f"'{replacement}'"), + ] + pieces: list[str] = [] + cursor = found = 0 + for start, end in _project_regions(text): + pieces.append(text[cursor:start]) + chunk = text[start:end] + for needle, substitute in spellings: + found += chunk.count(needle) + chunk = chunk.replace(needle, substitute) + pieces.append(chunk) + cursor = end + pieces.append(text[cursor:]) + if found != expected: + fail( + f"expected {expected} published copy(ies) of the requirement " + f"{original!r} to upgrade, found {found}; re-pin it by hand" + ) + return "".join(pieces) + + +def apply_pin_upgrades(config: Config, upgrades: list[PinUpgrade]) -> list[str]: + """Write resolved pin upgrades back into the packages' ``pyproject.toml``. + + Args: + config: The repository configuration. + upgrades: The upgrades to apply; every one must be resolved. + + Returns: + The repo-relative paths that were rewritten. + """ + # Grouped by requirement as well as by package: the same string declared in + # both ``dependencies`` and an optional-dependency group is two published + # requirements and one piece of text to rewrite, so the rewrite is told how + # many copies to expect rather than insisting on one. + by_package: dict[str, dict[str, list[PinUpgrade]]] = {} + for upgrade in upgrades: + by_package.setdefault(upgrade.package, {}).setdefault( + upgrade.requirement, [] + ).append(upgrade) + + changed: list[str] = [] + for package, requirements in by_package.items(): + pyproject = config.package_path(package) / "pyproject.toml" + text = pyproject.read_text(encoding="utf-8") + for requirement, entries in requirements.items(): + rewritten = entries[0].rewritten() + text = _replace_requirement(text, requirement, rewritten, len(entries)) + echo(f"{package}: {requirement} -> {rewritten}") + pyproject.write_text(text, encoding="utf-8") + changed.append(pyproject.relative_to(config.root).as_posix()) + return changed + + +def refresh_lock_file(config: Config) -> str | None: + """Re-resolve the repository lock file after a dependency pin changed. + + A uv workspace records its own members with no version specifier at all + (``{ name = "mypkg-base", editable = "packages/mypkg-base" }``), so lifting + a *sibling* pin leaves the lock file byte-identical; only a layout where the + dependency resolves from an index has a specifier for the lock to follow. + + Args: + config: The repository configuration. + + Returns: + The repo-relative lock file path when the re-lock changed it, or None + when the repository has no lock file or the lock did not move. + """ + lock = config.root / LOCK_FILE + if not lock.is_file(): + return None + before = lock.read_bytes() + echo(f"$ uv lock # {LOCK_FILE} follows the upgraded pins") + if subprocess.run(["uv", "lock"], cwd=config.root, check=False).returncode != 0: + fail( + f"`uv lock` failed after upgrading dependency pins; {LOCK_FILE} would " + "be left describing the old pins" + ) + if lock.read_bytes() == before: + echo(f"{LOCK_FILE} is unchanged by the lifted pins.") + return None + return LOCK_FILE + + +def upgrade_dev_pins( + config: Config, packages: list[str], allow_prereleases: bool +) -> list[str]: + """Lift every unpublishable dependency bound of the packages being released. + + Args: + config: The repository configuration. + packages: The packages being released. + allow_prereleases: Whether published prereleases count as releases. + + Returns: + The repo-relative paths that changed — the rewritten ``pyproject.toml`` + files, plus the lock file when the re-lock moved it. + """ + upgrades = _upgrades_for(config, packages, allow_prereleases) + blocked: dict[str, list[PinUpgrade]] = {} + for upgrade in upgrades: + if upgrade.resolved is None: + blocked.setdefault(upgrade.package, []).append(upgrade) + if blocked: + listing = "\n".join(f" {line}" for line in describe_blockers(blocked)) + fail( + "dependency pins that no published version satisfies cannot be " + f"materialized into a release:\n{listing}\n\n{blocker_advice(blocked)}" + ) + if not upgrades: + return [] + + # Every pin in the batch and the lock file move together or not at all: the + # rewrites run package by package, so a requirement the second package + # cannot be given would otherwise strand the first one's — and a lock file + # left describing the old pins is worse still, because a re-run finds + # nothing left to lift, does not re-lock, and could commit that pairing. + snapshot = { + path: path.read_text(encoding="utf-8") + for path in { + config.package_path(upgrade.package) / "pyproject.toml" + for upgrade in upgrades + } + } + try: + changed = apply_pin_upgrades(config, upgrades) + lock = refresh_lock_file(config) + except ReleaseError: + for path, text in snapshot.items(): + path.write_text(text, encoding="utf-8") + echo(f"restored {len(snapshot)} pyproject.toml file(s); no pin was lifted") + raise + if lock is not None: + changed.append(lock) + return changed diff --git a/packages/reflex-release/src/reflex_release/templates/workflows/dispatch_release.yml b/packages/reflex-release/src/reflex_release/templates/workflows/dispatch_release.yml index 5c3b3214472..8605049bf0a 100644 --- a/packages/reflex-release/src/reflex_release/templates/workflows/dispatch_release.yml +++ b/packages/reflex-release/src/reflex_release/templates/workflows/dispatch_release.yml @@ -12,6 +12,12 @@ name: Dispatch release # topped by an alpha (the train's fragments are already consumed). Lockstep # groups share one checkbox: their members only ever release together. # +# Materializing also lifts each released package's unshippable dependency +# floors — a *.dev pin, and a prerelease pin when the version is final — to the +# earliest published version that satisfies them, re-locking the repository so +# the pins and the lock file land with the changelog bump. A package whose +# floor no published version satisfies is held back from the release instead. +# # The package list is generated from [tool.reflex-release] — after adding or # removing a package, re-run `@@CLI@@ sync`. # @@ -92,6 +98,7 @@ jobs: shell: bash run: @@CLI@@ plan - name: Materialize changelogs + id: materialize env: ACTION: ${{ inputs.action }} RELEASES_JSON: ${{ steps.plan.outputs.releases }} @@ -104,6 +111,7 @@ jobs: ACTION: ${{ inputs.action }} REF_NAME: ${{ github.ref_name }} RELEASES_JSON: ${{ steps.plan.outputs.releases }} + REPINNED_JSON: ${{ steps.materialize.outputs.repinned }} shell: bash run: @@CLI@@ push-prerelease - name: Open release pull request @@ -113,5 +121,6 @@ jobs: ACTION: ${{ inputs.action }} REF_NAME: ${{ github.ref_name }} RELEASES_JSON: ${{ steps.plan.outputs.releases }} + REPINNED_JSON: ${{ steps.materialize.outputs.repinned }} shell: bash run: @@CLI@@ open-release-pr diff --git a/packages/reflex-release/src/reflex_release/templates/workflows/publish.yml b/packages/reflex-release/src/reflex_release/templates/workflows/publish.yml index 68cf855c312..2eb043ddf5d 100644 --- a/packages/reflex-release/src/reflex_release/templates/workflows/publish.yml +++ b/packages/reflex-release/src/reflex_release/templates/workflows/publish.yml @@ -296,7 +296,14 @@ jobs: # dependencies — it only uploads the artifact collected above. publish: needs: [prepare, collect] - if: needs.prepare.outputs.skipped != 'true' + # The status function is load-bearing. With none, GitHub applies an + # implicit success() evaluated over the whole transitive dependency + # closure rather than the direct needs — and exactly one build path ever + # runs, so the skipped one reaches this job straight through the `collect` + # written to absorb it and the upload silently never happens. + if: >- + !cancelled() && needs.collect.result == 'success' && + needs.prepare.outputs.skipped != 'true' runs-on: ubuntu-latest environment: name: pypi @@ -371,6 +378,12 @@ jobs: tag-and-release: needs: [prepare, publish] + # The same transitive implicit success() reaches this job through + # `publish`, so it needs a status function of its own. Not `!failure()`: + # a skipped publish is neither failed nor cancelled, and tolerating it + # would push the tag for a release that uploaded nothing. + if: >- + !cancelled() && needs.publish.result == 'success' runs-on: ubuntu-latest permissions: contents: write diff --git a/packages/reflex-release/src/reflex_release/templates/workflows/release_from_changelog.yml b/packages/reflex-release/src/reflex_release/templates/workflows/release_from_changelog.yml index df523d80115..eb2b2c21e95 100644 --- a/packages/reflex-release/src/reflex_release/templates/workflows/release_from_changelog.yml +++ b/packages/reflex-release/src/reflex_release/templates/workflows/release_from_changelog.yml @@ -122,23 +122,37 @@ jobs: DETECT: ${{ needs.detect.result }} PUBLISH: ${{ needs.publish.result }} PUBLISH_LAST: ${{ needs.publish-last.result }} + ANY: ${{ needs.detect.outputs.any }} ANY_LAST: ${{ needs.detect.outputs.any_last }} shell: bash run: | set -euo pipefail echo "detect: $DETECT, publish: $PUBLISH, publish-last: $PUBLISH_LAST" failed=0 - # Anything that is not success/skipped (failure, cancelled, - # timed_out, a rejected environment approval, ...) is a failed leg. - for leg in "detect:$DETECT" "publish:$PUBLISH" "publish-last:$PUBLISH_LAST"; do - case "${leg#*:}" in - success | skipped) ;; - *) - echo "::error::release leg '${leg%%:*}' ended '${leg#*:}'." - failed=1 - ;; - esac - done + # A leg is healthy only in the state detect's findings call for: + # 'success' when it had packages to publish, 'skipped' only when it + # had none. Accepting every 'skipped' would report green on a leg + # GitHub skipped despite having work — a skipped job is neither + # failed nor cancelled, so nothing else here would catch it. + check_leg() { + local name=$1 result=$2 had_work=$3 + if [[ "$result" == "success" ]]; then + return 0 + fi + if [[ "$result" == "skipped" ]]; then + if [[ "$had_work" != "true" ]]; then + return 0 + fi + echo "::error::release leg '$name' was skipped even though there were packages to publish." + else + # failure, cancelled, timed_out, a rejected environment approval... + echo "::error::release leg '$name' ended '$result'." + fi + failed=1 + } + check_leg detect "$DETECT" true + check_leg publish "$PUBLISH" "$ANY" + check_leg publish-last "$PUBLISH_LAST" "$ANY_LAST" if [[ "$failed" -eq 1 ]]; then if [[ "$DETECT" != "success" ]]; then echo "::error::Changelog detection did not complete (check for a lockstep violation) — no packages were published." diff --git a/pyi_hashes.json b/pyi_hashes.json index 32f2394e392..2a803289041 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -120,5 +120,5 @@ "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "f170ac685b6ba5892370166c80684db3", "reflex/__init__.pyi": "6a1a667017c016e586c3af7f8486f329", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", - "reflex/experimental/memo.pyi": "e73fd8bffa1c5bcb72478ef84534bd05" + "reflex/experimental/memo.pyi": "12a397fb82ef96ea21bd6ede849d0e71" } diff --git a/reflex/app.py b/reflex/app.py index e7c88222b79..a24116d42b0 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -1388,22 +1388,29 @@ def _find_route_conflict( constants.RouteRegex.DOUBLE_SEGMENT, constants.RouteRegex.DOUBLE_CATCHALL_SEGMENT, ) + replaced_new_route = replace_brackets_with_keywords(new_route) for route in existing_routes: replaced_route = replace_brackets_with_keywords(route) - for rw, r, nr in zip( + for rw, nrw, r, nr in zip( replaced_route.split("/"), + replaced_new_route.split("/"), route.split("/"), new_route.split("/"), strict=False, ): - if rw in segments and r != nr: + if r == nr: + continue + if rw in segments and nrw in segments: + # Two dynamic segments with different names cannot share + # the same position in the route tree. return route, r, nr - if rw not in segments and r != nr: - # if the section being compared in both routes is not a dynamic segment(i.e not wrapped in brackets) - # then we are guaranteed that the route is valid and there's no need checking the rest. - # eg. /posts/[id]/info/[slug1] and /posts/[id]/info1/[slug1] is always going to be valid since - # info1 will break away into its own tree. - break + # A static segment differing from the other route's segment + # (static or dynamic) splits into its own subtree, so the rest + # of the route cannot conflict. e.g. /posts/[id]/info/[slug1] + # and /posts/[id]/info1/[slug1] is always going to be valid + # since info1 will break away into its own tree; likewise + # /posts/all is a legal static sibling of /posts/[id]. + break return None def _setup_admin_dash(self): diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 7a90cce2f2e..3ba8746316e 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -236,11 +236,12 @@ def _compile_contexts(state: type[BaseState] | None, theme: Component | None) -> ) -def _compile_page(component: BaseComponent) -> str: +def _compile_page(component: BaseComponent, route: str) -> str: """Compile the component. Args: component: The component to compile. + route: The route the page is compiled for. Returns: The compiled component. @@ -256,6 +257,7 @@ def _compile_page(component: BaseComponent) -> str: custom_codes=component._get_all_custom_code(), hooks=component._get_all_hooks(), render=component.render(), + route=route, ) @@ -741,7 +743,7 @@ def compile_page(path: str, component: BaseComponent) -> tuple[str, str]: output_path = utils.get_page_path(path) # Add the style to the component. - code = _compile_page(component) + code = _compile_page(component, path) return output_path, code @@ -769,6 +771,7 @@ def compile_page_from_context(page_ctx: PageContext) -> tuple[str, str]: custom_codes=page_ctx.custom_code_dict(), hooks=page_ctx.hooks, render=page_ctx.root_component.render(), + route=page_ctx.route, ) return output_path, code diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index f671a9ed6ea..452fd512622 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -136,6 +136,12 @@ def _should_memoize(component: Component) -> bool: are evaluated from their own props/triggers; descendants are visited independently by the walker. + Explicitly memoized (``@rx.memo``) components are no exception: React's + ``memo`` only spares their own subtree, so state bound at the call site + still needs a wrapper to keep the hooks out of the page module. The + wrappers this pass generates are themselves memo components and opt out + via ``MemoizationDisposition.NEVER``. + Args: component: The candidate component. diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 110bb6362ee..e2f11c0f7df 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -467,6 +467,7 @@ def compile_experimental_component_memo( "name": memo_paths.library_and_symbol( definition.source_module, definition.export_name )[1], + "display_name": definition.display_name or definition.export_name, "signature": DestructuredArg( fields=tuple(signature_fields), rest=rest_param.placeholder_name if rest_param is not None else None, diff --git a/reflex/state.py b/reflex/state.py index a24b6f376d3..452c80e7109 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -2456,6 +2456,9 @@ class OnLoadInternalState(State): This is a separate substate to avoid deserializing the entire state tree for every page navigation. """ + # A newer navigation supersedes the previous unfinished on_load chain for + # the same client token, cancelling its stale work (#6593). + @event(supersedes=True) def on_load_internal(self) -> list[Event | EventSpec | event.EventCallback] | None: """Queue on_load handlers for the current page. diff --git a/tests/integration/tests_playwright/test_memo.py b/tests/integration/tests_playwright/test_memo.py index 64a65bfadcf..94003888bca 100644 --- a/tests/integration/tests_playwright/test_memo.py +++ b/tests/integration/tests_playwright/test_memo.py @@ -91,6 +91,16 @@ def keyed_row(label: rx.Var[str]) -> rx.Component: # element id so each row is locatable after reordering. return rx.input(id=label) + @rx.memo + def framed(title: rx.Var[str], children: rx.Var[rx.Component]) -> rx.Component: + # Stateful prop *and* a children slot: the auto-memoize pass wraps the + # call site so the state hooks live in the generated wrapper, which + # passes the page-rendered children straight through. + return rx.vstack( + rx.text(title, id="framed-title"), + rx.box(children, id="framed-slot"), + ) + @rx.memo(wrapper=None) def unwrapped_label(value: rx.Var[str]) -> rx.Component: # Compiled without the React ``memo`` wrapper: a bare function @@ -213,6 +223,10 @@ def index() -> rx.Component: id="keyed-rows", ), unwrapped_label(value=MemoState.last_value), + framed( + rx.text(MemoState.last_value, id="framed-child"), + title=MemoState.last_value, + ), rx.box( rx.foreach(MemoState.order, scoped_row), id="scoped-rows", @@ -358,6 +372,32 @@ def test_memo_key_preserves_identity_across_reorder( expect(page.locator(f"#{row_id}")).to_have_value(row_id.upper()) +def test_memo_stateful_prop_and_children_update( + memo_app: AppHarness, page: Page +) -> None: + """A memo bound to state renders its children and follows state changes. + + The call site binds a state Var to a prop and passes children positionally, + so the auto-memoize pass hoists the state hooks into a generated wrapper + that feeds both the prop and the page-rendered children. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + _load_page(page, memo_app) + + expect(page.locator("#framed-title")).to_have_text("") + expect(page.locator("#framed-child")).to_have_text("") + + page.locator("#memo-input").fill("framed_update") + + expect(page.locator("#framed-title")).to_have_text("framed_update") + expect(page.locator("#framed-slot").locator("#framed-child")).to_have_text( + "framed_update" + ) + + def test_memo_wrapper_none_renders_and_updates( memo_app: AppHarness, page: Page ) -> None: diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index d6994a100f8..efcfff556e3 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -1425,3 +1425,139 @@ def test_context_template_owner_stack_pin(disable_owner_stacks: bool): assert "REFLEX_REACT_OWNER_STACKS" in rendered # The trade-off must be stated where a reader of the output will see it. assert "captureOwnerStack" in rendered + + +def test_context_template_names_contexts_for_devtools(): + """Every context in the generated module carries a ``displayName``. + + React DevTools labels a provider from its context's ``displayName``; + without one the whole provider stack renders as ``Context.Provider``. + """ + from reflex_base.compiler.templates import context_template + + rendered = context_template( + is_dev_mode=True, + default_color_mode='"light"', + initial_state={ + "reflex___state____state": {}, + "reflex___state____state.demo_state": {}, + }, + state_name="reflex___state____state", + ) + + for context_name in ( + "ColorModeContext", + "UploadFilesContext", + "DispatchContext", + "EventLoopContext", + ): + assert f'{context_name}.displayName = "{context_name}";' in rendered + + # State contexts are named for the Python state they carry, using the + # dotted state name rather than the mangled JS identifier. + assert ( + "StateContexts.reflex___state____state.displayName = " + '"StateContext(reflex___state____state)";' in rendered + ) + assert ( + "StateContexts.reflex___state____state__demo_state.displayName = " + '"StateContext(reflex___state____state.demo_state)";' in rendered + ) + + +def test_context_template_client_side_component_is_named(): + """``ClientSide`` returns a named component, not an anonymous arrow.""" + from reflex_base.compiler.templates import context_template + + rendered = context_template(is_dev_mode=True, default_color_mode='"light"') + + assert "function ClientSideComponent({ children, ...props })" in rendered + assert ( + "ClientSideComponent.displayName = name ? `ClientSide(${name})` : " + '"ClientSide";' in rendered + ) + assert "return ClientSideComponent;" in rendered + + +def _render_page_template(route: str = "test/[dynamic]") -> str: + """Render the page template for ``route``. + + Args: + route: The route to compile the page for. + + Returns: + The rendered page module source. + """ + from reflex_base.compiler.templates import page_template + + return page_template( + imports=[], + dynamic_imports=[], + custom_codes=[], + hooks={}, + render=rx.el.div("hi").render(), + route=route, + ) + + +def test_page_template_display_name_carries_the_route(): + """Every page compiles to ``Component``; its route is in the display name.""" + assert ( + 'Component.displayName = "Component(test/[dynamic])";' + in _render_page_template() + ) + + +def test_page_template_without_a_route_omits_the_display_name(): + """``route`` is optional so out-of-tree callers of the shipped template work. + + ``page_template`` is a public symbol in ``reflex-base``; a downstream + compiler plugin that predates the parameter must keep working. Without a + route there is no name worth showing, so the assignment is skipped entirely + rather than emitting a contentless ``Component()`` label. + """ + from reflex_base.compiler.templates import page_template + + rendered = page_template( + imports=[], + dynamic_imports=[], + custom_codes=[], + hooks={}, + render=rx.el.div("hi").render(), + ) + + assert "displayName" not in rendered + assert "export default Component;" in rendered + + +def test_page_template_exports_the_component_binding_separately(): + """The page component is declared and named before it is exported. + + React Router rewrites an exported function *declaration* into a function + *expression* wrapped in ``UNSAFE_withComponentProps`` + (``decorateComponentExportsWithProps``), which leaves no module-scope + binding behind. A trailing ``Component.displayName = ...`` would then throw + ``ReferenceError: Component is not defined`` when the route module loads, + breaking every page. Exporting the identifier keeps the declaration intact. + """ + rendered = _render_page_template() + + assert "export default function Component" not in rendered + assert "\nfunction Component() {" in rendered + assert rendered.index("Component.displayName") < rendered.index( + "export default Component;" + ) + + +def test_compile_page_passes_its_route_to_the_template(): + """The route reaches the template through the legacy page compile path.""" + _, code = compiler.compile_page("about", rx.el.div("hi")) + + assert 'Component.displayName = "Component(about)";' in code + + +def test_no_ssr_dynamic_import_names_the_client_side_wrapper(): + """A client-only component passes its tag through to the wrapper's name.""" + from reflex_components_plotly.plotly import Plotly + + assert Plotly.create()._get_dynamic_imports().endswith(', "Plot")') diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index d4ce5f3ced4..81863d9e47d 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -138,6 +138,12 @@ def record(self, index: int, form_data: dict): self.value = f"{index}:{form_data}" +class MemoTriggerState(BaseState): + @rx.event + def ping(self): + """No-op handler for event-trigger memoization tests.""" + + @dataclasses.dataclass(slots=True) class FakePage: route: str @@ -570,6 +576,228 @@ def test_generated_memo_component_is_not_itself_memoized() -> None: assert not _should_memoize(wrapper) +def test_auto_memo_wrapper_opts_out_of_being_memoized() -> None: + """Generated wrappers carry ``NEVER`` so the pass can't wrap them again. + + The wrapper is itself a ``MemoComponent``; without the opt-out, a wrapper + built around a stateful component would look eligible to the heuristic and + the pass would wrap wrappers forever. + """ + from reflex_base.event import EventChain + + wrapper_factory, definition = create_passthrough_component_memo( + WithProp.create(label=STATE_VAR) + ) + assert definition.auto_memo_wrapper + wrapper = wrapper_factory() + assert isinstance(wrapper, MemoComponent) + assert wrapper._memoization_mode.disposition is MemoizationDisposition.NEVER + assert not _should_memoize(wrapper) + + # Even a signal the heuristic normally treats as eligible must not win. + wrapper.event_triggers["on_click"] = Var(_js_expr="test_event")._replace( + _var_type=EventChain, + merge_var_data=VarData(state="TestState"), + ) + assert not _should_memoize(wrapper) + + +def test_user_memo_with_stateful_prop_is_auto_memoized() -> None: + """An ``@rx.memo`` component bound to state gets its own memo wrapper. + + Regression: ``MemoComponent`` used to opt out of auto-memoization + wholesale, so binding a state Var at the call site left the state + ``useContext`` in the page module — every state change then re-rendered + the whole page, including static siblings. The hooks must live in a + generated wrapper instead, which re-renders on state change and lets + React's ``memo`` skip the wrapped component unless a prop value changed. + """ + from reflex.compiler.compiler import compile_memo_components + + @rx.memo + def stateful_card(label: rx.Var[str]) -> Component: + return WithProp.create(label=label) + + ctx, page_ctx = _compile_single_page( + lambda: Fragment.create( + Plain.create(LiteralVar.create("static sibling")), + stateful_card(label=SpecialFormMemoState.value), + ) + ) + + page_output = page_ctx.output_code + assert page_output is not None + assert "useContext(StateContexts" not in page_output + assert not any("useContext(StateContexts" in hook for hook in page_ctx.hooks) + + (definition,) = ctx.auto_memo_components.values() + assert isinstance(definition, MemoComponentDefinition) + wrapped = definition.component + assert isinstance(wrapped, MemoComponent) + assert wrapped.tag is not None + assert wrapped.tag.startswith("StatefulCard") + + # The page renders the wrapper; the wrapper renders the user's memo with + # the state-bound prop. + assert f"jsx({definition.export_name}," in page_output + memo_files, _memo_imports = compile_memo_components( + memos=tuple(ctx.auto_memo_components.values()), + ) + wrapper_code = next( + code for path, code in memo_files if definition.export_name in path + ) + assert "useContext(StateContexts" in wrapper_code + assert f"jsx({wrapped.tag}," in wrapper_code + + +def test_user_memo_with_static_props_is_not_auto_memoized() -> None: + """A memo with no reactive props stays inline — no wrapper is generated.""" + + @rx.memo + def static_card(label: rx.Var[str]) -> Component: + return WithProp.create(label=label) + + ctx, page_ctx = _compile_single_page( + lambda: Fragment.create(static_card(label="static")) + ) + + assert not ctx.auto_memo_components + page_output = page_ctx.output_code + assert page_output is not None + assert f"jsx({static_card(label='static').tag}," in page_output + + +def test_user_memo_event_trigger_usecallback_leaves_page_scope() -> None: + """A memo's event-handler prop is memoized inside the generated wrapper. + + An inline arrow recreated on every page render defeats the ``memo`` the + user asked for; the wrapper hoists it into a ``useCallback`` living beside + the state hooks it depends on. + """ + from reflex.compiler.compiler import compile_memo_components + + @rx.memo + def clickable( + on_click: rx.EventHandler[rx.event.no_args_event_spec], + ) -> Component: + return Plain.create(on_click=on_click) + + ctx, page_ctx = _compile_single_page( + lambda: Fragment.create(clickable(on_click=MemoTriggerState.ping)) + ) + + assert not any("useCallback" in hook for hook in page_ctx.hooks) + (definition,) = ctx.auto_memo_components.values() + memo_files, _memo_imports = compile_memo_components( + memos=tuple(ctx.auto_memo_components.values()), + ) + wrapper_code = next( + code for path, code in memo_files if definition.export_name in path + ) + assert "useCallback" in wrapper_code + + +def test_user_memo_children_render_in_page_scope() -> None: + """The wrapper passes children through instead of capturing them. + + Children keep compiling in the page module (so their own reactive parts + get independent wrappers), and the memo body only holds the ``{children}`` + hole plus the state-bound props. + """ + from reflex.compiler.compiler import compile_memo_components + + @rx.memo + def slot_card(label: rx.Var[str], children: rx.Var[Component]) -> Component: + return WithProp.create(children, label=label) + + ctx, page_ctx = _compile_single_page( + lambda: Fragment.create( + slot_card( + Plain.create(LiteralVar.create("static child")), + label=SpecialFormMemoState.value, + ) + ) + ) + + page_output = page_ctx.output_code + assert page_output is not None + assert "useContext(StateContexts" not in page_output + assert 'jsx(Plain,{},"static child")' in page_output + + wrapper_definition = next( + definition + for definition in ctx.auto_memo_components.values() + if isinstance(definition, MemoComponentDefinition) + and isinstance(definition.component, MemoComponent) + ) + memo_files, _memo_imports = compile_memo_components( + memos=tuple(ctx.auto_memo_components.values()), + ) + wrapper_code = next( + code for path, code in memo_files if wrapper_definition.export_name in path + ) + inner_tag = wrapper_definition.component.tag + assert f"jsx({inner_tag}," in wrapper_code + # The hole, not the authored child, is what the memo body renders. + assert ",children)" in wrapper_code + assert "static child" not in wrapper_code + + +def test_user_memo_inside_foreach_reads_its_loop_var_from_the_scope() -> None: + """A user memo with a loop-var prop is wrapped, and the wrapper reads the scope. + + Foreach owns the snapshot for the item subtree, so the wrapper module lands + beside it rather than on the page, and it resolves ``item`` through + ``useScopedValue`` instead of closing over the callback parameter. + """ + from reflex.compiler.compiler import compile_memo_components + + @rx.memo + def row(label: rx.Var[str]) -> Component: + return WithProp.create(label=label) + + ctx, page_ctx = _compile_single_page( + lambda: rx.box( + rx.foreach(SpecialFormMemoState.items, lambda item: row(label=item)) + ) + ) + + definitions = list(ctx.auto_memo_components.values()) + (foreach_definition,) = ( + definition + for definition in definitions + if isinstance(definition.component, Foreach) + ) + (wrapper_definition,) = ( + definition + for definition in definitions + if isinstance(definition.component, MemoComponent) + ) + assert isinstance(foreach_definition, MemoComponentDefinition) + assert isinstance(wrapper_definition, MemoComponentDefinition) + assert wrapper_definition.auto_memo_wrapper + + memo_files, _memo_imports = compile_memo_components( + memos=tuple(ctx.auto_memo_components.values()), + ) + code_by_export = { + definition.export_name: next( + code for path, code in memo_files if definition.export_name in path + ) + for definition in (foreach_definition, wrapper_definition) + } + + wrapper_code = code_by_export[wrapper_definition.export_name] + assert 'useScopedValue("item_rx_state_")' in wrapper_code + assert f"jsx({row(label='x').tag},{{label:item_rx_state_}}" in wrapper_code + + # The foreach body mounts the wrapper inside the per-item provider, and the + # page module is left with neither the loop var nor the item subtree. + foreach_code = code_by_export[foreach_definition.export_name] + assert f"jsx({wrapper_definition.export_name}," in foreach_code + assert not any("useScopedValue" in hook for hook in page_ctx.hooks) + + def test_passthrough_memo_skips_hole_for_childless_component() -> None: """Childless components own their JSX output, so the wrapper must not inject a ``{children}`` hole. @@ -624,6 +852,28 @@ def test_generated_memo_component_renders_as_its_exported_tag() -> None: assert wrapper.render()["name"] == tag +def test_auto_memo_display_name_is_the_wrapped_python_class() -> None: + """Auto-memo wrappers are labelled with the class they wrap, not their tag. + + ``export_name`` carries a content hash so identically-rendering subtrees + collapse to one module; that name is unreadable in the React DevTools tree, + so the memo's ``displayName`` names the Python component instead. + """ + from reflex.compiler.compiler import compile_memo_components + + ctx, _ = _compile_single_page( + lambda: Fragment.create(WithProp.create(label=STATE_VAR)) + ) + + definitions = list(ctx.auto_memo_components.values()) + assert [definition.display_name for definition in definitions] == ["WithProp"] + + memo_code = "\n".join( + code for _, code in compile_memo_components(memos=tuple(definitions))[0] + ) + assert f'{definitions[0].export_name}.displayName = "WithProp";' in memo_code + + def test_passthrough_memo_definitions_are_not_shared_globally(monkeypatch) -> None: """Repeated tags across compiles rebuild their passthrough definitions. diff --git a/tests/units/components/test_memo.py b/tests/units/components/test_memo.py index f1ecea1fc10..fc2eb8bbec1 100644 --- a/tests/units/components/test_memo.py +++ b/tests/units/components/test_memo.py @@ -1156,6 +1156,61 @@ def inline_wrapped(label: rx.Var[str]) -> rx.Component: ) +def test_component_memo_sets_display_name_from_python_name(): + """A ``@rx.memo`` component is labelled with its Python function name. + + ``memo()`` erases the name JS would otherwise infer from the assignment, + so React DevTools shows ``Anonymous`` without an explicit ``displayName``. + """ + + @rx.memo + def named_widget(label: rx.Var[str]) -> rx.Component: + return rx.text(label) + + definition = MEMOS["NamedWidget", __name__] + assert isinstance(definition, MemoComponentDefinition) + + files, _ = compiler.compile_memo_components((definition,)) + code = "\n".join(c for _, c in files) + sym = memo_paths.mirrored_symbol("NamedWidget", __name__) + assert f'{sym}.displayName = "NamedWidget";' in code + + +def test_component_memo_display_name_survives_custom_wrapper(): + """The ``displayName`` is assigned on the exported symbol, whatever wraps it.""" + track_render = FunctionStringVar.create( + "trackRender", + _var_data=VarData(imports={"my-render-lib": [ImportVar(tag="trackRender")]}), + ) + + @rx.memo(wrapper=track_render) + def wrapped_widget(label: rx.Var[str]) -> rx.Component: + return rx.text(label) + + files, _ = compiler.compile_memo_components((MEMOS["WrappedWidget", __name__],)) + code = "\n".join(c for _, c in files) + sym = memo_paths.mirrored_symbol("WrappedWidget", __name__) + assert f"export const {sym} = trackRender((" in code + assert f'{sym}.displayName = "WrappedWidget";' in code + + +def test_component_memo_display_name_is_escaped(): + """A display name is emitted as a JS string literal, never raw.""" + definition = MemoComponentDefinition( + fn=lambda: None, + python_name="quoted", + params=(), + export_name="Quoted", + _component=_LazyBody.ready(rx.text("hi")), + passthrough_hole_child=None, + display_name='Weird"Name', + ) + + files, _ = compiler.compile_memo_components((definition,)) + code = "\n".join(c for _, c in files) + assert 'Quoted.displayName = "Weird\\"Name";' in code + + def test_component_memo_wrapper_none_in_unmirrored_module(): """The per-name fallback module honors ``wrapper=None`` too.""" definition = MemoComponentDefinition( diff --git a/tests/units/reflex_base/event/processor/test_event_processor.py b/tests/units/reflex_base/event/processor/test_event_processor.py index d5dda19dca3..a66de81086e 100644 --- a/tests/units/reflex_base/event/processor/test_event_processor.py +++ b/tests/units/reflex_base/event/processor/test_event_processor.py @@ -2,6 +2,7 @@ import asyncio import contextlib +import logging from typing import Any import pytest @@ -127,6 +128,72 @@ async def _background_slow_logging_handler(value: str = "default"): _background_slow_logging_handler._reflex_background_task = True # type: ignore[attr-defined] +# Gates for coordinating supersede tests; tests create loop-local events here. +_GATES: dict[str, asyncio.Event] = {} + + +async def _gated_logging_handler(value: str = "default"): + """Wait for the gate named ``value`` (if any), then log. + + Args: + value: The value to log; also names the gate to wait for. + """ + gate = _GATES.get(value) + if gate is not None: + await gate.wait() + _CALL_LOG.append({"value": value}) + + +async def _cancellable_load_handler(value: str = "default"): + """Log ``value``; if a gate named ``value`` exists, signal it and block. + + Args: + value: The value to log; also names the gate to signal. + """ + gate = _GATES.get(value) + if gate is not None: + gate.set() + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + _CALL_LOG.append({"value": f"{value}_cancelled"}) + raise + _CALL_LOG.append({"value": value}) + + +async def _resurrecting_load_handler(value: str = "default"): + """Signal the gate named ``value``, block, and chain an event when cancelled. + + Args: + value: The value naming the gate to signal. + """ + gate = _GATES.get(value) + if gate is not None: + gate.set() + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + ctx = EventContext.get() + await ctx.enqueue(Event.from_event_type(logging_event("resurrected"))[0]) + raise + + +async def _superseding_root_handler(value: str = "default", child: str = "load"): + """A superseding handler that chains a load event, like on_load_internal. + + Args: + value: Label forwarded to the chained load event. + child: Which child handler to chain ("load" or "resurrect"). + """ + ctx = EventContext.get() + child_event = ( + resurrecting_load_event if child == "resurrect" else cancellable_load_event + ) + await ctx.enqueue(Event.from_event_type(child_event(value))[0]) + + +_superseding_root_handler._reflex_supersedes = True # type: ignore[attr-defined] + noop_event = EventHandler(fn=_noop_handler) slow_event = EventHandler(fn=_slow_handler) @@ -141,6 +208,10 @@ async def _background_slow_logging_handler(value: str = "default"): background_slow_logging_event = EventHandler(fn=_background_slow_logging_handler) background_then_normal_event = EventHandler(fn=_background_then_normal_handler) error_then_logging_event = EventHandler(fn=_error_then_logging_handler) +gated_logging_event = EventHandler(fn=_gated_logging_handler) +cancellable_load_event = EventHandler(fn=_cancellable_load_handler) +resurrecting_load_event = EventHandler(fn=_resurrecting_load_handler) +superseding_root_event = EventHandler(fn=_superseding_root_handler) @pytest.fixture(autouse=True) @@ -151,6 +222,7 @@ def _register_handlers(forked_registration_context: RegistrationContext): forked_registration_context: Isolated registration context for the test. """ _CALL_LOG.clear() + _GATES.clear() for handler in ( noop_event, slow_event, @@ -165,6 +237,10 @@ def _register_handlers(forked_registration_context: RegistrationContext): background_slow_logging_event, background_then_normal_event, error_then_logging_event, + gated_logging_event, + cancellable_load_event, + resurrecting_load_event, + superseding_root_event, ): RegistrationContext.register_event_handler(handler) @@ -485,6 +561,37 @@ def _catch(ex: Exception) -> None: assert isinstance(caught[0], RuntimeError) +async def test_exception_handler_can_chain_recovery_events(token: str): + """The backend exception handler task can enqueue recovery events. + + The failed future must not be retained in ``_futures`` during exception + recovery, or the recovery event would find its done (non-cancelled) parent + and ``add_child`` would raise instead of queuing the event. + + Args: + token: The client token. + """ + + class _RecoveringProcessor(EventProcessor): + async def _handle_backend_exception( + self, ex: Exception, ev_ctx: EventContext | None = None + ) -> None: + if ev_ctx is not None: + EventContext.set(ev_ctx) + await EventContext.get().enqueue( + Event.from_event_type(logging_event("recovered"))[0] + ) + + ep = _RecoveringProcessor( + backend_exception_handler=lambda ex: None, graceful_shutdown_timeout=2 + ) + ep.configure() + async with ep: + await ep.enqueue(token, Event.from_event_type(error_event())[0]) + await asyncio.wait_for(ep.join(), timeout=1) + assert _CALL_LOG == [{"value": "recovered"}] + + async def test_error_does_not_stop_queue( processor: EventProcessor, token: str, @@ -792,3 +899,151 @@ async def _watcher(): # noqa: RUF029 collected = [v async for v in _stream_queue_until_done(queue, _watcher())] assert collected == [99] + + +async def _drain_superseded(ep: EventProcessor) -> None: + """Wait for done callbacks to clean the supersession tracking. + + Callback cleanup completes in a bounded number of event-loop ticks, so + failing to drain within the allotted ticks is a real bug, not a timing + flake. + + Args: + ep: The event processor to wait on. + + Raises: + AssertionError: If the tracking dict is not cleaned up in time. + """ + for _ in range(100): + if not ep._superseded: + return + await asyncio.sleep(0) + msg = f"supersession tracking was not cleaned up: {ep._superseded}" + raise AssertionError(msg) + + +async def test_superseding_event_cancels_previous_chain( + processor: EventProcessor, + token: str, +): + """A newer superseding event cancels the previous running chain (#6593). + + Args: + processor: The event processor fixture. + token: The client token. + """ + _GATES["stale"] = asyncio.Event() + processor.configure() + async with processor as ep: + stale = await ep.enqueue( + token, Event.from_event_type(superseding_root_event("stale"))[0] + ) + await asyncio.wait_for(_GATES["stale"].wait(), timeout=1) + # The root handler returned after chaining, but the chain is live. + assert stale.done() + assert not stale.all_done() + + current = await ep.enqueue( + token, Event.from_event_type(superseding_root_event("fresh"))[0] + ) + await asyncio.wait_for(current.wait_all(), timeout=1) + await _drain_superseded(ep) + assert ep._superseded == {} + + assert _CALL_LOG == [{"value": "stale_cancelled"}, {"value": "fresh"}] + + +async def test_superseding_event_logs_debug_on_cancel( + processor: EventProcessor, + token: str, + caplog: pytest.LogCaptureFixture, +): + """Cancelling a stale chain emits a debug log naming the handler (#6593). + + Args: + processor: The event processor fixture. + token: The client token. + caplog: Pytest log capture fixture. + """ + caplog.set_level( + logging.DEBUG, logger="reflex_base.event.processor.event_processor" + ) + _GATES["stale"] = asyncio.Event() + stale_event = Event.from_event_type(superseding_root_event("stale"))[0] + processor.configure() + async with processor as ep: + await ep.enqueue(token, stale_event) + await asyncio.wait_for(_GATES["stale"].wait(), timeout=1) + # Nothing was superseded yet, so nothing is logged. + assert caplog.messages == [] + + current = await ep.enqueue( + token, Event.from_event_type(superseding_root_event("fresh"))[0] + ) + await asyncio.wait_for(current.wait_all(), timeout=1) + + assert len(caplog.messages) == 1 + assert stale_event.name in caplog.messages[0] + assert token in caplog.messages[0] + + +async def test_superseding_event_skips_queued_stale_chain( + processor: EventProcessor, + token: str, +): + """A superseded chain that never started is skipped, not executed. + + Args: + processor: The event processor fixture. + token: The client token. + """ + _GATES["blocker"] = asyncio.Event() + processor.configure() + async with processor as ep: + await ep.enqueue( + token, Event.from_event_type(gated_logging_event("blocker"))[0] + ) + stale = await ep.enqueue( + token, Event.from_event_type(superseding_root_event("stale"))[0] + ) + # Let the stale entry reach the per-token queue before superseding it. + await ep.join(timeout=1) + current = await ep.enqueue( + token, Event.from_event_type(superseding_root_event("fresh"))[0] + ) + assert stale.cancelled() + _GATES["blocker"].set() + await asyncio.wait_for(current.wait_all(), timeout=1) + + # The stale root handler never ran at all. + assert _CALL_LOG == [{"value": "blocker"}, {"value": "fresh"}] + + +async def test_superseded_chain_cannot_chain_new_events( + processor: EventProcessor, + token: str, +): + """A cancelled chain cannot resurrect itself by chaining during unwind. + + Args: + processor: The event processor fixture. + token: The client token. + """ + _GATES["stale"] = asyncio.Event() + processor.configure() + async with processor as ep: + await ep.enqueue( + token, + Event.from_event_type(superseding_root_event("stale", "resurrect"))[0], + ) + await asyncio.wait_for(_GATES["stale"].wait(), timeout=1) + + current = await ep.enqueue( + token, Event.from_event_type(superseding_root_event("fresh"))[0] + ) + await asyncio.wait_for(current.wait_all(), timeout=1) + # Drain anything the unwinding stale chain may have enqueued. + await asyncio.wait_for(ep.join(), timeout=1) + + assert {"value": "resurrected"} not in _CALL_LOG + assert {"value": "fresh"} in _CALL_LOG diff --git a/tests/units/reflex_base/utils/test_types.py b/tests/units/reflex_base/utils/test_types.py index e4ab9dece45..1c8a0837002 100644 --- a/tests/units/reflex_base/utils/test_types.py +++ b/tests/units/reflex_base/utils/test_types.py @@ -1,7 +1,22 @@ """Tests for reflex_base.utils.types.""" -from reflex_base.utils.types import ASGIApp, Message, Receive, Scope, Send -from typing_extensions import TypeAliasType +from collections.abc import Callable + +from reflex_base.utils.types import ( + ASGIApp, + Message, + Receive, + Scope, + Send, + resolve_type_alias, +) +from typing_extensions import ParamSpec, TypeAliasType, TypeVarTuple, Unpack + +P = ParamSpec("P") +Ts = TypeVarTuple("Ts") +Handlers = TypeAliasType( + "Handlers", tuple[Callable[P, int], Unpack[Ts]], type_params=(P, Ts) +) def test_asgi_aliases_keep_their_names(): @@ -14,3 +29,13 @@ def test_asgi_aliases_keep_their_names(): assert Receive.__name__ == "Receive" assert Send.__name__ == "Send" assert ASGIApp.__name__ == "ASGIApp" + + +def test_resolve_type_alias_substitutes_param_spec(): + """A ParamSpec is substituted even next to a TypeVarTuple. + + That combination falls back to manual substitution on 3.10 and 3.11, which + has to treat a ParamSpec as a type parameter too. + """ + resolved = resolve_type_alias(Handlers[[str], bool, float]) + assert resolved == tuple[Callable[[str], int], bool, float] diff --git a/tests/units/reflex_base/vars/test_base.py b/tests/units/reflex_base/vars/test_base.py index b0ff880aa20..6d4cf4b11c4 100644 --- a/tests/units/reflex_base/vars/test_base.py +++ b/tests/units/reflex_base/vars/test_base.py @@ -1,10 +1,17 @@ """Tests for reflex_base.vars.base state metaclass field handling.""" import threading -from typing import Any +import typing +from typing import Any, Literal, TypeVar +import pytest from reflex_base.utils.types import get_field_type -from reflex_base.vars.base import EvenMoreBasicBaseState, field +from reflex_base.vars.base import EvenMoreBasicBaseState, Var, field +from reflex_base.vars.object import ObjectVar +from reflex_base.vars.sequence import ArrayVar, StringVar +from typing_extensions import TypeAliasType, TypeVarTuple, Unpack + +from reflex.state import State _MARKER_ATTR = "_marker" @@ -87,3 +94,90 @@ class MyState(EvenMoreBasicBaseState): rebuilt = MyState.get_fields()["name"] assert rebuilt._check is check # pyright: ignore[reportAttributeAccessIssue] + + +def _type_alias_types() -> list[type]: + native = getattr(typing, "TypeAliasType", None) + return ( + [TypeAliasType] if native in (None, TypeAliasType) else [TypeAliasType, native] + ) + + +@pytest.mark.parametrize("alias_cls", _type_alias_types()) +def test_guess_type_resolves_type_alias(alias_cls: type) -> None: + """A TypeAliasType (PEP 695 ``type`` statement) resolves to its value. + + State var annotations like ``type Key = Literal[...]`` reach guess_type as + a TypeAliasType, which must be unwrapped instead of raising TypeError. + """ + alias = alias_cls("ChartKey", Literal["day", "week"]) + + var = Var(_js_expr="key", _var_type=alias).guess_type() + assert isinstance(var, StringVar) + assert var._var_type == Literal["day", "week"] + + optional_var = Var(_js_expr="key", _var_type=alias | None).guess_type() + assert isinstance(optional_var, StringVar) + + +@pytest.mark.parametrize("alias_cls", _type_alias_types()) +def test_guess_type_resolves_parameterized_type_alias(alias_cls: type) -> None: + """A subscripted generic alias (``type Keys[T] = list[T]``) resolves. + + The subscription keeps the TypeAliasType as the origin, so resolution has + to substitute the alias's type parameters into its value. + """ + t = TypeVar("t") + keys = alias_cls("Keys", list[t], type_params=(t,)) # pyright: ignore[reportGeneralTypeIssues] + + var = Var(_js_expr="keys", _var_type=keys[str]).guess_type() + assert isinstance(var, ArrayVar) + assert var._var_type == list[str] + + optional_var = Var(_js_expr="keys", _var_type=keys[str] | None).guess_type() + assert isinstance(optional_var, ArrayVar) + + k = TypeVar("k") + v = TypeVar("v") + # value's __parameters__ order (v, k) differs from type_params (k, v) + pair = alias_cls("Pair", dict[v, k], type_params=(k, v)) # pyright: ignore[reportGeneralTypeIssues] + pair_var = Var(_js_expr="pair", _var_type=pair[str, int]).guess_type() + assert isinstance(pair_var, ObjectVar) + assert pair_var._var_type == dict[int, str] + + +@pytest.mark.parametrize("alias_cls", _type_alias_types()) +def test_guess_type_resolves_variadic_type_alias(alias_cls: type) -> None: + """A variadic alias (``type Tup[*Ts] = tuple[*Ts]``) keeps all arguments. + + The TypeVarTuple must absorb every remaining subscription argument, not + just the one a plain positional zip would pair it with. + """ + ts = TypeVarTuple("ts") + tup = alias_cls("Tup", tuple[Unpack[ts]], type_params=(ts,)) # pyright: ignore[reportGeneralTypeIssues] + var = Var(_js_expr="t", _var_type=tup[str, int]).guess_type() + assert isinstance(var, ArrayVar) + assert var._var_type == tuple[str, int] + + t = TypeVar("t") + prefixed = alias_cls("Prefixed", dict[t, tuple[Unpack[ts]]], type_params=(t, ts)) # pyright: ignore[reportGeneralTypeIssues] + prefixed_var = Var(_js_expr="p", _var_type=prefixed[str, int, float]).guess_type() + assert isinstance(prefixed_var, ObjectVar) + assert prefixed_var._var_type == dict[str, tuple[int, float]] + + suffixed = alias_cls("Suffixed", dict[t, tuple[Unpack[ts]]], type_params=(ts, t)) # pyright: ignore[reportGeneralTypeIssues] + suffixed_var = Var(_js_expr="s", _var_type=suffixed[int, float, str]).guess_type() + assert isinstance(suffixed_var, ObjectVar) + assert suffixed_var._var_type == dict[str, tuple[int, float]] + + +@pytest.mark.parametrize("alias_cls", _type_alias_types()) +def test_state_var_type_alias(alias_cls: type) -> None: + """A state var annotated with a TypeAliasType compiles.""" + chart_key = alias_cls("ChartKey", Literal["day", "week"]) + + class TypeAliasState(State): + key: chart_key = "day" # pyright: ignore[reportInvalidTypeForm] + + assert isinstance(TypeAliasState.key, StringVar) + assert TypeAliasState.key._var_type == Literal["day", "week"] diff --git a/tests/units/reflex_cli/conftest.py b/tests/units/reflex_cli/conftest.py index f01f3f74a1f..7affb639330 100644 --- a/tests/units/reflex_cli/conftest.py +++ b/tests/units/reflex_cli/conftest.py @@ -2,6 +2,7 @@ import pytest from pytest_mock import MockFixture +from reflex_cli import constants @pytest.fixture(autouse=True) @@ -12,3 +13,26 @@ def mock_check_version(mocker: MockFixture) -> None: causing `check_version` to emit a warning and exit(1). """ mocker.patch("reflex_cli.v2.deployments.check_version") + + +@pytest.fixture(autouse=True) +def isolate_hosting_config( + monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory +) -> None: + """Point the hosting config at a temporary directory. + + Several code paths under test write or delete the token file for real, so + without this a test run destroys the developer's own `reflex login` state. + + Args: + monkeypatch: The pytest monkeypatch fixture. + tmp_path_factory: The pytest temporary directory factory. + """ + reflex_dir = tmp_path_factory.mktemp("reflex_data") + monkeypatch.setattr(constants.Reflex, "DIR", str(reflex_dir)) + monkeypatch.setattr( + constants.Hosting, "HOSTING_JSON", reflex_dir / "hosting_v1.json" + ) + monkeypatch.setattr( + constants.Hosting, "HOSTING_JSON_V0", reflex_dir / "hosting_v0.json" + ) diff --git a/tests/units/reflex_cli/test_min_reflex_support.py b/tests/units/reflex_cli/test_min_reflex_support.py new file mode 100644 index 00000000000..a8a5c3f775b --- /dev/null +++ b/tests/units/reflex_cli/test_min_reflex_support.py @@ -0,0 +1,174 @@ +"""Guards on the reflex versions reflex-hosting-cli claims to support. + +The hosting CLI advertises support down to +``ReflexHostingCli.MINIMUM_REFLEX_VERSION``, which predates the reflex release +that split the framework into workspace packages. Depending on any of those +packages is therefore unsatisfiable on the oldest reflex the CLI claims to +support -- and it fails quietly rather than loudly: reflex 0.8.x declares no +reflex-base dependency of its own, so pip has no conflict to report and simply +installs a second, mismatched framework base alongside reflex. + +The companion runtime guard is ``test_cli_imports_without_reflex_base`` in +``tests/units/reflex_cli/utils/test_log.py``, which covers the other half -- +importing a workspace package that older reflex does not ship. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name +from packaging.version import Version +from reflex_cli.constants.hosting import ReflexHostingCli + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +REPO_ROOT = Path(__file__).resolve().parents[3] +CLI_PYPROJECT = REPO_ROOT / "packages" / "reflex-hosting-cli" / "pyproject.toml" + +ROOT_PYPROJECT = REPO_ROOT / "pyproject.toml" + +# The reflex release that first shipped the framework runtime packages: +# reflex-base was carved out in #6281 and its earliest tag is reflex-base-v0.9.0. +# None of them can be depended on from a reflex older than this. +REFLEX_WORKSPACE_SPLIT_VERSION = Version("0.9.0") + + +def _load(pyproject: Path) -> dict: + """Parse a pyproject.toml file. + + Args: + pyproject: The file to parse. + + Returns: + The parsed document. + """ + with pyproject.open("rb") as f: + return tomllib.load(f) + + +def _dependency_names(pyproject: Path) -> set[str]: + """Collect the canonicalized names a package declares as dependencies. + + Args: + pyproject: The pyproject.toml to read. + + Returns: + The canonicalized distribution names. + """ + return { + canonicalize_name(Requirement(dep).name) + for dep in _load(pyproject)["project"]["dependencies"] + } + + +def _workspace_package_names() -> set[str]: + """Collect the distribution name of every package in the workspace. + + Returns: + The canonicalized distribution names. + """ + names = set() + for pyproject in sorted((REPO_ROOT / "packages").glob("*/pyproject.toml")): + if name := _load(pyproject).get("project", {}).get("name"): + names.add(canonicalize_name(name)) + return names + + +def _framework_runtime_packages() -> set[str]: + """Collect the workspace packages that reflex itself pulls in. + + These are the framework runtime: their presence and version are decided by + whichever reflex the user installed, so the hosting CLI depending on one is + either unsatisfiable on old reflex or silently resolves to a second, + mismatched copy alongside it. Workspace *utilities* reflex does not depend + on (reflex-release, reflex-docgen) are ordinary PyPI distributions and are + perfectly fine to depend on -- they are excluded here. + + Derived from the checkout rather than hard-coded, so a framework package + added later is covered without touching this test. + + Returns: + The canonicalized distribution names, excluding the hosting CLI itself. + """ + return (_dependency_names(ROOT_PYPROJECT) & _workspace_package_names()) - { + canonicalize_name("reflex-hosting-cli") + } + + +def test_framework_runtime_packages_are_discoverable(): + """The scan finds the right packages, so the guards below are not vacuous.""" + framework = _framework_runtime_packages() + # Shipped by reflex, so gated on the user's reflex version. + assert canonicalize_name("reflex-base") in framework + assert canonicalize_name("reflex-components-core") in framework + # Independent distributions, and the CLI itself: not gated, not banned. + assert canonicalize_name("reflex-release") not in framework + assert canonicalize_name("reflex-docgen") not in framework + assert canonicalize_name("reflex-hosting-cli") not in framework + + +def test_no_dependency_that_the_minimum_reflex_cannot_satisfy(): + """The CLI must not require a package older reflex does not ship. + + Adding one (as #6866 did with reflex-base) makes the advertised floor a + lie, so this fails until either the dependency goes or + MINIMUM_REFLEX_VERSION is raised past the workspace split. + """ + minimum = ReflexHostingCli.MINIMUM_REFLEX_VERSION + offenders = sorted(_dependency_names(CLI_PYPROJECT) & _framework_runtime_packages()) + + if minimum >= REFLEX_WORKSPACE_SPLIT_VERSION: + pytest.skip( + f"MINIMUM_REFLEX_VERSION is {minimum}, at or past the workspace " + f"split ({REFLEX_WORKSPACE_SPLIT_VERSION}); framework packages are " + "satisfiable and this guard no longer applies." + ) + + assert not offenders, ( + f"reflex-hosting-cli advertises reflex >= {minimum} " + f"(ReflexHostingCli.MINIMUM_REFLEX_VERSION) but declares {offenders}, " + f"which reflex only ships from {REFLEX_WORKSPACE_SPLIT_VERSION} on. " + "Reach for it through an optional import instead (see " + "reflex_cli.utils.log), or raise MINIMUM_REFLEX_VERSION and " + "RECOMMENDED_REFLEX_VERSION to match what is actually supported." + ) + + +def test_no_workspace_dependency_sources(): + """No ``[tool.uv.sources]`` workspace entry may smuggle a sibling package in. + + A workspace source resolves locally, so a dependency added this way can look + fine in the monorepo while being unsatisfiable for an installed user. + """ + sources = _load(CLI_PYPROJECT).get("tool", {}).get("uv", {}).get("sources", {}) + workspace_sources = sorted( + name for name, spec in sources.items() if spec.get("workspace") + ) + assert not workspace_sources, ( + f"reflex-hosting-cli declares workspace sources for {workspace_sources}. " + "The published package cannot resolve them; drop the source and the " + "matching dependency." + ) + + +def test_recommended_version_is_reachable_from_the_minimum(): + """The upgrade the CLI recommends must be a real step up from the floor. + + ``v2/deployments.py`` tells users below the recommended version to upgrade + to it, so it has to be a version that both exists and satisfies the CLI's + own dependencies. + """ + minimum = ReflexHostingCli.MINIMUM_REFLEX_VERSION + recommended = ReflexHostingCli.RECOMMENDED_REFLEX_VERSION + assert minimum <= recommended, ( + f"MINIMUM_REFLEX_VERSION ({minimum}) is above " + f"RECOMMENDED_REFLEX_VERSION ({recommended}), so the CLI gates on a " + "version it then tells the user is too old." + ) diff --git a/tests/units/reflex_cli/utils/test_hosting.py b/tests/units/reflex_cli/utils/test_hosting.py index 113695b52fe..5633b878045 100644 --- a/tests/units/reflex_cli/utils/test_hosting.py +++ b/tests/units/reflex_cli/utils/test_hosting.py @@ -11,6 +11,7 @@ import httpx import pytest from pytest_mock import MockerFixture, MockFixture +from reflex_cli import constants from reflex_cli.utils.exceptions import NotAuthenticatedError, TokenValidationError from reflex_cli.utils.hosting import ( UPLOAD_CHUNK_SIZE, @@ -18,7 +19,10 @@ ScaleParams, ScaleType, SecurityReviewError, + TokenSource, _archive_chunks, + _report_deployment_failure, + _strip_terminal_controls, _UploadAbandonedError, authenticated_token, create_app, @@ -30,6 +34,7 @@ get_auth_request_id, get_authenticated_client, get_existing_access_token, + get_existing_access_token_with_source, get_gcp_provider_status, get_security_review, get_selected_project, @@ -45,6 +50,7 @@ set_app_full_deploy, set_app_provider, set_instance_bounds, + stored_access_token, submit_security_review, update_deployment_description, validate_token, @@ -73,53 +79,233 @@ def test_get_existing_access_token( assert get_existing_access_token() == "" +def test_get_existing_access_token_prefers_the_environment( + monkeypatch: pytest.MonkeyPatch, +): + """An exported token is an explicit choice; the config file is ambient state. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.setenv("REFLEX_ACCESS_TOKEN", "env_token") + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "config_token"}') + + assert get_existing_access_token_with_source() == ( + "env_token", + TokenSource.ENVIRONMENT, + ) + + +def test_get_existing_access_token_falls_back_to_the_config_file( + monkeypatch: pytest.MonkeyPatch, +): + """Without the environment variable the stored token is used. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.delenv("REFLEX_ACCESS_TOKEN", raising=False) + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "config_token"}') + + assert get_existing_access_token_with_source() == ( + "config_token", + TokenSource.CONFIG, + ) + + +def test_get_existing_access_token_ignores_an_empty_environment_variable( + monkeypatch: pytest.MonkeyPatch, +): + """An empty export is not a token and must not shadow the config file. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.setenv("REFLEX_ACCESS_TOKEN", "") + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "config_token"}') + + assert get_existing_access_token_with_source() == ( + "config_token", + TokenSource.CONFIG, + ) + + +def test_get_existing_access_token_with_no_token_anywhere( + mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.delenv("REFLEX_ACCESS_TOKEN", raising=False) + mocker.patch("pathlib.Path.open", side_effect=FileNotFoundError("Test exception")) + + assert get_existing_access_token_with_source() == ("", TokenSource.NONE) + + @pytest.mark.parametrize( - "file_exists, config_content", + "config_content, expected", [ - (True, '{"access_token": "valid_token"}'), - (True, '{"another_key": "value"}'), - (False, ""), + ('{"access_token": "valid_token"}', {}), + ('{"access_token": "valid_token", "project": "p1"}', {"project": "p1"}), + ('{"another_key": "value"}', {"another_key": "value"}), ], ) -def test_delete_token_from_config( +def test_delete_token_from_config(config_content: str, expected: dict): + """Only the token is removed; everything else in the config survives. + + Args: + config_content: The starting contents of the config file. + expected: The config expected to remain afterwards. + """ + constants.Hosting.HOSTING_JSON.write_text(config_content) + + delete_token_from_config() + + assert json.loads(constants.Hosting.HOSTING_JSON.read_text()) == expected + + +def test_delete_token_from_config_without_a_config_file(): + """Deleting when no config exists is a no-op rather than an error.""" + assert not constants.Hosting.HOSTING_JSON.exists() + + delete_token_from_config() + + assert not constants.Hosting.HOSTING_JSON.exists() + + +def test_delete_token_from_config_keeps_the_config_when_the_write_fails( mocker: MockerFixture, - file_exists: bool, - config_content: str, ): - mocker.patch("pathlib.Path.exists", return_value=file_exists) - mock_os_remove = mocker.patch("pathlib.Path.unlink") + """A failed delete must leave the existing config readable, not truncated. - mocked_open = mock_open(read_data=config_content) - mocker.patch("pathlib.Path.open", mocked_open) - mock_json_load = mocker.patch( - "json.load", return_value=json.loads(config_content or "{}") - ) - mock_json_dump = mocker.patch("json.dump") + Args: + mocker: Pytest mocker fixture. + """ + original = '{"access_token": "good_token", "project": "p1"}' + constants.Hosting.HOSTING_JSON.write_text(original) + mocker.patch("json.dump", side_effect=OSError("disk full")) delete_token_from_config() - if file_exists: - assert mocked_open.call_count == 2 - mock_json_load.assert_called_once() - mock_json_dump.assert_called_once() - assert "access_token" not in mock_json_dump.call_args.args[0] - mock_os_remove.assert_called_once() - else: - mocked_open.assert_not_called() - mock_os_remove.assert_not_called() + assert constants.Hosting.HOSTING_JSON.read_text() == original + assert list(constants.Hosting.HOSTING_JSON.parent.iterdir()) == [ + constants.Hosting.HOSTING_JSON + ] -def test_save_token_to_config(mocker: MockFixture): - mocker.patch("pathlib.Path.exists", return_value=False) - mock_makedirs = mocker.patch("pathlib.Path.mkdir") - save_token_to_config("test_token") - mock_makedirs.assert_called_once() +def test_delete_token_from_config_keeps_an_unreadable_config( + mocker: MockerFixture, +): + """A config that cannot be parsed is left alone rather than replaced. + + Args: + mocker: Pytest mocker fixture. + """ + malformed = '{"access_token": "good_token", "project": "p1"' + constants.Hosting.HOSTING_JSON.write_text(malformed) + + delete_token_from_config() + + assert constants.Hosting.HOSTING_JSON.read_text() == malformed + - mocker.patch("pathlib.Path.exists", return_value=True) - mock_json_dump = mocker.patch("json.dump") - mocker.patch("pathlib.Path.open", mock_open()) +def test_save_token_to_config_recovers_from_an_unreadable_config(): + """Re-authenticating still works when the config is malformed.""" + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "good_token"') + + save_token_to_config("new_token") + + assert json.loads(constants.Hosting.HOSTING_JSON.read_text()) == { + "access_token": "new_token" + } + + +def test_stored_access_token_distinguishes_absent_from_unreadable(): + """A missing config reads as no token; a malformed one is an error.""" + assert stored_access_token() == "" + + constants.Hosting.HOSTING_JSON.write_text('{"project": "p1"}') + assert stored_access_token() == "" + + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "tok"}') + assert stored_access_token() == "tok" + + constants.Hosting.HOSTING_JSON.write_text("{not json") + with pytest.raises(ValueError): + stored_access_token() + + # Valid JSON that is not an object is still unusable, not empty. + constants.Hosting.HOSTING_JSON.write_text('["not", "an", "object"]') + with pytest.raises(ValueError): + stored_access_token() + + +def test_delete_token_from_config_tolerates_an_unremovable_legacy_file( + mocker: MockerFixture, +): + """The legacy cleanup must not abort the token removal it follows. + + Args: + mocker: Pytest mocker fixture. + """ + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "valid_token"}') + constants.Hosting.HOSTING_JSON_V0.write_text("{}") + mocker.patch("pathlib.Path.unlink", side_effect=PermissionError("denied")) + + delete_token_from_config() + + assert json.loads(constants.Hosting.HOSTING_JSON.read_text()) == {} + + +def test_delete_token_from_config_removes_the_legacy_file(): + """The pre-v1 hosting file is removed alongside the token.""" + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "valid_token"}') + constants.Hosting.HOSTING_JSON_V0.write_text("{}") + + delete_token_from_config() + + assert not constants.Hosting.HOSTING_JSON_V0.exists() + + +def test_save_token_to_config_creates_the_config(): + """Saving works when neither the directory nor the file exists yet.""" save_token_to_config("test_token") - mock_json_dump.assert_called_once() + + assert json.loads(constants.Hosting.HOSTING_JSON.read_text()) == { + "access_token": "test_token" + } + + +def test_save_token_to_config_preserves_other_keys(): + """Saving a token leaves unrelated config entries untouched.""" + constants.Hosting.HOSTING_JSON.write_text( + '{"access_token": "old_token", "project": "p1"}' + ) + + save_token_to_config("new_token") + + assert json.loads(constants.Hosting.HOSTING_JSON.read_text()) == { + "access_token": "new_token", + "project": "p1", + } + + +def test_save_token_to_config_keeps_the_old_token_when_the_write_fails( + mocker: MockerFixture, +): + """A failed write must not truncate the credentials already on disk. + + Args: + mocker: Pytest mocker fixture. + """ + original = '{"access_token": "good_token", "project": "p1"}' + constants.Hosting.HOSTING_JSON.write_text(original) + mocker.patch("json.dump", side_effect=OSError("disk full")) + + save_token_to_config("new_token") + + assert constants.Hosting.HOSTING_JSON.read_text() == original + # The temporary file used for the atomic replace is cleaned up. + assert list(constants.Hosting.HOSTING_JSON.parent.iterdir()) == [ + constants.Hosting.HOSTING_JSON + ] def test_authenticated_token_found_and_valid(mocker: MockFixture): @@ -1163,3 +1349,388 @@ def test_validate_token_failure_carries_request_id_on_exception(mocker: MockerFi validate_token("some-token") assert exc_info.value.request_id == get_auth_request_id() != "" + + +def _log_messages(caplog: pytest.LogCaptureFixture, level: int) -> list[str]: + """Return the captured log messages emitted at the given level. + + Args: + caplog: The pytest log capture fixture. + level: The numeric log level to filter records by. + + Returns: + The formatted messages of the matching records. + """ + return [r.getMessage() for r in caplog.records if r.levelno == level] + + +def _failure_report(mocker: MockerFixture, **fields: object): + """A mock 2xx /failure response carrying the given report fields. + + Args: + mocker: Pytest mocker fixture. + **fields: Failure-report fields to override on the default report. + + Returns: + A mocked successful HTTP response containing the failure report. + """ + report = { + "status": "Failed", + "code": None, + "fault": None, + "reason": "", + "guidance": "", + "build_log_excerpt": None, + } + report.update(fields) + return _ok(mocker, report) + + +def test_failure_report_prints_reason_and_build_log( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture, capsys +): + """A build failure shows its reason, its guidance and the log's tail. + + Args: + mocker: Pytest mocker fixture. + caplog: Pytest log capture fixture. + capsys: Pytest stdout capture fixture. + """ + mocker.patch( + "httpx.get", + return_value=_failure_report( + mocker, + code="build_failed", + fault="customer", + reason="Deployment error: the build failed", + guidance="Your app failed to build.", + build_log_excerpt="ERROR: no matching distribution for pandas==9.9", + ), + ) + + _report_deployment_failure( + "dep-1", "fake-token", "build error", offer_build_logs=True + ) + + assert "Deployment error: the build failed" in _log_messages(caplog, logging.ERROR) + assert "Your app failed to build." in _log_messages(caplog, logging.WARNING) + printed = capsys.readouterr().out + assert "no matching distribution for pandas==9.9" in printed + assert "reflex cloud apps build-logs dep-1" in printed + + +def test_failure_report_withholds_build_log_when_the_fault_is_ours( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture, capsys +): + """A platform failure says so and never sends the reader to their build. + + Args: + mocker: Pytest mocker fixture. + caplog: Pytest log capture fixture. + capsys: Pytest stdout capture fixture. + """ + mocker.patch( + "httpx.get", + return_value=_failure_report( + mocker, + code="image_push_failed", + fault="platform", + reason="Deployment error: could not push the image", + guidance="This failure is on Reflex's side, not in your app.", + ), + ) + + _report_deployment_failure( + "dep-1", "fake-token", "deployment error", offer_build_logs=False + ) + + assert "Deployment error: could not push the image" in _log_messages( + caplog, logging.ERROR + ) + assert "not in your app" in " ".join(_log_messages(caplog, logging.WARNING)) + assert "build-logs" not in capsys.readouterr().out + + +def test_failure_report_falls_back_when_the_endpoint_is_absent( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +): + """An older control plane 404s, and the status string is reported as before. + + Args: + mocker: Pytest mocker fixture. + caplog: Pytest log capture fixture. + """ + mocker.patch("httpx.get", return_value=_error(mocker, 404, "Not Found")) + + _report_deployment_failure( + "dep-1", "fake-token", "build error: something broke", offer_build_logs=True + ) + + warnings = _log_messages(caplog, logging.WARNING) + assert "build error: something broke" in warnings + assert any("reflex cloud apps build-logs dep-1" in w for w in warnings) + + +def test_failure_report_fallback_respects_the_arm_that_asked( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +): + """With no report, a generic failure offers no build log, as before. + + Args: + mocker: Pytest mocker fixture. + caplog: Pytest log capture fixture. + """ + mocker.patch("httpx.get", side_effect=httpx.RequestError("down")) + + _report_deployment_failure( + "dep-1", "fake-token", "deployment error", offer_build_logs=False + ) + + warnings = _log_messages(caplog, logging.WARNING) + assert warnings == ["deployment error"] + + +def test_failure_report_falls_back_to_the_status_when_no_reason_was_recorded( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +): + """A row that recorded no reason still reports something. + + Args: + mocker: Pytest mocker fixture. + caplog: Pytest log capture fixture. + """ + mocker.patch("httpx.get", return_value=_failure_report(mocker)) + + _report_deployment_failure( + "dep-1", "fake-token", "deployment error", offer_build_logs=False + ) + + assert "deployment error" in _log_messages(caplog, logging.ERROR) + + +@pytest.mark.parametrize( + "hostile, banned", + [ + # OSC 52: writes the reader's clipboard. + ("\x1b]52;c;bWFsaWNpb3Vz\x07error: build failed", "\x1b]52"), + # OSC 8: renders as one destination and links to another. + ("\x1b]8;;https://evil.example\x07docs\x1b]8;;\x07", "\x1b]8"), + # CSI: erases the lines above it, hiding what really happened. + ("done\x1b[2J\x1b[1;1Hbuild succeeded", "\x1b["), + # A carriage return overwrites the line in place. + ("real error\rbuild succeeded", "\r"), + ], +) +def test_a_build_log_cannot_drive_the_terminal(hostile: str, banned: str): + """Build output is the app's own dependencies, printed without being asked for. + + Args: + hostile: Build output carrying a terminal control sequence. + banned: The sequence that must not survive. + """ + cleaned = _strip_terminal_controls(hostile) + + assert banned not in cleaned + assert "\x1b" not in cleaned + + +def test_stripping_keeps_the_text_worth_reading(): + """Colour is dropped; the words, newlines and tabs that carry the answer stay.""" + log = "\x1b[31mERROR\x1b[0m: no matching distribution\n\tfor pandas==9.9\n" + + assert ( + _strip_terminal_controls(log) + == "ERROR: no matching distribution\n\tfor pandas==9.9\n" + ) + + +def test_the_printed_excerpt_is_stripped( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture, capsys +): + """The sanitiser is actually on the path the excerpt takes to the terminal. + + Args: + mocker: Pytest mocker fixture. + caplog: Pytest log capture fixture. + capsys: Pytest stdout capture fixture. + """ + mocker.patch( + "httpx.get", + return_value=_failure_report( + mocker, + code="build_failed", + reason="Deployment error: the build failed", + build_log_excerpt="\x1b]52;c;cHduZWQ=\x07ERROR: \x1b[31mno such package\x1b[0m", + ), + ) + + _report_deployment_failure( + "dep-1", "fake-token", "build error", offer_build_logs=True + ) + + printed = capsys.readouterr().out + assert "ERROR: no such package" in printed + assert "\x1b" not in printed + + +@pytest.mark.parametrize( + "hostile", + [ + "A\x1b7B", # DECSC: final byte 0x37, outside the CSI and OSC shapes + "A\x1b=B", # DECKPAM + "A\x1bcB", # RIS: a full terminal reset + "A\x1b(0B", # a designator with an intermediate byte + ], +) +def test_a_two_character_escape_leaves_no_stray_byte(hostile: str): + """The final byte goes with the ESC, rather than printing as garbage. + + Args: + hostile: Build output carrying a non-CSI escape sequence. + """ + cleaned = _strip_terminal_controls(hostile) + + assert cleaned == "AB" + + +def test_an_undecodable_body_falls_back_rather_than_raising( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +): + """A 2xx body httpx cannot decode is not an answer, and must not end the watch. + + Args: + mocker: Pytest mocker fixture. + caplog: Pytest log capture fixture. + """ + response = mocker.Mock() + response.raise_for_status.return_value = None + response.json.side_effect = UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid") + mocker.patch("httpx.get", return_value=response) + + _report_deployment_failure( + "dep-1", "fake-token", "build error", offer_build_logs=True + ) + + assert "build error" in _log_messages(caplog, logging.WARNING) + + +def test_a_non_string_excerpt_is_ignored( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture, capsys +): + """The CLI ships apart from the server, so the excerpt's type is not a given. + + Args: + mocker: Pytest mocker fixture. + caplog: Pytest log capture fixture. + capsys: Pytest stdout capture fixture. + """ + mocker.patch( + "httpx.get", + return_value=_failure_report( + mocker, + code="build_failed", + reason="Deployment error: the build failed", + build_log_excerpt={"unexpected": "shape"}, + ), + ) + + _report_deployment_failure( + "dep-1", "fake-token", "build error", offer_build_logs=True + ) + + assert "Deployment error: the build failed" in _log_messages(caplog, logging.ERROR) + assert "the end of the build log" not in capsys.readouterr().out + + +def test_an_unreadable_log_is_reported_rather_than_passed_over( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +): + """A log the server could not read is not a build that produced none. + + Args: + mocker: Pytest mocker fixture. + caplog: Pytest log capture fixture. + """ + mocker.patch( + "httpx.get", + return_value=_failure_report( + mocker, + code="build_failed", + reason="Deployment error: the build failed", + build_log_excerpt=None, + build_log_unreadable=True, + ), + ) + + _report_deployment_failure( + "dep-1", "fake-token", "build error", offer_build_logs=True + ) + + warnings = " ".join(_log_messages(caplog, logging.WARNING)) + assert "could not be read" in warnings + assert "reflex cloud apps build-logs dep-1" in warnings + + +def test_a_build_that_stored_no_log_says_nothing_extra( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture, capsys +): + """Absence is not an outage, and there is nothing to send the reader to. + + The reason stands alone: no excerpt, no header over one, and no command to + go and fetch a log that was never stored. The command is what separates + this path from the unreadable one, which does offer it. + + Args: + mocker: Pytest mocker fixture. + caplog: Pytest log capture fixture. + capsys: Pytest stdout capture fixture. + """ + mocker.patch( + "httpx.get", + return_value=_failure_report( + mocker, + code="build_failed", + reason="Deployment error: the build failed", + build_log_excerpt=None, + ), + ) + + _report_deployment_failure( + "dep-1", "fake-token", "build error", offer_build_logs=True + ) + + assert "Deployment error: the build failed" in _log_messages(caplog, logging.ERROR) + said = " ".join(_log_messages(caplog, logging.WARNING)) + capsys.readouterr().out + assert "could not be read" not in said + assert "the end of the build log" not in said + assert "reflex cloud apps build-logs" not in said + + +@pytest.mark.parametrize( + "failure", + [ + UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid"), + # A deeply nested document: a RuntimeError, so a ValueError catch misses it. + RecursionError("maximum recursion depth exceeded"), + ], +) +def test_a_malformed_body_falls_back_however_it_is_malformed( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture, failure: Exception +): + """Not getting an answer costs nothing, whichever way the answer is broken. + + Args: + mocker: Pytest mocker fixture. + caplog: Pytest log capture fixture. + failure: What `.json()` raises on this body. + """ + response = mocker.Mock() + response.raise_for_status.return_value = None + response.json.side_effect = failure + mocker.patch("httpx.get", return_value=response) + + _report_deployment_failure( + "dep-1", "fake-token", "build error", offer_build_logs=True + ) + + assert "build error" in _log_messages(caplog, logging.WARNING) diff --git a/tests/units/reflex_cli/utils/test_log.py b/tests/units/reflex_cli/utils/test_log.py new file mode 100644 index 00000000000..69033ec93a1 --- /dev/null +++ b/tests/units/reflex_cli/utils/test_log.py @@ -0,0 +1,294 @@ +"""Tests for the hosting CLI's logging pipeline, with and without reflex-base.""" + +from __future__ import annotations + +import contextlib +import importlib +import logging +import sys +from collections.abc import Iterator +from types import ModuleType + +import pytest +from reflex_cli.utils import console, log + + +class _ReflexBaseBlocker: + """Meta path finder that makes reflex-base look uninstalled.""" + + def find_spec(self, fullname: str, path=None, target=None): + """Refuse to resolve reflex_base, as if it were not installed. + + Args: + fullname: The module being imported. + path: The parent package's search path. + target: The module being reloaded, if any. + + Returns: + None for every other module, deferring to the real finders. + + Raises: + ImportError: If reflex_base (or a submodule) is being imported. + """ + if fullname == "reflex_base" or fullname.startswith("reflex_base."): + msg = f"No module named {fullname!r}" + raise ImportError(msg) + return + + +@contextlib.contextmanager +def _without_reflex_base() -> Iterator[tuple[ModuleType, ModuleType, ModuleType]]: + """Import the CLI's logging modules as if reflex-base were not installed. + + Yields: + The freshly imported (constants.base, utils.log, utils.console) modules. + """ + cli_logger = logging.getLogger("reflex_cli") + saved_state = (cli_logger.handlers[:], cli_logger.level, cli_logger.propagate) + saved_modules = { + name: module + for name, module in sys.modules.items() + if name.startswith(("reflex_cli", "reflex_base")) + } + for name in saved_modules: + del sys.modules[name] + blocker = _ReflexBaseBlocker() + sys.meta_path.insert(0, blocker) + try: + yield ( + importlib.import_module("reflex_cli.constants.base"), + importlib.import_module("reflex_cli.utils.log"), + importlib.import_module("reflex_cli.utils.console"), + ) + finally: + sys.meta_path.remove(blocker) + for name in list(sys.modules): + if name.startswith(("reflex_cli", "reflex_base")): + del sys.modules[name] + sys.modules.update(saved_modules) + cli_logger.handlers, cli_logger.level, cli_logger.propagate = saved_state + + +def test_reflex_base_is_used_when_installed(): + """The workspace has reflex-base, so the shared pipeline is what is used.""" + from reflex_base.utils import log as base_log + + assert log.HAS_REFLEX_BASE + assert log.SUCCESS is base_log.SUCCESS + assert log.set_log_level is base_log.set_log_level + + +def test_reflex_base_adopts_the_cli_logger_without_being_imported(): + """reflex-base parents reflex_cli itself, so the CLI never has to import it.""" + from reflex_base.utils import log as base_log + + assert "reflex_cli" in base_log.PACKAGE_LOGGER_NAMES + + +@pytest.mark.parametrize( + "module", + [ + "reflex_cli.utils.hosting", + "reflex_cli.v2.apps", + "reflex_cli.v2.auth", + "reflex_cli.v2.cli", + "reflex_cli.v2.deployments", + "reflex_cli.v2.gcp", + "reflex_cli.v2.project", + "reflex_cli.v2.providers", + "reflex_cli.v2.scan", + "reflex_cli.v2.secrets", + "reflex_cli.v2.vmtypes_regions", + ], +) +def test_cli_imports_without_reflex_base(module: str): + """Every CLI module imports on reflex versions that predate reflex-base.""" + with _without_reflex_base(): + importlib.import_module(module) + + +def test_fallback_success_level(): + """Without reflex-base the CLI defines the same SUCCESS level itself.""" + with _without_reflex_base() as (_, fallback_log, _console): + assert not fallback_log.HAS_REFLEX_BASE + assert fallback_log.SUCCESS == log.SUCCESS == 25 + assert logging.getLevelName(fallback_log.SUCCESS) == "SUCCESS" + + +def test_fallback_log_level_enum_matches_reflex_base(): + """The forked LogLevel is interchangeable with the reflex-base one.""" + from reflex_base.constants.base import LogLevel as BaseLogLevel + + with _without_reflex_base() as (constants_base, _, _console): + forked = constants_base.LogLevel + assert forked is not BaseLogLevel + assert [level.value for level in forked] == [ + level.value for level in BaseLogLevel + ] + for level in forked: + assert ( + level.to_logging_level() == BaseLogLevel(level.value).to_logging_level() + ) + assert forked.from_string("warning") is forked.WARNING + assert forked.from_string("nonsense") is None + assert forked.from_string(None) is None + assert forked.DEBUG < forked.INFO + assert forked.DEBUG <= forked.DEBUG + assert forked.ERROR > forked.INFO + assert forked.ERROR >= forked.ERROR + for level in forked: + assert ( + level.subprocess_level().value + == BaseLogLevel(level.value).subprocess_level().value + ) + + +def test_fallback_log_level_covers_the_whole_reflex_base_api(): + """The fork must expose everything the shared enum does. + + The fork is a drop-in for reflex-base's LogLevel, so CLI code written + against the shared enum has to keep working when reflex-base is absent. + A method added there and missed here would work on reflex 0.9 and break on + older reflex -- the exact class of bug this package guards against. + """ + from reflex_base.constants.base import LogLevel as BaseLogLevel + + with _without_reflex_base() as (constants_base, _, _console): + forked = constants_base.LogLevel + missing = { + name + for name in dir(BaseLogLevel) + if not name.startswith("_") and not hasattr(forked, name) + } + assert not missing, ( + f"reflex_cli.constants.log_level.LogLevel is missing {sorted(missing)}, " + "which reflex_base.constants.base.LogLevel defines." + ) + + +@pytest.mark.parametrize( + ("level", "message", "expected"), + [ + (logging.DEBUG, "a debug line", "Debug: a debug line"), + (logging.INFO, "an info line", "Info: an info line"), + (25, "a success line", "Success: a success line"), + (logging.WARNING, "a warning line", "Warning: a warning line"), + ], +) +def test_fallback_renders_records_to_stdout(capsys, level, message, expected): + """The fallback sink renders records with the same prefixes as reflex-base.""" + with _without_reflex_base() as (constants_base, fallback_log, _console): + fallback_log.set_log_level(constants_base.LogLevel.DEBUG) + logging.getLogger("reflex_cli.test").log(level, message) + + assert expected in capsys.readouterr().out + + +def test_fallback_renders_errors_to_stderr(capsys): + """Errors go to stderr, matching the reflex-base handler.""" + with _without_reflex_base() as (constants_base, fallback_log, _console): + fallback_log.set_log_level(constants_base.LogLevel.INFO) + logging.getLogger("reflex_cli.test").error("a failure") + + captured = capsys.readouterr() + assert "a failure" in captured.err + assert "a failure" not in captured.out + + +def test_fallback_gates_on_log_level(capsys): + """Records below the configured level are not rendered.""" + with _without_reflex_base() as (constants_base, fallback_log, _console): + fallback_log.set_log_level(constants_base.LogLevel.WARNING) + cli_logger = logging.getLogger("reflex_cli.test") + cli_logger.info("quiet info") + cli_logger.warning("loud warning") + + captured = capsys.readouterr() + assert "quiet info" not in captured.out + assert "loud warning" in captured.out + + +def test_fallback_does_not_stack_handlers(): + """Repeated set_log_level calls reuse the one sink instead of stacking.""" + with _without_reflex_base() as (constants_base, fallback_log, _console): + cli_logger = logging.getLogger("reflex_cli") + fallback_log.set_log_level(constants_base.LogLevel.INFO) + fallback_log.set_log_level(constants_base.LogLevel.DEBUG) + + assert cli_logger.handlers == [fallback_log._handler] + # Propagation is cut so an application's own root config cannot + # double-emit the CLI's records. + assert not cli_logger.propagate + assert cli_logger.level == logging.DEBUG + + +def test_fallback_rejects_non_log_level(): + """A non-LogLevel value is a programming error, not a silent no-op.""" + with _without_reflex_base() as (_, fallback_log, _console): + with pytest.raises(TypeError): + fallback_log.set_log_level("debug") + # None means "leave it alone", matching reflex-base. + fallback_log.set_log_level(None) + + +def test_set_log_level_accepts_strings(monkeypatch): + """console.set_log_level keeps taking legacy string values.""" + with _without_reflex_base() as (_, _log, fallback_console): + fallback_console.set_log_level("warning") + assert logging.getLogger("reflex_cli").level == logging.WARNING + # An unknown level falls back to INFO rather than raising. + fallback_console.set_log_level("nonsense") + assert logging.getLogger("reflex_cli").level == logging.INFO + + # The reflex-base path is process-wide: it sets REFLEX_LOGLEVEL so + # subprocesses inherit the level, and moves a module global. Sandbox the + # environment and put the level back, or the rest of the session (and any + # subprocess it spawns) inherits whatever this test left behind. + from reflex_base.constants.base import LogLevel as BaseLogLevel + from reflex_base.utils import log as base_log + + previous = base_log.get_log_level() + monkeypatch.setenv("REFLEX_LOGLEVEL", previous.value) + try: + console.set_log_level("warning") + assert base_log.get_log_level() is BaseLogLevel.WARNING + console.set_log_level("nonsense") + assert base_log.get_log_level() is BaseLogLevel.INFO + finally: + base_log.set_log_level(previous) + + +def test_fallback_console_helpers(capsys): + """The forked console renders the rich helpers the CLI actually uses.""" + with _without_reflex_base() as (_, _log, fallback_console): + fallback_console.print("a plain line") + fallback_console.print_table([["a@b.com", "1"]], headers=["email", "id"]) + fallback_console.rule("a rule") + + out = capsys.readouterr().out + assert "a plain line" in out + assert "a@b.com" in out + assert "email" in out + assert "a rule" in out + + +def test_fallback_progress_bars(capsys): + """Both progress bars build without reflex-base and render their tasks. + + transfer_progress drives the deploy upload, so it has to work on the reflex + versions that predate reflex-base like everything else here. + """ + with _without_reflex_base() as (_, fallback_log, fallback_console): + # JSON output is a reflex-base pipeline feature; without it there is + # nothing to stay quiet for, so the bars are never disabled. + assert fallback_log.is_json_mode() is False + + with fallback_console.progress() as bar: + bar.add_task("stepping", total=2) + with fallback_console.transfer_progress() as transfer: + task = transfer.add_task("uploading", total=1024) + transfer.update(task, advance=512) + + out = capsys.readouterr().out + assert "stepping" in out + assert "uploading" in out diff --git a/tests/units/reflex_cli/v2/test_auth.py b/tests/units/reflex_cli/v2/test_auth.py new file mode 100644 index 00000000000..ab00dce41ef --- /dev/null +++ b/tests/units/reflex_cli/v2/test_auth.py @@ -0,0 +1,463 @@ +"""Tests for the `reflex cloud whoami` and `reflex cloud token` commands.""" + +import json +import logging + +import pytest +from click.testing import CliRunner +from pytest_mock import MockFixture +from reflex_base.utils.log import SUCCESS +from reflex_cli import constants +from reflex_cli.utils import hosting +from reflex_cli.utils.exceptions import TokenAccessDeniedError, TokenValidationError +from reflex_cli.v2.auth import token_fingerprint +from reflex_cli.v2.deployments import hosting_cli + +from .utils import as_click_command + +hosting_cli = as_click_command(hosting_cli) + +runner = CliRunner() + +VALIDATED_INFO = { + "email": "user@example.com", + "user_id": "user-uuid", + "org_id": "org-uuid", + "tier": "Pro", + "is_service_account": False, + "_memo": {}, +} + + +def _messages(caplog: pytest.LogCaptureFixture, level: int) -> list[str]: + """Return the captured log messages emitted at the given level. + + Args: + caplog: The pytest log capture fixture. + level: The numeric log level to filter records by. + + Returns: + The formatted messages of the matching records. + """ + return [r.getMessage() for r in caplog.records if r.levelno == level] + + +def test_token_fingerprint_is_stable_and_hides_the_token(): + token = "super-secret-token" + assert token_fingerprint(token) == token_fingerprint(token) + assert token_fingerprint(token) != token_fingerprint(token + "x") + assert token not in token_fingerprint(token) + assert token_fingerprint("") == "" + + +def test_whoami_reports_identity_and_token_source(mocker: MockFixture): + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("valid_token", hosting.TokenSource.CONFIG), + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + + result = runner.invoke(hosting_cli, ["whoami", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["email"] == "user@example.com" + assert payload["org_id"] == "org-uuid" + assert payload["token_source"] == "config file" + assert payload["token_fingerprint"] == token_fingerprint("valid_token") + # Private control-plane fields stay out of the output. + assert "_memo" not in payload + # The token itself is never printed. + assert "valid_token" not in result.output + + +def test_whoami_table_output_is_complete_and_hides_the_token(mocker: MockFixture): + """Identifiers print in full: they are the reason to run the command.""" + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("valid_token", hosting.TokenSource.CONFIG), + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + + result = runner.invoke(hosting_cli, ["whoami"], terminal_width=40) + + assert result.exit_code == 0 + fields = dict(line.split(maxsplit=1) for line in result.output.splitlines()) + # A rich table would have truncated these to fit the 40-column terminal. + assert fields["user_id"] == VALIDATED_INFO["user_id"] + assert fields["email"] == "user@example.com" + assert fields["token_source"] == "config file" + assert "valid_token" not in result.output + + +def test_whoami_json_output_is_exact(mocker: MockFixture): + """`--json` must survive piping: one line, no markup, no wrapping.""" + wide = dict(VALIDATED_INFO, email="a-very-long-address@a-long-example-domain.com") + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("valid_token", hosting.TokenSource.CONFIG), + ) + mocker.patch("reflex_cli.utils.hosting.validate_token", return_value=wide) + + result = runner.invoke(hosting_cli, ["whoami", "--json"], terminal_width=40) + + assert result.exit_code == 0 + assert len(result.output.splitlines()) == 1 + assert json.loads(result.output)["email"] == wide["email"] + + +def test_whoami_prefers_the_token_option(mocker: MockFixture): + from_config = mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source" + ) + validate = mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + + result = runner.invoke(hosting_cli, ["whoami", "--json", "--token", "cli_token"]) + + assert result.exit_code == 0 + validate.assert_called_once_with("cli_token") + from_config.assert_not_called() + assert json.loads(result.output)["token_source"] == "--token option" + + +def test_whoami_without_a_token_does_not_open_a_browser( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("", hosting.TokenSource.NONE), + ) + authenticate = mocker.patch("reflex_cli.utils.hosting.authenticate_on_browser") + + result = runner.invoke(hosting_cli, ["whoami"]) + + assert result.exit_code == 1 + authenticate.assert_not_called() + assert any( + "Not logged in" in message for message in _messages(caplog, logging.ERROR) + ) + + +def test_whoami_surfaces_the_auth_request_id( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("stale_token", hosting.TokenSource.ENVIRONMENT), + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_token", + side_effect=TokenAccessDeniedError("access denied", request_id="req-123"), + ) + + result = runner.invoke(hosting_cli, ["whoami"]) + + assert result.exit_code == 1 + errors = _messages(caplog, logging.ERROR) + assert any("req-123" in message for message in errors) + assert any("REFLEX_ACCESS_TOKEN" in message for message in errors) + + +def test_token_print_writes_the_raw_token_to_stdout(mocker: MockFixture): + long_token = "t" * 300 + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=(long_token, hosting.TokenSource.CONFIG), + ) + + result = runner.invoke(hosting_cli, ["token", "--print"]) + + assert result.exit_code == 0 + # Captured verbatim on one line, so `$(reflex cloud token --print)` is exact. + assert result.output == long_token + "\n" + + +def test_token_print_keeps_stdout_free_of_diagnostics(): + """Debug logging must not land inside `$(reflex cloud token --print)`. + + The real lookup is exercised rather than mocked: the debug records that + would contaminate stdout come from inside the lookup helper, so a mock + would emit nothing and the test would pass even without the fix. + """ + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "quiet_token"}') + + result = runner.invoke(hosting_cli, ["token", "--print", "--loglevel", "debug"]) + + assert result.exit_code == 0 + assert result.output == "quiet_token\n" + + +def test_token_print_without_a_token_fails( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("", hosting.TokenSource.NONE), + ) + + result = runner.invoke(hosting_cli, ["token", "--print"]) + + assert result.exit_code == 1 + assert any( + "No access token stored" in message + for message in _messages(caplog, logging.ERROR) + ) + + +def test_token_set_validates_before_saving( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + save = mocker.patch("reflex_cli.utils.hosting.save_token_to_config") + mocker.patch( + "reflex_cli.utils.hosting.stored_access_token", return_value="new_token" + ) + + result = runner.invoke(hosting_cli, ["token", "--set", "new_token"]) + + assert result.exit_code == 0 + save.assert_called_once_with("new_token") + assert any("user@example.com" in message for message in _messages(caplog, SUCCESS)) + + +@pytest.mark.parametrize("args", [["--set", "-"], ["--set"]]) +def test_token_set_reads_the_token_from_stdin(args: list[str], mocker: MockFixture): + """A token on the command line leaks into shell history and the process list. + + Args: + args: The invocation form under test. + mocker: Pytest mocker fixture. + """ + validate = mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + save = mocker.patch("reflex_cli.utils.hosting.save_token_to_config") + mocker.patch( + "reflex_cli.utils.hosting.stored_access_token", return_value="piped_token" + ) + + result = runner.invoke(hosting_cli, ["token", *args], input="piped_token\n") + + assert result.exit_code == 0 + validate.assert_called_once_with("piped_token") + save.assert_called_once_with("piped_token") + + +@pytest.mark.parametrize("value", ["", " ", "\n"]) +def test_token_set_rejects_an_empty_token(value: str, mocker: MockFixture): + """An empty --set is a malformed --set, not a missing one. + + Args: + value: The empty value under test. + mocker: Pytest mocker fixture. + """ + validate = mocker.patch("reflex_cli.utils.hosting.validate_token") + + result = runner.invoke(hosting_cli, ["token", "--set", value]) + + assert result.exit_code == 2 + assert "empty token" in result.output + # Not reported as "specify exactly one", which reads as though --set was absent. + assert "exactly one" not in result.output + validate.assert_not_called() + + +def test_token_set_succeeds_while_the_environment_variable_is_set( + mocker: MockFixture, monkeypatch: pytest.MonkeyPatch +): + """The write is confirmed against the config, which the environment shadows. + + Args: + mocker: Pytest mocker fixture. + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.setenv("REFLEX_ACCESS_TOKEN", "env_token") + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + + result = runner.invoke(hosting_cli, ["token", "--set", "new_token"]) + + assert result.exit_code == 0 + assert json.loads(constants.Hosting.HOSTING_JSON.read_text()) == { + "access_token": "new_token" + } + + +def test_token_set_reports_an_unconfirmable_write( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + """A config that cannot be read back is not evidence the token was saved. + + Args: + mocker: Pytest mocker fixture. + caplog: The pytest log capture fixture. + """ + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + mocker.patch("reflex_cli.utils.hosting.save_token_to_config") + mocker.patch( + "reflex_cli.utils.hosting.stored_access_token", + side_effect=OSError("permission denied"), + ) + + result = runner.invoke(hosting_cli, ["token", "--set", "new_token"]) + + assert result.exit_code == 1 + assert any( + "Unable to confirm" in message for message in _messages(caplog, logging.ERROR) + ) + assert not _messages(caplog, SUCCESS) + + +def test_token_set_keeps_the_old_token_when_the_new_one_is_rejected( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + mocker.patch( + "reflex_cli.utils.hosting.validate_token", + side_effect=TokenValidationError("server error", request_id="req-456"), + ) + save = mocker.patch("reflex_cli.utils.hosting.save_token_to_config") + delete = mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + + result = runner.invoke(hosting_cli, ["token", "--set", "bad_token"]) + + assert result.exit_code == 1 + # A bad --set must not clobber or delete a working token. + save.assert_not_called() + delete.assert_not_called() + assert any("req-456" in message for message in _messages(caplog, logging.ERROR)) + + +def test_token_set_reports_a_failed_write( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + # save_token_to_config swallows write errors, so the command reads back. + mocker.patch("reflex_cli.utils.hosting.save_token_to_config") + mocker.patch("reflex_cli.utils.hosting.stored_access_token", return_value="") + + result = runner.invoke(hosting_cli, ["token", "--set", "new_token"]) + + assert result.exit_code == 1 + assert any( + "Unable to persist" in message for message in _messages(caplog, logging.ERROR) + ) + + +def test_token_clear_removes_the_token( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + delete = mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + mocker.patch("reflex_cli.utils.hosting.stored_access_token", return_value="") + + result = runner.invoke(hosting_cli, ["token", "--clear"]) + + assert result.exit_code == 0 + delete.assert_called_once_with() + assert any("Cleared" in message for message in _messages(caplog, SUCCESS)) + + +def test_token_clear_warns_when_the_env_var_still_applies( + mocker: MockFixture, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +): + monkeypatch.setenv("REFLEX_ACCESS_TOKEN", "env_token") + mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + + result = runner.invoke(hosting_cli, ["token", "--clear"]) + + assert result.exit_code == 0 + assert any( + "REFLEX_ACCESS_TOKEN is still set" in message + for message in _messages(caplog, logging.INFO) + ) + + +def test_token_clear_reports_an_unreadable_config( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + """A config that cannot be read is not evidence the token was removed.""" + mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + mocker.patch( + "reflex_cli.utils.hosting.stored_access_token", + side_effect=OSError("permission denied"), + ) + + result = runner.invoke(hosting_cli, ["token", "--clear"]) + + assert result.exit_code == 1 + assert any( + "Unable to confirm" in message for message in _messages(caplog, logging.ERROR) + ) + assert not _messages(caplog, SUCCESS) + + +def test_token_clear_reports_a_non_object_config(caplog: pytest.LogCaptureFixture): + """Valid JSON that is not an object is reported, not raised as a traceback. + + Args: + caplog: The pytest log capture fixture. + """ + constants.Hosting.HOSTING_JSON.write_text('["not", "an", "object"]') + + result = runner.invoke(hosting_cli, ["token", "--clear"]) + + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert any( + "Unable to confirm" in message for message in _messages(caplog, logging.ERROR) + ) + + +def test_token_clear_reports_a_failed_removal( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + """A swallowed delete failure must not be reported as success.""" + # delete_token_from_config swallows filesystem errors, so the token can + # still be in the config file when it returns. + mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + mocker.patch( + "reflex_cli.utils.hosting.stored_access_token", return_value="still_here" + ) + + result = runner.invoke(hosting_cli, ["token", "--clear"]) + + assert result.exit_code == 1 + assert any( + "Unable to remove" in message for message in _messages(caplog, logging.ERROR) + ) + assert not _messages(caplog, SUCCESS) + + +@pytest.mark.parametrize( + "args", + [ + [], + ["--print", "--clear"], + ["--print", "--set", "tok"], + ["--set", "tok", "--clear"], + ], +) +def test_token_requires_exactly_one_operation(args: list[str], mocker: MockFixture): + save = mocker.patch("reflex_cli.utils.hosting.save_token_to_config") + delete = mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + + result = runner.invoke(hosting_cli, ["token", *args]) + + assert result.exit_code == 2 + assert "exactly one of --print, --set or --clear" in result.output + save.assert_not_called() + delete.assert_not_called() diff --git a/tests/units/reflex_cli/v2/test_vmtypes_regions.py b/tests/units/reflex_cli/v2/test_vmtypes_regions.py index 31f0c45baab..b4a050eb6a2 100644 --- a/tests/units/reflex_cli/v2/test_vmtypes_regions.py +++ b/tests/units/reflex_cli/v2/test_vmtypes_regions.py @@ -14,14 +14,6 @@ runner = CliRunner() -@pytest.fixture -def mock_console(mocker: MockFixture): - """Fixture to mock console.print and console.error.""" - mock_print = mocker.patch("reflex_cli.utils.console.print") - mock_error = mocker.patch("reflex_cli.utils.console.error") - return mock_print, mock_error - - def test_get_vm_types_success(mocker: MockFixture): """Test successful retrieval of VM types.""" mock_get_vm_types = mocker.patch( diff --git a/tests/units/reflex_components_core/__init__.py b/tests/units/reflex_components_core/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/units/reflex_components_core/core/__init__.py b/tests/units/reflex_components_core/core/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/units/reflex_components_core/core/test_sticky.py b/tests/units/reflex_components_core/core/test_sticky.py new file mode 100644 index 00000000000..257201f2553 --- /dev/null +++ b/tests/units/reflex_components_core/core/test_sticky.py @@ -0,0 +1,39 @@ +"""Tests for the "Built with Reflex" sticky badge.""" + +import pytest +from reflex_components_core.core.sticky import StickyBadge + + +def _badge_href() -> str: + """Render a fresh badge and extract its href prop. + + Returns: + The rendered href value, without surrounding quotes. + """ + props = StickyBadge.create().render()["props"] + href_prop = next(p for p in props if p.startswith("href:")) + return href_prop.removeprefix("href:").strip('"') + + +def test_badge_href_default(monkeypatch: pytest.MonkeyPatch): + """Without a referrer param, the badge links to the plain reflex.dev URL.""" + monkeypatch.delenv("REFLEX_REFERRER_PARAM", raising=False) + assert _badge_href() == "https://reflex.dev" + + +def test_badge_href_with_referrer(monkeypatch: pytest.MonkeyPatch): + """A referrer param is appended as a ref query parameter.""" + monkeypatch.setenv("REFLEX_REFERRER_PARAM", "owner-123") + assert _badge_href() == "https://reflex.dev/?ref=owner-123" + + +def test_badge_href_urlencodes_referrer(monkeypatch: pytest.MonkeyPatch): + """Special characters in the referrer param are urlencoded.""" + monkeypatch.setenv("REFLEX_REFERRER_PARAM", "a b&c/d?e=f") + assert _badge_href() == "https://reflex.dev/?ref=a%20b%26c%2Fd%3Fe%3Df" + + +def test_badge_href_empty_referrer(monkeypatch: pytest.MonkeyPatch): + """An empty referrer param falls back to the default URL.""" + monkeypatch.setenv("REFLEX_REFERRER_PARAM", "") + assert _badge_href() == "https://reflex.dev" diff --git a/tests/units/reflex_release/test_commands.py b/tests/units/reflex_release/test_commands.py index 8825d7e9265..2099c070619 100644 --- a/tests/units/reflex_release/test_commands.py +++ b/tests/units/reflex_release/test_commands.py @@ -348,7 +348,7 @@ def test_materialize_writes_an_empty_entry_for_a_lockstep_partner( assert outputs()["any"] == "true" -def test_commit_changelogs_leaves_unrelated_work_alone( +def test_commit_materialized_leaves_unrelated_work_alone( config: Config, repo: Path ) -> None: """A release commit carries the changelogs and nothing a human was mid-way through.""" @@ -360,8 +360,8 @@ def test_commit_changelogs_leaves_unrelated_work_alone( set_changelog(config, "widget-core", "## v0.9.0 (2026-01-01)\n\nNot mine.\n") fragment(config, "widget-core", "3.bugfix.md") - commands._commit_changelogs( - config, [{"package": "mypkg", "next": "1.0.0"}], "Materialize changelogs" + commands._commit_materialized( + config, [{"package": "mypkg", "next": "1.0.0"}], [], "Materialize changelogs" ) assert git(repo, "show", "--name-only", "--format=", "HEAD").split() == [ @@ -381,9 +381,10 @@ def test_release_commit_removes_the_fragments_it_consumed( commands.cmd_plan(config, "release-minor", "widget-core") commands.cmd_materialize(config, "release-minor", outputs()["releases"]) - commands._commit_changelogs( + commands._commit_materialized( config, [{"package": "widget-core", "next": "0.1.0"}], + [], "Materialize changelogs", ) @@ -780,7 +781,7 @@ def test_push_prerelease_summary_links_the_branch( config: Config, dispatched: pytest.MonkeyPatch, summary: Callable[[], str] ) -> None: commands.cmd_push_prerelease( - config, "new-prerelease-minor", "main", json.dumps(PLAN) + config, "new-prerelease-minor", "main", json.dumps(PLAN), "" ) text = summary() branch = next( @@ -803,7 +804,7 @@ def test_push_prerelease_annotates_the_branch_url( capsys: pytest.CaptureFixture, ) -> None: commands.cmd_push_prerelease( - config, "new-prerelease-minor", "main", json.dumps(PLAN) + config, "new-prerelease-minor", "main", json.dumps(PLAN), "" ) notices = [ line @@ -819,7 +820,7 @@ def test_dispatch_summaries_degrade_outside_actions( ) -> None: dispatched.delenv("GITHUB_REPOSITORY") commands.cmd_push_prerelease( - config, "new-prerelease-minor", "main", json.dumps(PLAN) + config, "new-prerelease-minor", "main", json.dumps(PLAN), "" ) text = summary() assert "](" not in text @@ -834,7 +835,7 @@ def test_open_release_pr_summary_links_the_pull_request( ) -> None: url = "https://github.example.com/acme/widgets/pull/42" dispatched.setattr(commands, "gh_output", lambda *args, **kwargs: url) - commands.cmd_open_release_pr(config, "release-minor", "main", json.dumps(PLAN)) + commands.cmd_open_release_pr(config, "release-minor", "main", json.dumps(PLAN), "") text = summary() assert f"Pull request: [#42]({url})" in text assert "/tree/release/release-minor-" in text @@ -1004,3 +1005,151 @@ def test_post_release_rejects_an_unknown_package( monkeypatch.setattr(commands, "gh_run", lambda *args, **kwargs: 0) with pytest.raises(ReleaseError, match="unknown package"): commands.cmd_post_release(reloaded, "v1.2.3", "ghost", "1.2.3") + + +def dev_pin(repo: Path, requirement: str) -> Config: + """Replace the root package's dependency and reload the configuration. + + Args: + repo: The repository root. + requirement: The requirement string to declare instead. + + Returns: + The reloaded configuration. + """ + pyproject = repo / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").replace( + '"widget-core >= 0.1.0"', f'"{requirement}"' + ), + encoding="utf-8", + ) + return load_config(repo) + + +def test_plan_holds_back_an_auto_selected_unsatisfiable_pin( + config: Config, repo: Path, outputs: Outputs, summary: Callable[[], str] +) -> None: + reloaded = dev_pin(repo, "widget-core >= 9.9.9.dev1") + fragment(reloaded, "mypkg", "1.feature.md") + fragment(reloaded, "widget-core", "2.feature.md") + commands.cmd_plan(reloaded, "release-minor", "") + # The dependency can still be released; only its dependent is held back. + assert [r["package"] for r in json.loads(outputs()["releases"])] == ["widget-core"] + assert "### Held back" in summary() + + +def test_plan_rejects_an_explicit_unsatisfiable_pin( + config: Config, repo: Path, outputs: Outputs +) -> None: + reloaded = dev_pin(repo, "widget-core >= 9.9.9.dev1") + with pytest.raises(ReleaseError, match="no published version satisfies"): + commands.cmd_plan(reloaded, "release-minor", "mypkg") + + +def test_plan_holds_back_a_whole_lockstep_group( + config: Config, repo: Path, outputs: Outputs +) -> None: + """Members only ever release together, so one blocker holds back the group.""" + write_lockstep(repo) + # Not the lockstep sibling, which pin-lockstep rewrites at build time. + reloaded = dev_pin(repo, "third-party >= 9.9.9.dev1") + fragment(reloaded, "widget-core", "2.feature.md") + with pytest.raises(ReleaseError, match="every auto-selected package"): + commands.cmd_plan(reloaded, "release-minor", "") + + +def test_plan_accepts_a_pin_a_prerelease_can_satisfy( + config: Config, repo: Path, outputs: Outputs +) -> None: + reloaded = dev_pin(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0a1") + fragment(reloaded, "mypkg", "1.feature.md") + # A final version cannot take the alpha; the alpha train can. + with pytest.raises(ReleaseError, match="no published version satisfies"): + commands.cmd_plan(reloaded, "release-minor", "mypkg") + commands.cmd_plan(reloaded, "new-prerelease-minor", "mypkg") + assert [r["package"] for r in json.loads(outputs()["releases"])] == ["mypkg"] + + +def test_materialize_lifts_the_dev_pin_it_can_resolve( + config: Config, repo: Path, outputs: Outputs +) -> None: + reloaded = dev_pin(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0") + fragment(reloaded, "mypkg", "4.feature.md", "Something.") + commit_all(repo) + + commands.cmd_plan(reloaded, "release-minor", "mypkg") + commands.cmd_materialize(reloaded, "release-minor", outputs()["releases"]) + + assert '"widget-core >= 0.2.0"' in (repo / "pyproject.toml").read_text( + encoding="utf-8" + ) + + +def test_release_commit_carries_the_lifted_pins( + config: Config, repo: Path, outputs: Outputs +) -> None: + reloaded = dev_pin(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0") + fragment(reloaded, "mypkg", "5.feature.md", "Something.") + commit_all(repo) + + commands.cmd_plan(reloaded, "release-minor", "mypkg") + commands.cmd_materialize(reloaded, "release-minor", outputs()["releases"]) + # The commit runs as its own process, so materialize hands it the paths. + assert json.loads(outputs()["repinned"]) == ["pyproject.toml"] + commands._commit_materialized( + reloaded, + [{"package": "mypkg", "next": "0.1.0"}], + json.loads(outputs()["repinned"]), + "Materialize", + ) + + assert sorted(git(repo, "show", "--name-only", "--format=", "HEAD").split()) == [ + "CHANGELOG.md", + "news/5.feature.md", + "pyproject.toml", + ] + + +def test_release_commit_stages_no_pyproject_when_no_pin_moved( + config: Config, repo: Path, outputs: Outputs +) -> None: + """Only what the pin upgrade actually rewrote is staged beside the changelogs.""" + fragment(config, "mypkg", "6.feature.md", "Something.") + commit_all(repo) + commands.cmd_plan(config, "release-minor", "mypkg") + commands.cmd_materialize(config, "release-minor", outputs()["releases"]) + assert json.loads(outputs()["repinned"]) == [] + + commands._commit_materialized( + config, [{"package": "mypkg", "next": "0.1.0"}], [], "Materialize" + ) + + assert ( + "pyproject.toml" + not in git(repo, "show", "--name-only", "--format=", "HEAD").split() + ) + + +def test_release_commit_refuses_to_strand_a_lifted_pin( + config: Config, repo: Path, outputs: Outputs +) -> None: + """A workflow that predates the `repinned` output would otherwise commit the + changelog bump with the old pin, and die at the publish-time gate. + """ + reloaded = dev_pin(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0") + fragment(reloaded, "mypkg", "7.feature.md", "Something.") + commit_all(repo) + commands.cmd_plan(reloaded, "release-minor", "mypkg") + commands.cmd_materialize(reloaded, "release-minor", outputs()["releases"]) + assert json.loads(outputs()["repinned"]) == ["pyproject.toml"] + + # The delivery step of an un-synced workflow passes nothing through. + with pytest.raises(ReleaseError, match="modified but unstaged"): + commands._commit_materialized( + reloaded, [{"package": "mypkg", "next": "0.1.0"}], [], "Materialize" + ) diff --git a/tests/units/reflex_release/test_devpins.py b/tests/units/reflex_release/test_devpins.py index 7a2d00c6595..89d83189058 100644 --- a/tests/units/reflex_release/test_devpins.py +++ b/tests/units/reflex_release/test_devpins.py @@ -2,17 +2,47 @@ from __future__ import annotations +import subprocess from pathlib import Path import pytest +from packaging.version import Version from reflex_release.actions import ReleaseError -from reflex_release.config import Config +from reflex_release.config import Config, load_config from reflex_release.devpins import ( + LOCK_FILE, + PinUpgrade, + blocker_advice, + blocking_pins, check_dev_pins, parse_requirement, + pin_upgrades, published_dependencies, + upgrade_dev_pins, ) +from .conftest import git, write_lockstep + + +def set_root_dependency(repo: Path, requirement: str) -> Config: + """Replace the root package's single dependency and reload the configuration. + + Args: + repo: The repository root. + requirement: The requirement string to declare instead. + + Returns: + The reloaded configuration. + """ + pyproject = repo / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").replace( + '"widget-core >= 0.1.0"', f'"{requirement}"' + ), + encoding="utf-8", + ) + return load_config(repo) + @pytest.mark.parametrize( ("requirement", "expected"), @@ -76,3 +106,385 @@ def test_check_dev_pins_is_scoped_to_the_selected_package( def test_check_dev_pins_rejects_unknown_packages(config: Config) -> None: with pytest.raises(ReleaseError, match="unknown package"): check_dev_pins(config, ["ghost"]) + + +@pytest.mark.parametrize( + ("requirement", "bounds", "expected"), + [ + ("widget-core >= 0.2.0.dev1", ("0.2.0.dev1",), "widget-core >= 0.2.0"), + ("widget-core>=0.2.0.dev1", ("0.2.0.dev1",), "widget-core>=0.2.0"), + # Extras, the other specifiers and the marker all survive the lift. + ( + "widget-core[extra] >= 0.2.0.dev1, < 1.0 ; python_version > '3.10'", + ("0.2.0.dev1",), + "widget-core[extra] >= 0.2.0, < 1.0 ; python_version > '3.10'", + ), + # An upper bound naming a dev release is resolvable as it stands. + ( + "widget-core >= 0.2.0a1, != 0.3.0.dev1", + ("0.2.0a1",), + "widget-core >= 0.2.0, != 0.3.0.dev1", + ), + # A second lower bound that is already publishable stays put. + ( + "widget-core >= 0.2.0.dev1, > 0.1.0", + ("0.2.0.dev1",), + "widget-core >= 0.2.0, > 0.1.0", + ), + ], +) +def test_pin_upgrade_rewrites_only_the_offending_bound( + requirement: str, bounds: tuple[str, ...], expected: str +) -> None: + upgrade = PinUpgrade("mypkg", requirement, "widget-core", bounds, Version("0.2.0")) + assert upgrade.rewritten() == expected + + +def test_pin_upgrades_resolves_to_the_earliest_published_version( + repo: Path, +) -> None: + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + for tag in ("widget-core-v0.1.9", "widget-core-v0.2.0", "widget-core-v0.3.0"): + git(repo, "tag", tag) + (upgrade,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=False) + assert upgrade.resolved == Version("0.2.0") + assert upgrade.rewritten() == "widget-core >= 0.2.0" + + +def test_pin_upgrades_ignores_a_published_lower_bound(repo: Path) -> None: + reloaded = set_root_dependency(repo, "widget-core >= 0.1.0") + assert pin_upgrades(reloaded, "mypkg", allow_prereleases=False) == [] + + +def test_pin_upgrades_holds_back_an_unreleased_dev_pin(repo: Path) -> None: + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.1.9") + (upgrade,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=False) + assert upgrade.resolved is None + assert "newest tagged: 0.1.9" in upgrade.reason + + +def test_pin_upgrades_only_takes_a_prerelease_for_a_prerelease(repo: Path) -> None: + """An alpha may depend on a sibling's alpha; a final version may not.""" + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0a1") + + (final,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=False) + assert final.resolved is None + assert "no final release of widget-core" in final.reason + + (alpha,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=True) + assert alpha.resolved == Version("0.2.0a1") + + +def test_pin_upgrades_lifts_a_prerelease_floor_for_a_final_release(repo: Path) -> None: + """A final version must not floor its users on a sibling's alpha.""" + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0a1") + git(repo, "tag", "widget-core-v0.2.0a1") + git(repo, "tag", "widget-core-v0.2.0") + + (final,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=False) + assert final.resolved == Version("0.2.0") + # The same floor is fine in a prerelease, which may ship it as it stands. + assert pin_upgrades(reloaded, "mypkg", allow_prereleases=True) == [] + + +def test_pin_upgrades_leaves_an_outside_prerelease_pin_alone(repo: Path) -> None: + """Only siblings' releases are recorded here; an outside pin is deliberate.""" + reloaded = set_root_dependency(repo, "third-party >= 2.0b1") + assert pin_upgrades(reloaded, "mypkg", allow_prereleases=False) == [] + + +def test_pin_upgrades_holds_back_an_outside_dev_pin(repo: Path) -> None: + """A dev pin is unpublishable whoever owns the dependency.""" + reloaded = set_root_dependency(repo, "third-party >= 2.0.dev1") + (upgrade,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=True) + assert upgrade.resolved is None + assert "not a package in this repository" in upgrade.reason + + +def test_pin_upgrades_skips_exactly_pinned_lockstep_siblings(repo: Path) -> None: + """pin-lockstep rewrites those at build time, so nothing here is shipped.""" + write_lockstep(repo) + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + assert reloaded.exact_pin_targets("mypkg") == ("widget-core",) + assert pin_upgrades(reloaded, "mypkg", allow_prereleases=False) == [] + + +def test_pin_upgrades_respects_the_whole_specifier_set(repo: Path) -> None: + """The lifted version has to satisfy the upper bound too.""" + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1, < 0.3") + git(repo, "tag", "widget-core-v0.3.0") + (blocked,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=False) + assert blocked.resolved is None + + git(repo, "tag", "widget-core-v0.2.5") + (upgrade,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=False) + assert upgrade.resolved == Version("0.2.5") + + +def test_blocking_pins_groups_by_package(repo: Path) -> None: + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + blocked = blocking_pins(reloaded, ["mypkg", "widget-core"], allow_prereleases=False) + assert list(blocked) == ["mypkg"] + + +def test_upgrade_dev_pins_rewrites_the_pyproject(repo: Path) -> None: + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0") + assert upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) == [ + "pyproject.toml" + ] + assert '"widget-core >= 0.2.0"' in (repo / "pyproject.toml").read_text( + encoding="utf-8" + ) + # The gate the publish job runs is satisfied by the rewrite. + check_dev_pins(reloaded, ["mypkg"]) + + +def stub_uv_lock( + monkeypatch: pytest.MonkeyPatch, + returncode: int, + lock: Path | None = None, +) -> list[list[str]]: + """Answer ``uv lock`` with a fixed status, letting git run for real. + + Args: + monkeypatch: The pytest monkeypatch fixture. + returncode: The status ``uv lock`` should report. + lock: A lock file the stubbed run should rewrite, standing in for a + re-resolution that actually moves it. Left alone when None, which is + what a uv workspace does for a sibling pin. + + Returns: + The list the intercepted commands are recorded in. + """ + real_run = subprocess.run + recorded: list[list[str]] = [] + + def run(cmd, **kwargs): + if cmd[:2] != ["uv", "lock"]: + return real_run(cmd, **kwargs) + recorded.append(cmd) + if lock is not None: + lock.write_text("version = 2\n", encoding="utf-8") + return subprocess.CompletedProcess(cmd, returncode) + + monkeypatch.setattr(subprocess, "run", run) + return recorded + + +def test_upgrade_dev_pins_refreshes_the_lock_file( + repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0") + lock = repo / LOCK_FILE + lock.write_text("version = 1\n", encoding="utf-8") + recorded = stub_uv_lock(monkeypatch, 0, lock=lock) + assert upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) == [ + "pyproject.toml", + LOCK_FILE, + ] + assert recorded == [["uv", "lock"]] + + +def test_upgrade_dev_pins_omits_a_lock_file_the_pins_did_not_move( + repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A uv workspace records siblings with no specifier, so a sibling pin + cannot move the lock — reporting it would stage an unrelated diff. + """ + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0") + (repo / LOCK_FILE).write_text("version = 1\n", encoding="utf-8") + recorded = stub_uv_lock(monkeypatch, 0) + assert upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) == [ + "pyproject.toml" + ] + assert recorded == [["uv", "lock"]] + + +def test_upgrade_dev_pins_fails_when_the_lock_cannot_be_refreshed( + repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0") + (repo / LOCK_FILE).write_text("version = 1\n", encoding="utf-8") + stub_uv_lock(monkeypatch, 1) + with pytest.raises(ReleaseError, match="uv lock` failed"): + upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) + + +def test_upgrade_dev_pins_refuses_an_unsatisfiable_pin(repo: Path) -> None: + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + with pytest.raises(ReleaseError, match="no published version satisfies"): + upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) + + +def test_upgrade_dev_pins_is_a_no_op_without_pins(repo: Path) -> None: + reloaded = load_config(repo) + before = (repo / "pyproject.toml").read_text(encoding="utf-8") + assert upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) == [] + assert (repo / "pyproject.toml").read_text(encoding="utf-8") == before + + +def test_upgrade_dev_pins_lifts_every_published_copy(repo: Path) -> None: + """The same pin in `dependencies` and in an extra is published twice.""" + pyproject = repo / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").replace( + 'dependencies = ["widget-core >= 0.1.0"]', + 'dependencies = ["widget-core >= 0.2.0.dev1"]\n' + '[project.optional-dependencies]\nextra = ["widget-core >= 0.2.0.dev1"]', + ), + encoding="utf-8", + ) + reloaded = load_config(repo) + git(repo, "tag", "widget-core-v0.2.0") + upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) + text = pyproject.read_text(encoding="utf-8") + assert text.count('"widget-core >= 0.2.0"') == 2 + assert "0.2.0.dev1" not in text + + +def test_upgrade_dev_pins_ignores_an_unpublished_copy(repo: Path) -> None: + """`[dependency-groups]` is development-only, so it is neither counted nor + rewritten — the same rule `check_dev_pins` applies. + """ + pyproject = repo / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").replace( + 'dependencies = ["widget-core >= 0.1.0"]', + 'dependencies = ["widget-core >= 0.2.0.dev1"]', + ) + + '\n[dependency-groups]\ndev = ["widget-core >= 0.2.0.dev1"]\n', + encoding="utf-8", + ) + reloaded = load_config(repo) + git(repo, "tag", "widget-core-v0.2.0") + upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) + text = pyproject.read_text(encoding="utf-8") + assert 'dependencies = ["widget-core >= 0.2.0"]' in text + assert 'dev = ["widget-core >= 0.2.0.dev1"]' in text + + +def test_pin_upgrade_relaxes_a_strict_floor(repo: Path) -> None: + """`> 0.2.0.dev1` admits 0.2.0, so `> 0.2.0` would exclude what it resolved to.""" + reloaded = set_root_dependency(repo, "widget-core > 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0") + (upgrade,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=False) + assert upgrade.resolved == Version("0.2.0") + assert upgrade.rewritten() == "widget-core >= 0.2.0" + + +def test_pin_upgrade_rejects_a_rewrite_its_version_would_not_satisfy() -> None: + """The guard against a future operator gap shipping an unsatisfiable pin.""" + upgrade = PinUpgrade( + "mypkg", + # A bound the rewrite cannot lift, since 0.2.0 is not below 0.3. + "widget-core >= 0.2.0.dev1, < 0.3", + "widget-core", + ("0.2.0.dev1",), + Version("0.4.0"), + ) + with pytest.raises(ReleaseError, match="does not satisfy"): + upgrade.rewritten() + + +def test_upgrade_dev_pins_rewrites_an_escaped_toml_marker(repo: Path) -> None: + """A basic string escapes its quotes; the parsed value does not carry them.""" + requirement = 'widget-core >= 0.2.0.dev1; python_version > \\"3.10\\"' + reloaded = set_root_dependency(repo, requirement) + git(repo, "tag", "widget-core-v0.2.0") + upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) + assert 'widget-core >= 0.2.0; python_version > \\"3.10\\"' in ( + repo / "pyproject.toml" + ).read_text(encoding="utf-8") + + +def test_upgrade_dev_pins_rewrites_a_literal_toml_string(repo: Path) -> None: + pyproject = repo / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").replace( + '"widget-core >= 0.1.0"', "'widget-core >= 0.2.0.dev1'" + ), + encoding="utf-8", + ) + reloaded = load_config(repo) + git(repo, "tag", "widget-core-v0.2.0") + upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) + assert "'widget-core >= 0.2.0'" in pyproject.read_text(encoding="utf-8") + + +def test_upgrade_dev_pins_rolls_back_when_the_lock_cannot_follow( + repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Pins and lock file move together: a half-applied upgrade would be committed.""" + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0") + (repo / LOCK_FILE).write_text("version = 1\n", encoding="utf-8") + pyproject = repo / "pyproject.toml" + before = pyproject.read_text(encoding="utf-8") + stub_uv_lock(monkeypatch, 1) + + with pytest.raises(ReleaseError, match="uv lock` failed"): + upgrade_dev_pins(reloaded, ["mypkg"], allow_prereleases=False) + + assert pyproject.read_text(encoding="utf-8") == before + # A re-run therefore still has the pin to lift, rather than finding nothing + # to do and leaving the stale lock file to be committed. + assert pin_upgrades(reloaded, "mypkg", allow_prereleases=False) + + +def test_upgrade_dev_pins_rolls_back_an_earlier_package(repo: Path) -> None: + """The rewrites run package by package; one that fails must strand none.""" + reloaded = set_root_dependency(repo, "widget-core >= 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.2.0") + git(repo, "tag", "v1.0") + # The sibling's own pin resolves, but is spelled with an escape, so the + # parsed value cannot be found in the file — and it is rewritten after the + # root package's. + sub = repo / "packages" / "widget-core" / "pyproject.toml" + sub.write_text( + sub.read_text(encoding="utf-8") + + 'dependencies = ["mypkg \\u003E= 1.0.dev1"]\n', + encoding="utf-8", + ) + reloaded = load_config(repo) + root = repo / "pyproject.toml" + before = root.read_text(encoding="utf-8") + + with pytest.raises(ReleaseError, match="published copy"): + upgrade_dev_pins(reloaded, ["mypkg", "widget-core"], allow_prereleases=False) + + assert root.read_text(encoding="utf-8") == before + + +def test_pin_upgrades_calls_an_exact_dev_pin_a_dead_end(repo: Path) -> None: + """No release can satisfy `== 0.2.0.dev1`, so "release it first" is a circle.""" + reloaded = set_root_dependency(repo, "widget-core == 0.2.0.dev1") + git(repo, "tag", "widget-core-v0.3.0") + (upgrade,) = pin_upgrades(reloaded, "mypkg", allow_prereleases=False) + assert upgrade.resolved is None + assert upgrade.exact + assert "re-pin it by hand" in upgrade.reason + # The tags say nothing useful here, so the reason must not cite them. + assert "newest tagged" not in upgrade.reason + + +def test_blocker_advice_matches_what_can_be_waited_for(repo: Path) -> None: + waitable = PinUpgrade("mypkg", "a >= 1.dev1", "a", ("1.dev1",), None, "r") + dead_end = PinUpgrade( + "mypkg", "b == 1.dev1", "b", ("1.dev1",), None, "r", exact=True + ) + + assert "lifts those pins automatically" in blocker_advice({"mypkg": [waitable]}) + assert "re-pin it by hand" in blocker_advice({"mypkg": [dead_end]}) + + only_exact = blocker_advice({"mypkg": [dead_end]}) + assert "Release the depended-on package(s) first" not in only_exact + + mixed = blocker_advice({"mypkg": [waitable, dead_end]}) + assert "lifts those pins automatically" in mixed + assert "re-pin it by hand" in mixed diff --git a/tests/units/reflex_release/test_scaffold.py b/tests/units/reflex_release/test_scaffold.py index 883475d955b..eb1d4a77755 100644 --- a/tests/units/reflex_release/test_scaffold.py +++ b/tests/units/reflex_release/test_scaffold.py @@ -2,7 +2,11 @@ from __future__ import annotations +import os import re +import shutil +import subprocess +import sys from pathlib import Path import pytest @@ -943,3 +947,104 @@ def test_a_custom_build_and_a_post_release_workflow_coexist( assert step["name"] == "Trigger the post-release workflow" assert step["env"]["TAG"] == "${{ needs.prepare.outputs.tag }}" assert "prepare" in jobs["tag-and-release"]["needs"] + + +def test_the_gate_survives_the_build_path_that_is_always_skipped( + config: Config, repo: Path +) -> None: + """A skipped ancestor must not skip the upload. + + GitHub's implicit success() — the one a job gets when its ``if`` names no + status function — is evaluated over the transitive dependency closure, so + the build path that never runs would otherwise skip the upload straight + through the ``collect`` that was written to absorb it. + """ + write_custom_build(repo) + jobs = yaml.safe_load(render("publish.yml", load_config(repo)))["jobs"] + condition = jobs["publish"]["if"] + assert "needs.collect.result == 'success'" in condition + assert "!cancelled()" in condition + assert "needs.prepare.outputs.skipped != 'true'" in condition + + +def test_the_tag_is_pushed_only_after_a_successful_upload( + config: Config, repo: Path +) -> None: + """`!failure()` would not do: a skipped publish is neither failed nor cancelled.""" + write_custom_build(repo) + condition = yaml.safe_load(render("publish.yml", load_config(repo)))["jobs"][ + "tag-and-release" + ]["if"] + assert "needs.publish.result == 'success'" in condition + assert "!cancelled()" in condition + assert "!failure()" not in condition + + +# The report step is a bash script pinned to `shell: bash` on ubuntu-latest, so +# running it needs a POSIX bash. On Windows `bash` on PATH is the WSL launcher, +# which exits non-zero when no distribution is installed; Git for Windows ships +# a real one next to the git the runners already have. +_GIT_BASH = Path(os.environ.get("PROGRAMFILES", "C:/Program Files"), "Git/bin/bash.exe") +BASH = ( + (str(_GIT_BASH) if _GIT_BASH.is_file() else None) + if sys.platform == "win32" + else shutil.which("bash") +) + + +def _report_script(config: Config) -> str: + """Return the shell of the release-batch report step. + + Args: + config: The repository configuration. + + Returns: + The step's ``run`` script. + """ + document = yaml.safe_load(render("release_from_changelog.yml", config)) + return document["jobs"]["report"]["steps"][0]["run"] + + +@pytest.mark.parametrize( + ("detect", "publish", "publish_last", "any_", "any_last", "expected"), + [ + # Nothing to release: every leg is legitimately skipped. + ("success", "skipped", "skipped", "false", "false", 0), + ("success", "success", "skipped", "true", "false", 0), + ("success", "success", "success", "true", "true", 0), + # Detect found packages, yet the leg that publishes them never ran. + ("success", "skipped", "skipped", "true", "false", 1), + # A lockstep package held back is a partial release, not a no-op. + ("success", "success", "skipped", "true", "true", 1), + ("success", "failure", "skipped", "true", "false", 1), + ("failure", "skipped", "skipped", "", "", 1), + ("success", "cancelled", "skipped", "true", "false", 1), + ], +) +@pytest.mark.skipif(BASH is None, reason="no POSIX bash to run the step with") +def test_the_report_is_red_when_a_leg_with_work_did_not_publish( + config: Config, + detect: str, + publish: str, + publish_last: str, + any_: str, + any_last: str, + expected: int, +) -> None: + """A skipped leg is only healthy when detect found nothing for it to do.""" + assert BASH is not None + result = subprocess.run( + [BASH, "-c", _report_script(config)], + env={ + **os.environ, + "DETECT": detect, + "PUBLISH": publish, + "PUBLISH_LAST": publish_last, + "ANY": any_, + "ANY_LAST": any_last, + }, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == expected, result.stdout + result.stderr diff --git a/tests/units/test_route.py b/tests/units/test_route.py index ae1b81e89cb..1102211b320 100644 --- a/tests/units/test_route.py +++ b/tests/units/test_route.py @@ -104,6 +104,11 @@ def test_check_routes_conflict_invalid( ("/posts/[slug]/info/[[...splat]]", "/posts/[slug]/info1/[[...splat]]"), ("/posts/[slug]/info/[...slug1]", "/posts/[slug]/info1/[...slug1]"), ("/posts/[slug]/info/[...slug1]", "/posts/[slug]/info1/[...slug2]"), + # static siblings of dynamic segments are legal (static wins in React Router) + ("/posts/[slug]", "/posts/all/[x]"), + ("/posts/all/[x]", "/posts/[slug]"), + ("/[org]/dashboard", "/admin/devices/[pk]"), + ("/admin/devices/[pk]", "/[org]/dashboard"), ], ) def test_check_routes_conflict_valid(mocker: MockerFixture, app, route1, route2): diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 7f661aa17a4..e49e2926809 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -5139,6 +5139,89 @@ def child_view(self) -> int: assert (ParentDescState.get_full_name(), "parent_view") in parent_deps +class OnLoadCancelState(State): + """A test state whose on_load handler blocks until cancelled.""" + + # Signalling gates, populated per-test with loop-local events. + _gates: ClassVar[dict[str, asyncio.Event]] = {} + + @rx.event + async def slow_handler(self): + """Signal start, then block; signal again if cancelled.""" + type(self)._gates["started"].set() + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + type(self)._gates["cancelled"].set() + raise + + +async def test_on_load_internal_supersedes_previous_navigation( + app_module_mock, + token, + mock_root_event_context: EventContext, + mock_base_state_event_processor: BaseStateEventProcessor, +): + """A newer navigation cancels the previous unfinished on_load chain (#6593). + + Args: + app_module_mock: The app module that will be returned by get_app(). + token: A token. + mock_root_event_context: The mock root event context. + mock_base_state_event_processor: The event processor. + """ + assert OnLoadInternalState.event_handlers["on_load_internal"].supersedes + assert not State.event_handlers["hydrate"].supersedes + + app = app_module_mock.app = App(_state=State) + app._state_manager = mock_root_event_context.state_manager + + def index(): + return "hello" + + app.add_page(index, on_load=OnLoadCancelState.slow_handler) + app._compile_page("index") + + OnLoadCancelState._gates = { + "started": asyncio.Event(), + "cancelled": asyncio.Event(), + } + on_load_internal_name = format.format_event_handler( + OnLoadInternalState.on_load_internal # pyright: ignore[reportArgumentType] + ) + + async with mock_base_state_event_processor as processor: + stale = await processor.enqueue( + token, + Event( + name=on_load_internal_name, + router_data={ + RouteVar.PATH: "/", + RouteVar.ORIGIN: "/", + RouteVar.QUERY: {}, + }, + ), + ) + await asyncio.wait_for(OnLoadCancelState._gates["started"].wait(), timeout=5) + + # Navigate to a page without on_load events (fast path). + current = await processor.enqueue( + token, + Event( + name=on_load_internal_name, + router_data={ + RouteVar.PATH: "/other", + RouteVar.ORIGIN: "/other", + RouteVar.QUERY: {}, + }, + ), + ) + await asyncio.wait_for(OnLoadCancelState._gates["cancelled"].wait(), timeout=5) + # The fresh navigation completes without waiting behind the stale chain. + await asyncio.wait_for(current.wait_all(), timeout=5) + assert stale.done() + + async def test_resolve_delta_awaits_coroutines_and_keeps_plain_values(): """_resolve_delta awaits coroutine values and leaves plain values untouched.""" from reflex.state import _resolve_delta diff --git a/uv.lock b/uv.lock index c5f03b2c6e3..4212881b6da 100644 --- a/uv.lock +++ b/uv.lock @@ -4133,7 +4133,6 @@ dependencies = [ { name = "httpx" }, { name = "packaging" }, { name = "platformdirs" }, - { name = "reflex-base" }, { name = "rich" }, ] @@ -4143,7 +4142,6 @@ requires-dist = [ { name = "httpx", specifier = ">=0.25.1,<1.0" }, { name = "packaging", specifier = ">=24.2" }, { name = "platformdirs", specifier = ">=3.10.0,<5.0" }, - { name = "reflex-base", editable = "packages/reflex-base" }, { name = "rich", specifier = ">=13,<16" }, ]