diff --git a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py index 160b9b0e..0b2975dc 100644 --- a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py +++ b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py @@ -447,6 +447,9 @@ async def stream(self): def _source(obj: Any) -> str: """Source of a plain function, an ``@reflex_xy.figure`` var, or an ``@rx.event`` handler.""" + # Class-level access to an object-valued computed var hands back reflex's + # casted wrapper; the declared var (with its fget) sits behind _original. + obj = getattr(obj, "_original", obj) fget = getattr(obj, "_fget", None) if fget is not None: # a @reflex_xy.figure / computed var builder = getattr(fget, BUILDER_ATTR, None) @@ -517,7 +520,7 @@ def kv(label: str, value: Any) -> rx.Component: # §1 wiring — the live figure var and its semantic events def cloud_view() -> rx.Component: return reflex_xy.chart( - Demo.cloud, + figure=Demo.cloud, on_point_hover=Demo.on_hover, on_point_click=Demo.on_click, on_select_end=Demo.on_select, @@ -529,7 +532,7 @@ def cloud_view() -> rx.Component: # §2 wiring — a chart driven by a slider and another chart's selection def histogram_view() -> rx.Component: return rx.vstack( - reflex_xy.chart(Demo.histogram, height="240px", id="hist"), + reflex_xy.chart(figure=Demo.histogram, height="240px", id="hist"), rx.hstack( rx.text("bins", size="2", color_scheme="gray"), rx.slider( @@ -549,7 +552,7 @@ def histogram_view() -> rx.Component: # §3 wiring — a chart that grows from a background task def live_view() -> rx.Component: return rx.vstack( - reflex_xy.chart(Demo.live, height="240px", id="live"), + reflex_xy.chart(figure=Demo.live, height="240px", id="live"), rx.button( rx.cond(Demo.streaming, "stop stream", "go live"), on_click=Demo.stream, @@ -563,8 +566,10 @@ def live_view() -> rx.Component: # §4 wiring — a detail chart computed from the overview's view-change events def viewport_view() -> rx.Component: return rx.grid( - reflex_xy.chart(Demo.overview, on_view_change=Demo.on_view, height="240px", id="overview"), - reflex_xy.chart(Demo.detail, height="240px", id="detail"), + reflex_xy.chart( + figure=Demo.overview, on_view_change=Demo.on_view, height="240px", id="overview" + ), + reflex_xy.chart(figure=Demo.detail, height="240px", id="detail"), columns="2", gap="1rem", width="100%", @@ -575,7 +580,7 @@ def viewport_view() -> rx.Component: def fixed_view() -> rx.Component: return rx.grid( reflex_xy.chart(sparkline_chart(), height="240px", id="inline"), - reflex_xy.chart(ORBITS_TOKEN, height="240px", id="orbits"), + reflex_xy.chart(figure=ORBITS_TOKEN, height="240px", id="orbits"), columns="2", gap="1rem", width="100%", @@ -584,14 +589,14 @@ def fixed_view() -> rx.Component: # §6 wiring — the whole drilldown integration is this one line def drilldown_view() -> rx.Component: - return reflex_xy.chart(DRILLDOWN_TOKEN, height="430px", id="drilldown") + return reflex_xy.chart(figure=DRILLDOWN_TOKEN, height="430px", id="drilldown") # §7 wiring — legend interactivity ships with the charts; no handlers needed def legend_view() -> rx.Component: return rx.grid( reflex_xy.chart(legend_series_chart(), height="300px", id="legend-series"), - reflex_xy.chart(LEGEND_CATS_TOKEN, height="300px", id="legend-cats"), + reflex_xy.chart(figure=LEGEND_CATS_TOKEN, height="300px", id="legend-cats"), columns="2", gap="1rem", width="100%", diff --git a/python/reflex_xy/__init__.py b/python/reflex_xy/__init__.py index a12b58f6..0a554722 100644 --- a/python/reflex_xy/__init__.py +++ b/python/reflex_xy/__init__.py @@ -4,11 +4,11 @@ spec/design/reflex-integration.md in the xy repo): chart data rides the app's *existing* websocket as a second socket.io namespace — binary columns, no JSON numbers, no extra endpoints to proxy. Figures live in a -per-process registry keyed by tokens; the tokens live in Reflex state. A -`@reflex_xy.figure` state method is both the chart definition and the -recovery recipe: any worker can rebuild the figure from state when a -reconnect lands somewhere new, so there is no central figure store to -operate. +per-process registry keyed by tokens; Reflex state holds only a small typed +handle wrapping the token. A `@reflex_xy.figure` state method is both the +chart definition and the recovery recipe: any worker can rebuild the figure +from state when a reconnect lands somewhere new, so there is no central +figure store to operate. Quickstart:: @@ -32,7 +32,7 @@ def chart(self) -> xy.Chart: return xy.scatter_chart(xy.scatter(xs, ys), width="100%", height=460) def index() -> rx.Component: - return reflex_xy.chart(Dash.chart, height="460px") + return reflex_xy.chart(figure=Dash.chart, height="460px") app = rx.App() """ @@ -54,6 +54,8 @@ def index() -> rx.Component: "set_view": ".app", "setup": ".app", "chart": ".component", + "DataHandle": ".handles", + "FigureHandle": ".handles", "CanonicalRowIdGroup": ".events", "DataBounds": ".events", "Modifiers": ".events", @@ -79,6 +81,8 @@ def index() -> rx.Component: "AsyncFigureVar", "CanonicalRowIdGroup", "DataBounds", + "DataHandle", + "FigureHandle", "FigureRegistry", "FigureVar", "Modifiers", @@ -154,22 +158,25 @@ def __dir__() -> list[str]: return sorted(set(globals()) | set(__all__)) -def register(chart_or_figure: Any) -> str: - """Imperatively register a chart; returns an opaque token for state. +def register(chart_or_figure: Any) -> "FigureHandle": + """Imperatively register a chart; returns a typed handle for state. - Dev-tier API: the figure lives only in this process and cannot be - rebuilt after a worker restart or on another node — prefer - `@reflex_xy.figure` for anything long-lived (see the module doc). + The handle's ``.token`` is the registry key; pass the handle itself to + ``chart(figure=...)`` (or store it in state). Dev-tier API: the figure + lives only in this process and cannot be rebuilt after a worker restart + or on another node — prefer `@reflex_xy.figure` for anything long-lived + (see the module doc). """ + from .handles import FigureHandle from .registry import _figure_of, registry globals()["registry"] = registry - return registry.register(_figure_of(chart_or_figure)) + return FigureHandle(registry.register(_figure_of(chart_or_figure))) -def inline(chart_or_figure: Any) -> str: - """Register a fixed, kernel-backed chart at module scope; returns its token. +def inline(chart_or_figure: Any) -> "FigureHandle": + """Register a fixed, kernel-backed chart at module scope; returns its handle. For charts whose data never changes but which still want server-side drilldown/picks on the shared websocket. Call at **module scope** so the @@ -179,12 +186,13 @@ def inline(chart_or_figure: Any) -> str: cloud = reflex_xy.inline(xy.scatter_chart(xy.scatter(x, y))) def index(): - return reflex_xy.chart(cloud, height="460px") + return reflex_xy.chart(figure=cloud, height="460px") - The token is content-addressed — every worker independently derives the - same one, so the frontend's baked-in token resolves everywhere without - state or rebuild hooks. The entry is pinned (exempt from the TTL sweep): - there is no recipe to rebuild it from, so it lives with the process. + The handle's token is content-addressed — every worker independently + derives the same one, so the frontend's baked-in token resolves + everywhere without state or rebuild hooks. The entry is pinned (exempt + from the TTL sweep): there is no recipe to rebuild it from, so it lives + with the process. Shared by design: one figure object serves every viewer, so kernel-side drill state is shared too (like N notebook views of one widget). Data @@ -192,6 +200,7 @@ def index(): no kernel at all can be passed straight to `reflex_xy.chart()` (static payload tier). """ + from .handles import FigureHandle from .registry import _figure_of, registry globals()["registry"] = registry @@ -202,16 +211,21 @@ def index(): digest = hashlib.sha256(canonical + blob).hexdigest()[:20] token = f"xyin-{digest}" registry.publish(token, fig, broadcast=False, pinned=True) - return token + return FigureHandle(token) -def release(token: str) -> None: - """Drop a registered figure (idempotent).""" +def release(token: "str | FigureHandle") -> None: + """Drop a registered figure (idempotent). Takes a handle or its token.""" + from .handles import token_of from .registry import registry globals()["registry"] = registry - registry.release(token) + resolved = token_of(token) + if resolved is None: + msg = f"expected a FigureHandle or figure token string, got {type(token).__name__}" + raise TypeError(msg) + registry.release(resolved) if TYPE_CHECKING: @@ -229,6 +243,7 @@ def release(token: str) -> None: SelectionPayload, ViewChangeEvent, ) + from .handles import DataHandle, FigureHandle from .namespace import XY_NAMESPACE, XYNamespace from .registry import FigureRegistry, registry from .selections import resolve_selection diff --git a/python/reflex_xy/app.py b/python/reflex_xy/app.py index 9a2ccfae..12542c2e 100644 --- a/python/reflex_xy/app.py +++ b/python/reflex_xy/app.py @@ -25,6 +25,7 @@ from reflex.plugins import Plugin +from .handles import FigureHandle, token_of from .namespace import XYNamespace from .registry import registry from .state_bridge import make_rebuild_hook @@ -89,8 +90,17 @@ def post_compile(self, **context: Any) -> None: setup(app) +def _token(source: "str | FigureHandle") -> str: + """Normalize a public figure argument (handle or bare token string).""" + token = token_of(source) + if token is None: + msg = f"expected a FigureHandle or figure token string, got {type(source).__name__}" + raise TypeError(msg) + return token + + def append( - token: str, + token: "str | FigureHandle", x: Any, y: Any, *, @@ -103,26 +113,28 @@ def append( Thin alias for `registry.append` — see its docstring for the threading contract. """ - registry.append(token, x, y, color=color, size=size, trace=trace) + registry.append(_token(token), x, y, color=color, size=size, trace=trace) -def set_view(token: str, ranges: Any, *, animate: bool = True, history: bool = True) -> None: +def set_view( + token: "str | FigureHandle", ranges: Any, *, animate: bool = True, history: bool = True +) -> None: """Out-of-band programmatic view patch (view-state.md §5.2). Mirrors `append`: callable from any event handler, background task, or thread; one wire message pushed room-wide, applied by every client through the same clamped mutation path as a gesture, `source: "api"`. """ - registry.set_view(token, ranges, animate=animate, history=history) + registry.set_view(_token(token), ranges, animate=animate, history=history) -def reset_view(token: str, axes: Any = None) -> None: +def reset_view(token: "str | FigureHandle", axes: Any = None) -> None: """Out-of-band navigation to the home ranges (room-wide).""" - registry.reset_view(token, axes) + registry.reset_view(_token(token), axes) def select( - token: str, + token: "str | FigureHandle", *, range: Any = None, polygon: Any = None, @@ -132,12 +144,12 @@ def select( """Out-of-band programmatic selection (room-wide). Geometric forms resolve client-side like a gesture; `rows=` resolves kernel-side and is non-durable (see view-state.md §5.1).""" - registry.select(token, range=range, polygon=polygon, rows=rows, history=history) + registry.select(_token(token), range=range, polygon=polygon, rows=rows, history=history) -def clear_selection(token: str) -> None: +def clear_selection(token: "str | FigureHandle") -> None: """Out-of-band selection clear (room-wide).""" - registry.clear_selection(token) + registry.clear_selection(_token(token)) def reset_setup_for_tests() -> None: diff --git a/python/reflex_xy/assets/XYChart.jsx b/python/reflex_xy/assets/XYChart.jsx index 11513693..91feaa90 100644 --- a/python/reflex_xy/assets/XYChart.jsx +++ b/python/reflex_xy/assets/XYChart.jsx @@ -1,8 +1,10 @@ // XYChart: mount a xy figure inside a Reflex app. // -// Two modes, one prop apart (spec/design/reflex-integration.md): +// Two modes (spec/design/reflex-integration.md). A live subscription has +// two spellings that reduce to one token: `figure` ({token} — the typed +// FigureHandle) and the deprecated bare `token` string. // -// `token` (live) — this component does NOT open its own connection. +// Live — this component does NOT open its own connection. // socket.io multiplexing reuses the app's engine.io websocket when the // manager options match, so `xySocket()` below constructs its `/_xy` // namespace socket with exactly the options Reflex's own `connect()` uses @@ -259,6 +261,7 @@ const pointEnvelope = (type, token, row, extra = {}) => { export function XYChart(props) { const { token, + figure, src, onPointHover, onPointClick, @@ -276,6 +279,11 @@ export function XYChart(props) { ...divProps } = props; void _tailwindClassTokens; + // One subscription token from the two live spellings. `figure` is the + // typed handle ({token}); the bare `token` string is the deprecated wire. + // A present handle always wins — its empty token means "not ready" (no + // subscription yet), never a fallback to the legacy spelling. + const liveToken = figure != null ? figure.token || null : token || null; const elRef = useRef(null); // inner chart mount (wiped on payload swaps) const outerRef = useRef(null); // stable wrapper: events, tooltip slot const tooltipSlotRef = useRef(null); @@ -284,7 +292,7 @@ export function XYChart(props) { const [hoverPayload, setHoverPayload] = useState(null); const hasTooltipChildrenRef = useRef(false); hasTooltipChildrenRef.current = Boolean(children); - dbg("render", { id: divProps.id, token: String(token).slice(0, 30), src }); + dbg("render", { id: divProps.id, token: String(liveToken).slice(0, 30), src }); // Live callback refs so socket handlers never close over stale props. const cbRef = useRef({}); cbRef.current = { @@ -398,8 +406,8 @@ export function XYChart(props) { // Live mode: subscribe on the shared websocket. useEffect(() => { const el = elRef.current; - dbg("effect run", { token: token && token.slice(0, 24), hasEl: !!el }); - if (!token || src || !el) return undefined; + dbg("effect run", { token: liveToken && liveToken.slice(0, 24), hasEl: !!el }); + if (!liveToken || src || !el) return undefined; const socket = xySocket(); const mid = `m${nextMountId++}`; let view = null; @@ -445,14 +453,14 @@ export function XYChart(props) { // A reconnect can land on a fresh worker whose rebuilt figure starts at // version 1. Versions are monotonic only within this subscription epoch. resetEpoch(); - socket.emit("sub", { fig: token, px: el.clientWidth || null, mid }); + socket.emit("sub", { fig: liveToken, px: el.clientWidth || null, mid }); }; const emitMessage = (m) => { // socket.io flushes its sendBuffer before firing `connect`; never queue // an old-epoch request while the namespace is disconnected. if (awaitingPayload || !socket.connected) return; - const envelope = { fig: token, mid, m }; + const envelope = { fig: liveToken, mid, m }; if (payloadVersion !== null) envelope.v = payloadVersion; socket.emit("msg", envelope); }; @@ -499,7 +507,7 @@ export function XYChart(props) { const latest = pendingHover; pendingHover = null; if (!destroyed && latest && cbRef.current.onPointHover) { - cbRef.current.onPointHover(pointEnvelope("point_hover", token, latest)); + cbRef.current.onPointHover(pointEnvelope("point_hover", liveToken, latest)); } }, HOVER_THROTTLE_MS); }; @@ -509,7 +517,7 @@ export function XYChart(props) { cbRef.current.onViewChange({ version: 1, type: "view_change", - token, + token: liveToken, x_domain: [m.x0, m.x1], y_domain: [m.y0, m.y1], source: m.source, @@ -673,7 +681,7 @@ export function XYChart(props) { ); const onPayload = (data) => { - if (destroyed || !data || data.fig !== token) return; + if (destroyed || !data || data.fig !== liveToken) return; // Direct subscription replies are mount-addressed; room-wide rebuild // broadcasts intentionally omit mid and remain visible to every mount. if (data.mid !== undefined && data.mid !== null && data.mid !== mid) return; @@ -781,7 +789,7 @@ export function XYChart(props) { }; const onMsg = (data) => { - if (destroyed || !data || data.fig !== token) return; + if (destroyed || !data || data.fig !== liveToken) return; // Replies are mount-addressed; pushes (append) carry no mid. if (data.mid !== undefined && data.mid !== null && data.mid !== mid) return; const message = data.message; @@ -831,7 +839,7 @@ export function XYChart(props) { if (!clickWasPending) return; if (message.type === "pick_result" && message.row) { cbRef.current.onPointClick?.( - pointEnvelope("point_click", token, message.row, clickInput || {}), + pointEnvelope("point_click", liveToken, message.row, clickInput || {}), ); } return; // synthetic pick — not for the view @@ -857,7 +865,7 @@ export function XYChart(props) { cbRef.current.onSelectEnd({ version: 1, type: "select_end", - token, + token: liveToken, selection: { kind: cleared ? "clear" : (message.kind || "box"), mode: message.mode || "replace", @@ -890,7 +898,7 @@ export function XYChart(props) { }; const onErr = (data) => { - if (destroyed || !data || data.fig !== token) return; + if (destroyed || !data || data.fig !== liveToken) return; console.warn(`xy: ${data.error} (fig ${data.fig})`); if (data.resync === true && socket.connected) subscribe(); }; @@ -907,7 +915,7 @@ export function XYChart(props) { // shared manager, rooms are gone and — on another backend node — the // figure itself may need a state-driven rebuild. `sub` triggers both. socket.on("connect", subscribe); - subCounts.set(token, (subCounts.get(token) || 0) + 1); + subCounts.set(liveToken, (subCounts.get(liveToken) || 0) + 1); if (socket.connected) subscribe(); const rememberClick = (event) => { @@ -938,12 +946,12 @@ export function XYChart(props) { socket.off("err", onErr); socket.off("disconnect", onDisconnect); socket.off("connect", subscribe); - const remaining = (subCounts.get(token) || 1) - 1; + const remaining = (subCounts.get(liveToken) || 1) - 1; if (remaining <= 0) { - subCounts.delete(token); - if (socket.connected) socket.emit("unsub", { fig: token, mid }); + subCounts.delete(liveToken); + if (socket.connected) socket.emit("unsub", { fig: liveToken, mid }); } else { - subCounts.set(token, remaining); + subCounts.set(liveToken, remaining); } reclaimTooltipSlot(); if (view) view.destroy(); @@ -951,7 +959,7 @@ export function XYChart(props) { window.__xy_views?.delete(outerRef.current?.id || mid); el.replaceChildren(); }; - }, [token, src]); + }, [liveToken, src]); // One DOM node, two consumers: our mount logic and reflex's ref registry. const mergedRef = (node) => { diff --git a/python/reflex_xy/component.py b/python/reflex_xy/component.py index 655d0c27..2655c174 100644 --- a/python/reflex_xy/component.py +++ b/python/reflex_xy/component.py @@ -1,12 +1,15 @@ """The Reflex component: `reflex_xy.chart(...)`. -One factory, three chart sources (spec/design/reflex-integration.md §5): +One factory, two chart sources (spec/design/reflex-integration.md §5): - reflex_xy.chart(Dash.chart) # @reflex_xy.figure state var (live) - reflex_xy.chart(some_token_string) # register()/inline() token (live) + reflex_xy.chart(figure=Dash.chart) # @reflex_xy.figure var / handle (live) reflex_xy.chart(xy.scatter_chart(...)) # a Chart directly (static tier) -A live source compiles to the `token` prop and rides the shared-websocket +The pre-handle positional forms — `chart(Dash.chart)` and +`chart(token_string)` — remain as a deprecation shim for one release cycle. + +A live source compiles to the typed `figure` prop (`Var[FigureHandle]` — +wrong vars and raw strings fail at compile) and rides the shared-websocket data plane. A `xy` Chart (or internal Figure) passed directly is compiled to a static payload asset (payload_asset.py) and lands in the `src` prop: the wrapper fetches the binary frame and runs the render client @@ -37,6 +40,7 @@ from __future__ import annotations +import warnings from collections.abc import Iterable, Mapping, Set from typing import Annotated, Any, Optional @@ -45,11 +49,18 @@ from xy.facets import FacetGrid from .assets import WRAPPER_TAG, register +from .handles import FigureHandle from .payload_asset import payload_asset from .registry import _figure_of __all__ = ["chart"] +#: Event props that need the interaction kernel: they only ever fire for +#: live (token/figure) sources. A static payload (``src``) renders and +#: navigates client-side, so these would be silent no-ops — refused at +#: create() instead (see _validate_source_events). +_KERNEL_EVENT_PROPS = ("on_point_hover", "on_point_click", "on_select_end") + # Lazily-built component class (see module doc); Any because reflex Component # metaclasses defeat static typing of the create() classmethod. _component_cls: Optional[Any] = None @@ -66,8 +77,13 @@ class XYChart(rx.Component): library = wrapper_library tag = WRAPPER_TAG - # Live mode: the figure token minted by @reflex_xy.figure / - # register() / inline(). Exactly one of token/src is ever set. + # Live mode: the typed figure handle minted by @reflex_xy.figure / + # register() / inline(). ``Var[FigureHandle]`` makes a wrong var + # (``Dash.points``) or a raw string fail at create() — compile time + # (fact R1). Exactly one of figure/token/src is ever set. + figure: rx.Var[FigureHandle] + # Deprecated live mode: the bare token string. Kept for one release + # cycle; the wrapper accepts both (figure wins). token: rx.Var[str] # Static mode: URL of a payload asset (XYBF frame) to render # kernel-less. @@ -102,6 +118,34 @@ class XYChart(rx.Component): # legacy row form; new code uses this. on_hover: Annotated[rx.EventHandler, lambda payload: [payload]] + @classmethod + def create(cls, *children: Any, **props: Any) -> Any: + # Compile-time validation the framework can't do for us + # (recharts pattern, fact R5): kernel-backed events on a static + # source would be silent no-ops at runtime — fail the compile + # with the reason instead. + # `on_point_hover=None` is an explicitly disabled handler: drop it + # (Reflex would reject a None trigger) so the value-based static + # check below and the framework both see "no handler". + props = { + name: value + for name, value in props.items() + if value is not None or not name.startswith("on_") + } + if props.get("src") is not None: + offenders = [name for name in _KERNEL_EVENT_PROPS if props.get(name) is not None] + if offenders: + msg = ( + f"{', '.join(offenders)} need the interaction kernel and never " + "fire on a static chart source (a Chart/Figure compiled to a " + "payload asset). Serve the figure live instead — a " + "@reflex_xy.figure state var or register()/inline() — " + "or drop the handler(s). Client-side " + "events (on_hover, on_view_change) work on static charts." + ) + raise ValueError(msg) + return super().create(*children, **props) + # The class is created lazily inside this function; reflex derives JS # identifiers from __qualname__, and "" would leak an illegal # "<" into compiled import names. Present it as a module-level class. @@ -267,19 +311,41 @@ def _facet_grid( ) +def _is_handle_var(source: Any) -> bool: + """A Reflex Var whose declared value type is FigureHandle.""" + var_type = getattr(source, "_var_type", None) + return isinstance(var_type, type) and issubclass(var_type, FigureHandle) + + +def _warn_positional(replacement: str) -> None: + warnings.warn( + f"positional reflex_xy.chart(source) is deprecated for live sources; use {replacement}", + DeprecationWarning, + stacklevel=3, + ) + + def chart( - source: Any, + source: Any = None, *, + figure: Any = None, tooltip: Any = None, tailwind_classes: Optional[str | Iterable[str]] = None, **props: Any, ) -> Any: """Place a xy chart. - `source` is a figure token (a `@reflex_xy.figure` state var, or a - `register()`/`inline()` token string) for a live, kernel-backed chart — - or a `xy` Chart/Figure directly, which renders as a static - payload asset with client-side interactivity only (see module doc). + ``figure=`` is the live, kernel-backed form: a ``@reflex_xy.figure`` + state var, or the :class:`~reflex_xy.handles.FigureHandle` returned by + ``register()``/``inline()``. The prop is typed ``Var[FigureHandle]``, so + the wrong var or a raw string fails at compile with the framework's own + ``TypeError``. + + A positional `source` remains supported: an ``xy`` Chart/Figure renders + as a static payload asset with client-side interactivity only (see + module doc; this is the static tier and stays positional), while + var/handle/token-string sources are the deprecated pre-handle spelling + of ``figure=`` and warn. `tooltip=` mounts a Reflex component as the chart tooltip: the render client positions it with the built-in tooltip's placement logic (the @@ -299,7 +365,33 @@ def chart( """ component_cls = _component() tailwind_manifest = _tailwind_class_tokens(tailwind_classes) - if isinstance(source, (str, rx.Var)): + if figure is not None and source is not None: + msg = "reflex_xy.chart() takes a positional source or figure=, not both" + raise TypeError(msg) + if figure is None and source is None: + msg = "reflex_xy.chart() needs a chart source: figure=, or a positional Chart/Figure" + raise TypeError(msg) + if figure is None and ( + isinstance(source, FigureHandle) or (isinstance(source, rx.Var) and _is_handle_var(source)) + ): + # Pre-handle spelling: the var/handle used to land in the str `token` + # prop. Handles ride the typed prop now; legacy str-typed vars keep + # the old wire path below. + _warn_positional("chart(figure=...)") + figure, source = source, None + if figure is not None: + props.setdefault("width", "100%") + props.setdefault("height", "420px") + props["figure"] = figure + if tailwind_manifest: + props["tailwind_class_tokens"] = _tailwind_scan_literal(tailwind_manifest) + elif isinstance(source, (str, rx.Var)): + _warn_positional( + "chart(figure=...) — register()/inline() return a FigureHandle, " + "@reflex_xy.figure vars are FigureHandle-valued, and a stored bare " + "token wraps as figure=FigureHandle(token) (the typed prop rejects " + "raw strings)" + ) props.setdefault("width", "100%") props.setdefault("height", "420px") props["token"] = source @@ -310,8 +402,9 @@ def chart( # payload and its Tailwind scan manifest. In particular, do not call # build_payload() just to discover classes: that would duplicate the # largest part of static-chart compilation. - if tooltip is None and callable(getattr(source, "chrome_components", None)): - tooltip = source.chrome_components().get("tooltip") + chrome_components = getattr(source, "chrome_components", None) + if tooltip is None and callable(chrome_components): + tooltip = chrome_components().get("tooltip") figure = _figure_of(source) if isinstance(figure, FacetGrid): return _facet_grid( @@ -331,8 +424,9 @@ def chart( props["tailwind_class_tokens"] = _tailwind_scan_literal(class_manifest) else: msg = ( - "reflex_xy.chart() takes a figure token (state var or string) or a " - f"xy Chart/Figure, got {type(source).__name__}" + "reflex_xy.chart() takes figure= (a @reflex_xy.figure var or " + "FigureHandle) or a positional xy Chart/Figure, got " + f"{type(source).__name__}" ) raise TypeError(msg) if tooltip is not None: diff --git a/python/reflex_xy/handles.py b/python/reflex_xy/handles.py new file mode 100644 index 00000000..8d22b591 --- /dev/null +++ b/python/reflex_xy/handles.py @@ -0,0 +1,85 @@ +"""Typed handles: the values chart-related state vars carry. + +The integration keeps figures and columns in the per-process registry; +Reflex state only ever holds a small *handle* wrapping the registry token +(spec/design/reflex-integration.md §5). Wrapping the token in a frozen +dataclass instead of a bare ``str`` is what gives the component seam a +compile-time type: ``XYChart.figure`` / ``XYChart.data`` are ``Var[T]`` +props, so passing the wrong var (``Dash.points``) or a raw string fails at +``create()`` — page evaluation — with the framework's own ``TypeError`` +(design facts R1/R7, pinned in tests/reflex_adapter/test_framework_contracts.py). + +``DataHandle`` is generic over the data method's return annotation: +``DataHandle[CloudData]`` survives as the class-level Var's ``_var_type``, +which is the schema channel the flat factories read column names from — +without executing any user code. + +The empty token is the "not ready / no chart" sentinel (pre-hydration, or a +builder returning ``None``), so the var types stay non-optional. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Generic, TypeVar + +import reflex as rx + +__all__ = ["DataHandle", "FigureHandle"] + +S = TypeVar("S") + + +@dataclass(frozen=True) +class FigureHandle: + """Reference to a registered figure; ``token`` is the registry key.""" + + token: str = "" + + def __bool__(self) -> bool: + return bool(self.token) + + +@dataclass(frozen=True) +class DataHandle(Generic[S]): + """Reference to a registered column set; ``token`` is the registry key. + + The type parameter carries the data method's declared schema (a + ``TypedDict``) purely at the annotation level. + """ + + token: str = "" + + def __bool__(self) -> bool: + return bool(self.token) + + +# Serializers keep the state-delta path a plain dict (and make reflex's +# guess_type treat handle-valued vars as object vars). + + +@rx.serializer +def serialize_figure_handle(handle: FigureHandle) -> dict: + return {"token": handle.token} + + +@rx.serializer +def serialize_data_handle(handle: DataHandle) -> dict: + return {"token": handle.token} + + +def token_of(source: object) -> str | None: + """The token string of a *figure* source; None otherwise. + + The one-cycle compatibility seam: public helpers that take "a figure" + (``append``, ``set_view``, ``release``, ...) accept both a handle and the + old-style bare token string through this normalizer. A ``DataHandle`` + names a column set, never a figure — figure-only operations must reject + it immediately (None here) instead of addressing a room that can never + exist. + """ + if isinstance(source, FigureHandle): + return source.token + if isinstance(source, str): + return source + return None diff --git a/python/reflex_xy/vars.py b/python/reflex_xy/vars.py index c21a7ad3..53f7190c 100644 --- a/python/reflex_xy/vars.py +++ b/python/reflex_xy/vars.py @@ -1,11 +1,12 @@ """`@reflex_xy.figure`: a computed var that *is* the chart registration. The pattern (spec/design/reflex-integration.md): the state method builds the -chart from state, the computed var's value is only the figure *token*, and +chart from state, the computed var's value is only a typed +:class:`~reflex_xy.handles.FigureHandle` wrapping the figure *token*, and evaluating the var is what (re)registers the figure in the per-process registry. Reflex's own dependency tracking decides when that happens: -- first render: var evaluates -> figure built -> token into state. +- first render: var evaluates -> figure built -> handle into state. - a dependency changes: reflex marks the var dirty, the next delta evaluation rebuilds the figure and re-publishes it; subscribers get the fresh payload pushed over the data plane. The token itself is stable, so @@ -37,6 +38,7 @@ from reflex_base.vars.base import AsyncComputedVar, ComputedVar +from .handles import FigureHandle from .registry import _figure_of, registry from .tokens import BUILDER_ATTR, build_state_token @@ -53,14 +55,14 @@ def _builder_target(var: Any, obj: Any) -> Any: class FigureVar(ComputedVar): - """ComputedVar whose value is a figure token (sync builder).""" + """ComputedVar whose value is a FigureHandle (sync builder).""" def _deps(self, objclass: Any, obj: Any = None) -> dict[str, set[str]]: return ComputedVar._deps(self, objclass, obj=_builder_target(self, obj)) class AsyncFigureVar(AsyncComputedVar): - """AsyncComputedVar whose value is a figure token (async builder).""" + """AsyncComputedVar whose value is a FigureHandle (async builder).""" def _deps(self, objclass: Any, obj: Any = None) -> dict[str, set[str]]: return AsyncComputedVar._deps(self, objclass, obj=_builder_target(self, obj)) @@ -69,19 +71,20 @@ def _deps(self, objclass: Any, obj: Any = None) -> dict[str, set[str]]: def _mint_token(state: Any, builder_name: str) -> Optional[str]: """Deterministic token for this (session, state, var) — or None pre-hydration (no session yet, so no figure to serve; the component - treats "" as "not ready" and waits for the hydrated value).""" + treats the empty-token handle as "not ready" and waits for the + hydrated value).""" client_token = state.router.session.client_token if not client_token: return None return build_state_token(client_token, type(state).get_full_name(), builder_name) -def _publish(token: str, chart: Any) -> str: +def _publish(token: str, chart: Any) -> FigureHandle: if chart is None: registry.release(token) - return "" + return FigureHandle("") registry.publish(token, _figure_of(chart)) - return token + return FigureHandle(token) def _adopt_identity(fget: Any, builder: Callable[..., Any], name: str) -> None: @@ -92,13 +95,13 @@ def _adopt_identity(fget: Any, builder: Callable[..., Any], name: str) -> None: setattr(fget, BUILDER_ATTR, builder) -def _make_fget(builder: Callable[[Any], Any]) -> Callable[[Any], str]: +def _make_fget(builder: Callable[[Any], Any]) -> Callable[[Any], FigureHandle]: builder_name = _fn_name(builder) - def fget(self: Any) -> str: + def fget(self: Any) -> FigureHandle: token = _mint_token(self, builder_name) if token is None: - return "" + return FigureHandle("") return _publish(token, builder(self)) _adopt_identity(fget, builder, builder_name) @@ -108,10 +111,10 @@ def fget(self: Any) -> str: def _make_async_fget(builder: Callable[[Any], Any]) -> Callable[[Any], Any]: builder_name = _fn_name(builder) - async def fget(self: Any) -> str: + async def fget(self: Any) -> FigureHandle: token = _mint_token(self, builder_name) if token is None: - return "" + return FigureHandle("") return _publish(token, await builder(self)) _adopt_identity(fget, builder, builder_name) @@ -156,7 +159,7 @@ async def remote(self) -> xy.Chart: rows = await fetch_rows(self.query) # db / http / store return xy.line_chart(xy.line(rows.t, rows.value)) - # in the page: reflex_xy.chart(Dash.chart, height="480px") + # in the page: reflex_xy.chart(figure=Dash.chart, height="480px") The method must return a public ``xy`` chart (or an internal Figure), or ``None`` for "no chart right now". ``async def`` builders @@ -177,8 +180,8 @@ def _decorate(fn: Callable[[Any], Any]) -> "FigureVar | AsyncFigureVar": raise ValueError(msg) var_kwargs.setdefault("cache", True) if inspect.iscoroutinefunction(fn): - return AsyncFigureVar(fget=_make_async_fget(fn), return_type=str, **var_kwargs) - return FigureVar(fget=_make_fget(fn), return_type=str, **var_kwargs) + return AsyncFigureVar(fget=_make_async_fget(fn), return_type=FigureHandle, **var_kwargs) + return FigureVar(fget=_make_fget(fn), return_type=FigureHandle, **var_kwargs) if builder is None: return _decorate diff --git a/spec/design/reflex-integration.md b/spec/design/reflex-integration.md index c9b5dc14..23b4a9ec 100644 --- a/spec/design/reflex-integration.md +++ b/spec/design/reflex-integration.md @@ -233,27 +233,34 @@ class Dash(rx.State): return xy.line_chart(xy.line(rows.t, rows.value), width="100%", height=220) ``` -`@reflex_xy.figure` is a computed var whose **value is only the token -string** — `xyv1|||` — and whose -evaluation is what (re)registers the figure in the per-process registry. +`@reflex_xy.figure` is a computed var whose **value is only a typed +`FigureHandle`** — a frozen dataclass wrapping the token string +`xyv1|||` (serialized to +`{"token": …}` on the delta path by a registered `@rx.serializer`) — and +whose evaluation is what (re)registers the figure in the per-process +registry. The handle type is what makes the component seam compile-checked: +the component's `figure` prop is `Var[FigureHandle]`, so the wrong var or a +raw string fails at `create()` with the framework's own `TypeError` +(design fact R1, pinned in `tests/reflex_adapter/test_framework_contracts.py`). Reflex's own dependency tracker watches the *builder's* body (the var subclass points dependency analysis at it), so: -- First render: var evaluates → figure built and registered → token into +- First render: var evaluates → figure built and registered → handle into state. - A dependency changes: Reflex marks the var dirty; the next delta evaluation rebuilds the figure and re-publishes; every subscriber gets a fresh payload pushed over the data plane. The token is deterministic, so the frontend sees **no prop change at all** — pixels move, DOM doesn't. -- Reconnect (same node or another): the cached token comes back with the +- Reconnect (same node or another): the cached handle comes back with the state; the component re-`sub`s; hit → serve, miss → §3.2. -Two values are not tokens. Before session hydration there is no client -token to mint from, so the var evaluates to `""`; and a builder may return -`None` for "no chart right now", which **releases** any existing registry -entry and likewise yields `""`. The wrapper treats `""` as "not ready / no -chart" and mounts nothing, so both cases are a blank mount rather than an -error. +Two values carry no token. Before session hydration there is no client +token to mint from, so the var evaluates to `FigureHandle("")`; and a +builder may return `None` for "no chart right now", which **releases** any +existing registry entry and likewise yields the empty handle. The wrapper +treats the empty token as "not ready / no chart" and mounts nothing, so +both cases are a blank mount rather than an error — and the var type stays +non-optional. Async builders are first-class, mirroring reflex's own `ComputedVar`/`AsyncComputedVar` split with the same @@ -459,7 +466,7 @@ publishes and always ships the latest payload. ```python reflex_xy.chart( - Dash.cloud, # a figure var / inline() / register() token… + figure=Dash.cloud, # a figure var, or an inline()/register() handle on_point_hover=Dash.on_hover, # semantic events -> normal handlers on_select_end=Dash.on_select, tailwind_classes="rounded-xl dark:bg-slate-950", # build-time scan inventory @@ -469,12 +476,38 @@ reflex_xy.chart( reflex_xy.chart(xy.line_chart(...)) # …or a Chart directly: static tier (§3.4) ``` -One factory, dispatched on the source: tokens (state vars or strings) -compile to the `token` prop and ride the socket data plane; a Chart/Figure -passed directly compiles to a payload asset and lands in the `src` prop, -which the wrapper fetches and renders kernel-less. Semantic-event props -apply to live sources; a static chart resolves hover tooltips client-side -but dispatches no backend events. +One factory, dispatched on the source. `figure=` takes the live tier: a +`@reflex_xy.figure` state var or the `FigureHandle` returned by +`register()`/`inline()`, landing in the typed `figure` prop +(`Var[FigureHandle]`) and riding the socket data plane. Because the prop is +`Var`-typed, `chart(figure=Dash.points)` and `chart(figure="raw string")` +fail at `create()` — page evaluation, before any browser — with the +framework's `TypeError` (R1, §3.1). A Chart/Figure +passed positionally compiles to a payload asset and lands in the `src` +prop, which the wrapper fetches and renders kernel-less — the static tier +stays positional (it is the only route for arbitrary Charts, e.g. facet +grids) and is not deprecated. + +**Deprecation (one release cycle).** The pre-handle positional spellings — +`chart(Dash.cloud)` and `chart(token_string)` — remain as a shim and warn: +handle-typed sources (vars or `FigureHandle`s) are routed to `figure=`; +legacy `str`-typed vars and raw token strings keep the old `Var[str]` +`token` prop, which the wrapper still accepts alongside `figure` +(`figure` wins when both are set). Public APIs that take "a figure" +(`append`, `set_view`, `reset_view`, `select`, `clear_selection`, +`release`) accept both a `FigureHandle` and its bare `.token` string — +and *only* those: a `DataHandle` (columns, never a figure) or any other +value raises `TypeError` immediately instead of resolving to a token that +can never name a figure room. + +Kernel-backed event props (`on_point_hover`, `on_point_click`, +`on_select_end`) on a static `src` source are refused at `create()` with a +`ValueError` naming the live alternatives — previously they compiled and +silently never fired. Client-resolved events (`on_hover`, +`on_view_change`, animation events) stay valid on every tier. A +`None`-valued `on_*` prop is an explicitly disabled handler: `create()` +drops it before validation and before the framework sees it, so +`on_point_hover=None` is legal on every tier. Static Chart/Figure sources mirror every class string from `Figure.dom_class_strings()` into the scan-only `tailwindClassTokens` JSX prop, @@ -550,7 +583,7 @@ def inspect_point(self, event: dict): self.last_id = event["canonical_row_id"] self.last_xy = event["data"] -reflex_xy.chart(Dash.cloud, on_point_click=Dash.inspect_point) +reflex_xy.chart(figure=Dash.cloud, on_point_click=Dash.inspect_point) ``` Selection events use the following shape. P0 supports deterministic `replace` @@ -649,7 +682,7 @@ def remember_view(self, event: dict): self.x_domain = event["x_domain"] self.y_domain = event["y_domain"] -reflex_xy.chart(Dash.cloud, on_view_change=Dash.remember_view) +reflex_xy.chart(figure=Dash.cloud, on_view_change=Dash.remember_view) ``` Every kernel request echoes the last payload version as `v`; the namespace @@ -687,13 +720,15 @@ python/reflex_xy/ registry.py token -> FigureEntry(figure, version, lock); TTL; publish/push fan-out seams; append tokens.py xyv1 token grammar; builder discovery on vars + handles.py FigureHandle / DataHandle[S] (+ serializers): + the typed values chart state vars carry vars.py @reflex_xy.figure (FigureVar: builder-tracked deps) state_bridge.py token -> state_manager -> builder rebuild hook namespace.py XYNamespace: sub/unsub/msg, payload/msg/err, affinity, rebuild-on-miss, binary attachments app.py setup(app), XYPlugin (post_compile), lifespan - component.py chart() -> rx.Component (local-JSX library); - dispatches token (live) vs Chart (static tier) + component.py chart(figure=...) -> rx.Component (local-JSX + library); typed figure prop; static tier payload_asset.py static tier: Chart -> content-addressed XYBF asset in assets/xy/ (§3.4) assets/ XYChart.jsx; links xy's installed render client @@ -712,7 +747,8 @@ examples/reflex/ (repo root) Reflex showcase: figure-var drilldown with examples/fastapi/ (repo root) the same charts + a live 100M drilldown served from a plain FastAPI app (no committed HTML) tests/reflex_adapter/ token/registry/var/bridge/payload-asset units, - component compile, and a real-websocket + component compile, framework contract pins + (R1/R7/R8), and a real-websocket integration suite (uvicorn + socketio client) covering payload/pick/select/affinity/rebuild/ publish-broadcast/append/unsub diff --git a/tests/reflex_adapter/test_async_figure_var.py b/tests/reflex_adapter/test_async_figure_var.py index 9eaa250c..390c4f02 100644 --- a/tests/reflex_adapter/test_async_figure_var.py +++ b/tests/reflex_adapter/test_async_figure_var.py @@ -77,22 +77,23 @@ def test_await_registers_caches_and_rebuilds(_fresh_registry, client_token): calls_before = BUILDER_CALLS["count"] async def main(): - token = await state.chart - entry = _fresh_registry.get(token) + handle = await state.chart + assert isinstance(handle, reflex_xy.FigureHandle) + entry = _fresh_registry.get(handle.token) assert entry is not None assert entry.figure.traces[0].n_points == 50 # cache hit: the builder (and its awaited fetch) must not rerun - assert await state.chart == token + assert await state.chart == handle assert BUILDER_CALLS["count"] == calls_before + 1 - # dependency change -> dirty -> re-await rebuilds, token stable + # dependency change -> dirty -> re-await rebuilds, handle stable state.n = 120 type(state).computed_vars["chart"].mark_dirty(state) - assert await state.chart == token + assert await state.chart == handle assert BUILDER_CALLS["count"] == calls_before + 2 - assert _fresh_registry.get(token).version == 2 - assert _fresh_registry.get(token).figure.traces[0].n_points == 120 + assert _fresh_registry.get(handle.token).version == 2 + assert _fresh_registry.get(handle.token).figure.traces[0].n_points == 120 asyncio.run(main()) @@ -100,7 +101,7 @@ async def main(): def test_pre_hydration_returns_empty(_fresh_registry): root = rx.State(_reflex_internal_init=True) state = root.get_substate(tuple(AsyncVarDemo.get_full_name().split("."))[1:]) - assert asyncio.run(state.chart) == "" + assert asyncio.run(state.chart) == reflex_xy.FigureHandle("") assert len(_fresh_registry) == 0 @@ -108,11 +109,11 @@ def test_none_chart_unregisters(_fresh_registry, client_token): state = hydrated_substate(client_token) async def main(): - token = await state.maybe_chart + token = (await state.maybe_chart).token assert _fresh_registry.get(token) is not None state.n = -1 type(state).computed_vars["maybe_chart"].mark_dirty(state) - assert await state.maybe_chart == "" + assert await state.maybe_chart == reflex_xy.FigureHandle("") assert _fresh_registry.get(token) is None asyncio.run(main()) diff --git a/tests/reflex_adapter/test_component.py b/tests/reflex_adapter/test_component.py index 3eb92143..0c490780 100644 --- a/tests/reflex_adapter/test_component.py +++ b/tests/reflex_adapter/test_component.py @@ -40,11 +40,17 @@ def app_cwd(tmp_path, monkeypatch): def test_component_compiles_with_events(app_cwd): - comp = reflex_xy.chart("tok-abc", on_point_hover=CompState.picked, height="300px", id="chart1") + comp = reflex_xy.chart( + figure=reflex_xy.FigureHandle("tok-abc"), + on_point_hover=CompState.picked, + height="300px", + id="chart1", + ) assert comp.tag == "XYChart" assert str(comp.library).startswith("$/public/external/reflex_xy/assets/XYChart") rendered = str(comp) - assert 'token:"tok-abc"' in rendered + # the handle's token reaches the *figure* prop, not just any prop + assert 'figure:({ ["token"] : "tok-abc" })' in rendered assert "onPointHover" in rendered assert "picked" in rendered # the reflex event dispatch is in the prop @@ -56,19 +62,69 @@ def test_component_compiles_with_events(app_cwd): assert (ext / "xy_client.js").resolve().read_bytes()[:16] -def test_component_accepts_var_token(app_cwd): - class TokState(rx.State): - tok: str = "" +def test_component_accepts_figure_var(app_cwd): + class FigState(rx.State): + @reflex_xy.figure + def fig(self): + return None - comp = reflex_xy.chart(TokState.tok) + comp = reflex_xy.chart(figure=FigState.fig, height="300px") rendered = str(comp) - assert "tok" in rendered + assert "figure" in rendered + assert "fig" in rendered # default sizing keeps the mount visible before the first payload assert "height" in rendered.lower() +def test_component_rejects_wrong_var_and_raw_string_at_compile(app_cwd): + """The Phase-0 R1 contract, through the real component: the typed + ``figure`` prop fails the compile for a non-handle var or a string.""" + + class WrongVarState(rx.State): + points: int = 5 + + with pytest.raises(TypeError, match="figure"): + reflex_xy.chart(figure=WrongVarState.points) + with pytest.raises(TypeError, match="figure"): + reflex_xy.chart(figure="xyv1|raw|token|string") + + +def test_positional_var_source_warns_and_routes_to_figure(app_cwd): + class ShimState(rx.State): + @reflex_xy.figure + def fig(self): + return None + + with pytest.warns(DeprecationWarning, match="figure="): + comp = reflex_xy.chart(ShimState.fig) + assert "figure" in str(comp) + + +def test_positional_token_string_warns_but_keeps_the_token_prop(app_cwd): + with pytest.warns(DeprecationWarning, match="figure="): + comp = reflex_xy.chart("tok-abc") + assert 'token:"tok-abc"' in str(comp) + + +def test_kernel_events_on_static_source_fail_at_compile(app_cwd): + static_chart = xy.line_chart(xy.line([0, 1], [1, 2])) + with pytest.raises(ValueError, match=r"on_select_end.*static"): + reflex_xy.chart(static_chart, on_select_end=CompState.picked) + # client-side events stay valid on the static tier + comp = reflex_xy.chart(static_chart, on_view_change=CompState.picked, on_hover=CompState.picked) + assert "onViewChange" in str(comp) + + +def test_explicitly_disabled_kernel_events_pass_on_static_source(app_cwd): + """``on_point_hover=None`` means "no handler" — a disabled handler must + not fail the static-source compile (value check, not key presence).""" + static_chart = xy.line_chart(xy.line([0, 1], [1, 2])) + comp = reflex_xy.chart(static_chart, on_point_hover=None, on_select_end=None) + assert "src" in str(comp) + + def test_component_import_is_local_library(app_cwd): - comp = reflex_xy.chart("tok") + comp = reflex_xy.chart(figure=reflex_xy.FigureHandle("tok")) imports = comp._get_all_imports() lib = [k for k in imports if "XYChart" in k] assert lib, f"wrapper import missing from {list(imports)}" @@ -155,7 +211,7 @@ def test_static_facet_chart_compiles_as_grid_of_panel_payloads(app_cwd): def test_live_chart_does_not_claim_runtime_classes_are_compile_time_known(app_cwd): - rendered = str(reflex_xy.chart("xyfig-runtime")) + rendered = str(reflex_xy.chart(figure=reflex_xy.FigureHandle("xyfig-runtime"))) assert "tailwindClassTokens" not in rendered @@ -163,7 +219,7 @@ def test_live_chart_accepts_explicit_tailwind_scan_inventory(app_cwd): """Token payloads are runtime-only; callers can expose complete utilities.""" rendered = str( reflex_xy.chart( - "xyfig-runtime", + figure=reflex_xy.FigureHandle("xyfig-runtime"), tailwind_classes=[ "rounded-[28px] border-fuchsia-500", "dark:bg-slate-950 hover:bg-fuchsia-100", @@ -181,7 +237,7 @@ def test_tailwind_scan_inventory_is_verbatim_for_live_and_static_charts(app_cwd) manifest = " ".join(TAILWIND_SCAN_EDGE_CLASSES) live_rendered = str( reflex_xy.chart( - "xyfig-runtime", + figure=reflex_xy.FigureHandle("xyfig-runtime"), tailwind_classes=TAILWIND_SCAN_EDGE_CLASSES, ) ) @@ -257,4 +313,4 @@ def test_tailwind_inventory_requires_literal_strings(app_cwd, value): match=r"tailwind_classes must be a string or ordered iterable of strings|" "tailwind_classes must contain only strings", ): - reflex_xy.chart("xyfig-runtime", tailwind_classes=value) + reflex_xy.chart(figure=reflex_xy.FigureHandle("xyfig-runtime"), tailwind_classes=value) diff --git a/tests/reflex_adapter/test_figure_var.py b/tests/reflex_adapter/test_figure_var.py index 3c83f250..7f63f25f 100644 --- a/tests/reflex_adapter/test_figure_var.py +++ b/tests/reflex_adapter/test_figure_var.py @@ -45,26 +45,27 @@ def test_deps_track_the_builder_not_the_wrapper(): assert deps == {VarDemo.get_full_name(): {"n", "_scale"}} -def test_evaluation_registers_and_token_parses(_fresh_registry, client_token): +def test_evaluation_registers_and_returns_a_parsing_handle(_fresh_registry, client_token): state = hydrated_substate(client_token) - token = state.chart - parsed = parse_token(token) + handle = state.chart + assert isinstance(handle, reflex_xy.FigureHandle) + parsed = parse_token(handle.token) assert parsed is not None assert parsed.client_token == client_token assert parsed.state_full_name == VarDemo.get_full_name() assert parsed.var_name == "chart" - entry = _fresh_registry.get(token) + entry = _fresh_registry.get(handle.token) assert entry is not None assert entry.figure.traces[0].n_points == 100 -def test_dep_change_keeps_token_bumps_version(_fresh_registry, client_token): +def test_dep_change_keeps_handle_bumps_version(_fresh_registry, client_token): state = hydrated_substate(client_token) - token = state.chart + handle = state.chart state.n = 250 VarDemo.computed_vars["chart"].mark_dirty(state) - assert state.chart == token # stable identity: frontend never re-renders - entry = _fresh_registry.get(token) + assert state.chart == handle # stable identity: frontend never re-renders + entry = _fresh_registry.get(handle.token) assert entry.version == 2 assert entry.figure.traces[0].n_points == 250 @@ -79,31 +80,33 @@ async def main(): _fresh_registry.attach_loop(asyncio.get_running_loop()) _fresh_registry.on_publish(hook) state = hydrated_substate(client_token) - token = state.chart # first registration: new entry, no fan-out needed yet + handle = state.chart # first registration: new entry, no fan-out needed yet state.n = 300 VarDemo.computed_vars["chart"].mark_dirty(state) - assert state.chart == token + assert state.chart == handle await asyncio.sleep(0.02) - return token + return handle.token token = asyncio.run(main()) assert published == [(token, 2)] -def test_pre_hydration_returns_empty(_fresh_registry): +def test_pre_hydration_returns_empty_handle(_fresh_registry): root = rx.State(_reflex_internal_init=True) state = root.get_substate(tuple(VarDemo.get_full_name().split("."))[1:]) - assert state.chart == "" # no client token yet -> no figure, no crash + # no client token yet -> the empty-token "not ready" sentinel, no crash + assert state.chart == reflex_xy.FigureHandle("") + assert not state.chart assert len(_fresh_registry) == 0 def test_none_chart_unregisters(_fresh_registry, client_token): state = hydrated_substate(client_token) - token = state.maybe_chart + token = state.maybe_chart.token assert _fresh_registry.get(token) is not None state.n = -1 VarDemo.computed_vars["maybe_chart"].mark_dirty(state) - assert state.maybe_chart == "" + assert state.maybe_chart == reflex_xy.FigureHandle("") assert _fresh_registry.get(token) is None @@ -125,16 +128,16 @@ def _hidden(self): def test_var_value_survives_state_serialization(_fresh_registry, client_token): - """Simulates the reconnect-on-another-node handoff: the token rides the + """Simulates the reconnect-on-another-node handoff: the handle rides the state serializer (as it would through redis); the figure does not.""" state = hydrated_substate(client_token) - token = state.chart + handle = state.chart payload = state._serialize() assert payload # pickles fine with a registered figure in play - _fresh_registry.release(token) # "another node": no local figure + _fresh_registry.release(handle.token) # "another node": no local figure restored = VarDemo._deserialize(payload) # The cached var value comes back verbatim WITHOUT re-running the # builder — exactly why the namespace needs the rebuild-from-state path. - assert restored.chart == token - assert _fresh_registry.get(token) is None + assert restored.chart == handle + assert _fresh_registry.get(handle.token) is None diff --git a/tests/reflex_adapter/test_payload_asset.py b/tests/reflex_adapter/test_payload_asset.py index 768f55d0..e53a2cca 100644 --- a/tests/reflex_adapter/test_payload_asset.py +++ b/tests/reflex_adapter/test_payload_asset.py @@ -110,43 +110,53 @@ def test_chart_component_accepts_figure_directly(app_cwd, _fresh_registry): def test_chart_component_rejects_junk(app_cwd): - with pytest.raises(TypeError, match=r"figure token .* or a"): + with pytest.raises(TypeError, match=r"figure=.*or a positional"): reflex_xy.chart(42) -def test_inline_token_is_stable_and_pinned(_fresh_registry): - token = reflex_xy.inline(make_chart(seed=3.0)) - assert token.startswith("xyin-") - assert parse_token(token) is None # opaque: no session affinity, shared +def test_inline_handle_is_stable_and_pinned(_fresh_registry): + handle = reflex_xy.inline(make_chart(seed=3.0)) + assert isinstance(handle, reflex_xy.FigureHandle) + assert handle.token.startswith("xyin-") + assert parse_token(handle.token) is None # opaque: no session affinity, shared # same content, e.g. another worker importing the module -> same token - assert reflex_xy.inline(make_chart(seed=3.0)) == token - assert reflex_xy.inline(make_chart(seed=4.0)) != token + assert reflex_xy.inline(make_chart(seed=3.0)) == handle + assert reflex_xy.inline(make_chart(seed=4.0)) != handle - entry = _fresh_registry.get(token) + entry = _fresh_registry.get(handle.token) assert entry is not None and entry.pinned # pinned entries survive the TTL sweep (no rebuild recipe exists) assert _fresh_registry.sweep(now=entry.last_access + 10**9) == [] - assert _fresh_registry.get(token) is not None + assert _fresh_registry.get(handle.token) is not None def test_unpinned_entries_still_sweep(_fresh_registry): - token = reflex_xy.register(make_chart()) - entry = _fresh_registry.get(token) + handle = reflex_xy.register(make_chart()) + entry = _fresh_registry.get(handle.token) dropped = _fresh_registry.sweep(now=entry.last_access + 10**9) - assert dropped == [token] + assert dropped == [handle.token] -def test_inline_chart_component_uses_token(app_cwd, _fresh_registry): - token = reflex_xy.inline(make_chart()) - comp = reflex_xy.chart(token) +def test_inline_chart_component_uses_figure_prop(app_cwd, _fresh_registry): + handle = reflex_xy.inline(make_chart()) + comp = reflex_xy.chart(figure=handle) rendered = str(comp) - assert f'token:"{token}"' in rendered + assert f'"{handle.token}"' in rendered + assert "figure" in rendered assert "src" not in rendered -def test_component_var_still_routes_to_token(app_cwd): +def test_positional_handle_routes_to_figure_prop_with_warning(app_cwd, _fresh_registry): + handle = reflex_xy.inline(make_chart()) + with pytest.warns(DeprecationWarning, match="figure="): + comp = reflex_xy.chart(handle) + assert f'"{handle.token}"' in str(comp) + + +def test_component_str_var_still_routes_to_token(app_cwd): class SrcTokState(rx.State): tok: str = "" - comp = reflex_xy.chart(SrcTokState.tok) + with pytest.warns(DeprecationWarning, match="figure="): + comp = reflex_xy.chart(SrcTokState.tok) assert "token:" in str(comp) diff --git a/tests/reflex_adapter/test_registry.py b/tests/reflex_adapter/test_registry.py index 593eabb7..78c4908c 100644 --- a/tests/reflex_adapter/test_registry.py +++ b/tests/reflex_adapter/test_registry.py @@ -26,6 +26,21 @@ def test_register_release_roundtrip(_fresh_registry): registry.release(token) # idempotent +def test_figure_only_helpers_reject_data_handles_and_junk(_fresh_registry): + """A DataHandle names columns, never a figure: figure-only operations + fail immediately instead of resolving to a room that can never exist.""" + import reflex_xy + from reflex_xy.handles import DataHandle + + data = DataHandle("xyd1|client-token-1234|app.app.State|cloud") + with pytest.raises(TypeError, match="DataHandle"): + reflex_xy.release(data) + with pytest.raises(TypeError, match="DataHandle"): + reflex_xy.append(data, [0.0], [0.0]) + with pytest.raises(TypeError, match="int"): + reflex_xy.release(123) # never silently registry.release("") + + def test_release_preserves_version_while_rebuildable_subscriber_remains( _fresh_registry, ): @@ -309,8 +324,8 @@ def test_figure_accepts_chart_or_figure(_fresh_registry): import reflex_xy - token = reflex_xy.register(chart) # public API accepts the composed Chart - assert reflex_xy.registry.get(token) is not None + handle = reflex_xy.register(chart) # public API accepts the composed Chart + assert reflex_xy.registry.get(handle.token) is not None def test_entry_lock_serializes(_fresh_registry): diff --git a/tests/test_example_apps.py b/tests/test_example_apps.py index df1b7635..ebb02de4 100644 --- a/tests/test_example_apps.py +++ b/tests/test_example_apps.py @@ -194,9 +194,9 @@ def test_reflex_app_introspection_and_composition(tmp_path, monkeypatch) -> None assert "@reflex_xy.figure" in module._source(module.Demo.cloud) assert "def cloud" in module._source(module.Demo.cloud) assert "def on_view" in module._source(module.Demo.on_view) - # The page composes without error and mints inline() tokens at import. - assert module.ORBITS_TOKEN.startswith("xyin-") - assert module.DRILLDOWN_TOKEN.startswith("xyin-") + # The page composes without error and mints inline() handles at import. + assert module.ORBITS_TOKEN.token.startswith("xyin-") + assert module.DRILLDOWN_TOKEN.token.startswith("xyin-") assert module.DRILLDOWN_POINTS == 50000 assert module.index() is not None