diff --git a/spec/design/reflex-component-api-implementation.md b/spec/design/reflex-component-api-implementation.md new file mode 100644 index 00000000..6b22abab --- /dev/null +++ b/spec/design/reflex-component-api-implementation.md @@ -0,0 +1,380 @@ +# Data-bound chart components — implementation plan + +**Status: implemented — Phases 0–4 landed (2026-08); this document is the +executed work plan plus its completion record (see "Completion record" +at the end).** Design authority: +[`reflex-component-api-options.md`](reflex-component-api-options.md) +(Option 6 + Option 2 for the escape hatch; §7 phasing; decision record in +its §8). The shipped behavior is specified in +[`reflex-integration.md`](reflex-integration.md) — §3.1 (handles + compile +probe), §3.6 (data vars, plans, composite tokens), §5 (component tiers and +deprecations). Each phase was implemented as written unless the completion +record notes otherwise; every phase landed with its spec updates in the +same change (repo rule: a change is incomplete while its spec is stale). + +Target DX (recap): + +```python +class CloudData(TypedDict): + x: np.ndarray; y: np.ndarray; mag: np.ndarray + +class Dash(rx.State): + points: int = 200_000 + + @rxy.data + def cloud(self) -> CloudData: ... + +def index(): + return rxy.scatter_chart(data=Dash.cloud, x="x", y="y", + color="mag", colormap="viridis", + height="460px", on_select_end=Dash.select) +``` + +--- + +## Phase 0 — pin the ground (tests only) + +Everything later rests on behavior verified by probe but not yet pinned. +These tests fail loudly when a Reflex or grammar upgrade moves the ground. + +**New: `tests/reflex_adapter/test_framework_contracts.py`** +(runs under the `reflex` extra job, like the rest of `tests/reflex_adapter/`) + +- A computed var may return a parametrized generic frozen dataclass + (`Handle[SomeTypedDict]`); the class-level Var's `_var_type` preserves the + full alias; `typing.get_args` recovers the TypedDict; `get_type_hints` + yields its keys (fact R7). +- Same preservation for a base var `list[Handle[Schema]]` and for the + element Var produced by indexing / `rx.foreach` (R7, foreach half). +- A component prop annotated `rx.Var[Handle]` accepts the parametrized var + and raises `TypeError` at `create()` for an int var and for a raw string + (R1). +- An unknown `on_*` kwarg raises `ValueError` at `create()`; an unknown + non-event kwarg is silently absorbed into `style` (R8 — this test + *documents the hazard* the factories must compensate for; if Reflex ever + starts rejecting these, our partition layer gets simpler). + +**New core-side test (beside the existing composition-API tests in +`tests/`): validation-timing pins (facts X1–X3)** + +- Zero-row construction compiles: for every mark kind Phase 2/3 will cover, + `xy._chart(xy.([], ...)).figure()` succeeds. +- Mark config validates at `.figure()`, not construction + (`xy.scatter([1], [1], colormap="bogus")` constructs; `.figure()` raises). +- Chrome nodes validate eagerly (`xy.x_axis(type_="bogus")` raises at call). +- `Chart.figure()` memoizes; data rebinding requires a fresh `Chart`. + +**Acceptance:** suite green in CI on the pinned Reflex floor; a comment in +each test names the design fact (R1/R7/R8/X1–X3) it pins. + +--- + +## Phase 1 — typed seam (small, non-breaking) + +Make the component a real, exported, prop-typed Reflex component and the +figure var's value a typed handle. + +**New: `python/reflex_xy/handles.py`** + +- `@dataclass(frozen=True) class FigureHandle: token: str = ""` and + `class DataHandle(Generic[S]): token: str = ""`. +- One-line `@rx.serializer` each → `dict` (delta-path safety; also makes + `guess_type` produce an `ObjectVar`). +- Empty token = "not ready / no chart" — the existing `""` sentinel wrapped, + so the var type stays non-optional. + +**`python/reflex_xy/vars.py`** + +- `FigureVar` / `AsyncFigureVar`: `return_type=FigureHandle`; `_publish` + returns `FigureHandle(token)` / `FigureHandle("")`. + +**`python/reflex_xy/component.py`** + +- Component class gains `figure: rx.Var[FigureHandle]`; the wrapper reads + `figure.token`. Keep the `token: rx.Var[str]` prop for one deprecation + cycle (wire/JSX accepts both). +- `chart()` gains keyword form `chart(figure=Dash.cloud, ...)`. The + positional `chart(source, ...)` stays as a shim: state-var/handle sources + route to `figure=`, str tokens and Chart/Figure objects keep today's + behavior; emit a deprecation warning for the positional form. +- `create()`-override validation (recharts pattern): semantic-event props on + a static (`src`) source → clear error instead of silent no-op; malformed + `tailwind_classes` keeps its existing eager errors. + +**`python/reflex_xy/__init__.py`** — `register()` / `inline()` return +`FigureHandle` (their token remains inside; `.token` documented). The shim +accepts old-style str for one cycle. + +**`python/reflex_xy/assets/XYChart.jsx`** — accept `figure` object prop +(`{token}`) alongside legacy `token` string. + +**Tests:** extend `tests/reflex_adapter/test_component.py` (typed-prop +rejection cases mirror Phase 0's contract test but through the real +component) and `test_figure_var.py` (handle-valued var; empty-token +sentinel). + +**Spec:** `reflex-integration.md` §5 — `figure=` prop, handle type, +deprecation note. + +**Acceptance:** existing demo app (`examples/reflex/`) runs unmodified; +`chart(figure=Dash.points)` fails at compile with the framework's +`TypeError`; full gate (`pre-commit`, `ruff`, `ty`, `pytest`) green. + +--- + +## Phase 2 — `@rxy.data` + flat factories (the headline) + +### 2.1 `@rxy.data` — new `python/reflex_xy/data.py` + +`DataVar` / `AsyncDataVar`, structurally a sibling of `FigureVar` (same +`_deps` builder-targeting, same `iscoroutinefunction` dispatch, same +pre-session short-circuit): + +- fget: mint `xyd1|||` token; run the data method; + validate the returned mapping (str keys; array-likes; consistent lengths — + the only checks that need real data); publish **columns** to the registry; + return `DataHandle(token)`. +- `return_type` set from the method's return annotation: + `DataHandle[CloudData]` when it's a TypedDict, plain `DataHandle` + otherwise — this is the schema channel (R7); nothing executes to read it. +- `None` return → release + `DataHandle("")`, mirroring figure vars. +- Underscore names refused at decoration (same reason as figure vars: the + handle must sync to the client). + +**`python/reflex_xy/tokens.py`** — add the `xyd1` grammar +(`xyd1|client|state|var`, same charset rules as `xyv1`); `parse_token` +learns the new prefix; `builder_of` works unchanged (DataVars are computed +vars on the state class). + +### 2.2 Plans — new `python/reflex_xy/plan.py` + +`ChartPlan`: the validated, data-free chart structure. + +- **Build** (at factory call = page evaluation): construct the real xy tree + (marks with string channels, chrome nodes, chart props); bind zero-row + placeholder columns for every referenced channel name; call `.figure()` + once — the full mark/config validation gate at compile (X2). Discard the + probe figure. +- **Serialize**: dataclass nodes → canonical JSON (sorted keys, versioned + `plan_version: 1`) → sha256 → `digest`. Register in a process-local + `{digest: ChartPlan}` map. Page bodies run in every worker (X4 — but see + the completion record: backend-only workers needed this made true by + construction), so the map is populated everywhere; a lookup miss + (hot-reload drift) answers `err {resync}` with a message naming the + digest. +- **Bind** (at serve time): columns + plan → fresh `Chart` (never reuse — X3) + → `.figure()` → `Figure`. Column-mismatch errors name both sides: + *"plan binds column 'mag'; Dash.cloud produced {x, y}"*. +- Bonus wired here: the zero-row probe figure's `dom_class_strings()` gives + live charts automatic Tailwind discovery — mirror into the existing + `tailwind_class_tokens` scan prop (today live sources need the manual + inventory). + +### 2.3 Flat factories — new `python/reflex_xy/factories.py` + +Phase 2 kinds: `scatter_chart`, `line_chart`, `histogram_chart`, +`bar_chart`. Each flat factory: + +1. **Partitions kwargs** — derived from `inspect.signature` of the xy mark + (positional params = channels, keyword-only = options; the convention is + uniform across `components.py`) plus chart props, component fields, + event triggers, and style passthrough. + - Collision table (component/chart level wins): `width`, `height`, + `opacity`, `style`, `class_name`, `key`, `animation`. Colliding mark + options get flat aliases (`stroke_width` for `line.width`); the table + is *generated* into the docs and pinned by a test — public contract. + - Unknown kwarg close to a known name (difflib) → error with suggestion; + far from everything → style passthrough (preserves Reflex's CSS + convention). The Phase 0 R8 test documents why this layer must exist. +2. **Checks columns** — `get_args(data_var._var_type)` → TypedDict → + channel strings validated with the *Available columns* error. Untyped + `dict[str, ...]` → skip; checked at first execution. +3. **Builds the plan** (2.2) and returns the component with props + `plan` (literal digest, baked into JSX) + `data` (`Var[DataHandle]`). +4. **Static tier:** `data=` given concrete arrays/mapping (not a Var) → + bind immediately and route to the existing `payload_asset` path + (`src` prop) — works under `reflex export`. + +### 2.4 Transport & registry + +Composite figure identity, minimal wire change: + +- The wrapper subscribes with `fig = "xyp1||"`, + assembled client-side once `data.token` is non-empty. Rooms, `mid` + addressing, versioning, and the attachment-cap logic in + `namespace.py` are reused unchanged — one new token prefix to parse. +- **`registry.py`**: column entries (token → columns + version) beside + figure entries; figure cache keyed by the composite token, plus an index + `data_token → {digests}` so a data republish rebuilds and broadcasts + every dependent figure. Figure entries stay derived caches: TTL-sweepable, + rebuildable from (plan map, data rebuild). +- **`namespace.py`**: `sub`/`msg` on `xyp1|…` → plan lookup; data resolve + (registry hit, else rebuild); bind → publish → serve. Affinity check uses + the client token embedded in the `xyd1` half. +- **`state_bridge.py`**: `rebuild_data(app, parsed)` mirroring + `rebuild_figure` — resolve state class, find the DataVar's method, run it + (await if async), return columns. +- **`assets/XYChart.jsx`**: accept `plan` + `data` props; compose the + subscription token; everything downstream (payload epochs, appends, + view-state) unchanged. + +### 2.5 Tests + +- `test_data_var.py` — handle value, schema in `_var_type`, pre-session + short-circuit, `None` release, underscore refusal, async variant. +- `test_plan.py` — digest stability (goldens), zero-row validation catches + bad colormap/enum/axis-ref at build, bind produces fresh figures, + version field. +- `test_factories.py` — partition + collision table, did-you-mean, + TypedDict column errors (including through a foreach item var), untyped + fallback, static-tier routing. +- Extend `test_socket_data_plane.py` — composite `sub`, payload, pick; + data republish fans out to all dependent plans; rebuild-on-miss for both + halves; affinity refusal; plan-miss `err {resync}`. +- Demo app: add one data-bound chart to `examples/reflex/` beside the + existing figure-var charts (both models exercised by + `scripts/reflex_ws_smoke.py`). + +**Spec:** `reflex-integration.md` — new section for the data plane vars + +plan tier + composite tokens; `wire-protocol.md` touched only if the +envelope grows a field (target: it doesn't — composite token rides `fig`). +Decision record added to `reflex-component-api-options.md`. + +**Acceptance:** the target-DX snippet at the top of this file runs; every +row of the error-catalog table in the options doc §5.6 reproduces at the +stated phase (import/compile/hydrate); 100M-point state delta is still just +the handle; full gate green. + +--- + +## Phase 3 — composed `rxy.chart(...)` + remaining kinds + +- `factories.py`: `rxy.chart(*nodes, data=..., **props)` consuming xy + mark/annotation/chrome nodes (plain xy dataclasses — they never enter the + Reflex tree; the factory builds the plan before any component exists). +- `__init__.py` lazy exports: curated re-exports of mark + chrome + constructors (`rxy.scatter` *is* `xy.scatter`, etc.) so hallucinated + names die at import against the explicit export map. +- Remaining `*_chart` kinds via the same signature derivation. +- **Decision point (resolve during this phase):** the data-taking composite + factories (`pie_chart`, `radar_chart`, `wind_rose`, `sankey`) do eager + numeric work at call time — they don't fit the plan/zero-row model. + Options: (a) static-tier / concrete-data only, (b) escape hatch only, + (c) column-ref variants in core xy. Record the choice in the spec. +- Facets: static facet grid already works; live data-bound facets deferred + (tracked in the options doc's open questions). +- Plan format frozen: goldens + a compatibility note in the spec. + +**Acceptance:** the composed example from the options doc runs; `rx.cond` +between two composed charts compiles and validates both. + +--- + +## Phase 4 — compile probe for the escape hatch + +- `vars.py`: `@rxy.figure(probe="build" | "figure" | False)` — default + `"build"` for sync builders, `False` for async. +- `app.py` (`XYPlugin` compile hook): walk state classes, find FigureVars, + default-construct the substate, run the builder; `probe="figure"` + additionally compiles the result. Errors re-raise wrapped with state + class, var name, and source location. Builders touching + `self.router`/session degrade to a warning, not a compile failure + (constraint 2's escape valve). +- Tests: hallucinated-API builder fails compile; session-dependent builder + downgrades; opt-out honored; async skipped by default. + +**Spec:** `reflex-integration.md` §3.1 — probe semantics and defaults. + +--- + +## Deferred (tracked, do not start without a design pass) + +- Keyed dataset collections for `foreach` (one data var = one dataset + today; a runtime-length collection needs a keyed extension). +- Per-mark `data=` (two sources, one chart). +- Core-xy metadata registry (stubs/docs generation) — nice-to-have, not a + prerequisite; signature derivation covers v1. +- Upstream Reflex PRs, both sites located in the monorepo: + generic attribute access + (`packages/reflex-base/src/reflex_base/utils/types.py`, + `get_attribute_access_type` ~:436 — `get_origin()` unwrap) enabling + `x=Dash.cloud.x`; Var-type child coercion + (`packages/reflex-base/src/reflex_base/components/component.py` ~:1314) + enabling bare `rx.card(State.cloud)`. + +## Rollout & deprecation + +- Phases 1–4 are additive. Deprecations (positional `chart(source)`, str + tokens in public returns, legacy `token` JSX prop) warn for one release + cycle after Phase 2 ships, then tighten. The `@rxy.figure` tier is + permanent (escape hatch), not deprecated. +- CI order unchanged: `abi_smoke` / `render_smoke_nonumpy` / + `append_stream_smoke` first (none are affected), adapter suite under the + `reflex` extra, browser E2E via `scripts/reflex_ws_smoke.py`. +- Every phase: run the full pre-commit + ruff + ty + pytest gate before + commit (repo rule); spec updates land in the same PR as the code. + +## Risks + +| risk | mitigation | +|---|---| +| Reflex upgrade changes Var/prop machinery | Phase 0 contract tests fail first, named after the design facts | +| Grammar change alters plan JSON → digest churn | digests are content addresses: old subscribers get `err {resync}` and re-sub against the new plan; goldens catch *accidental* churn | +| Kwarg partition drifts from xy signatures | partition is derived from `inspect.signature` at import, not hand-listed; collision table pinned by test | +| Data republish fan-out amplifies (one data var, many plans) | reuse the existing coalescing broadcast machinery; index bounded by mounted plans; measure in the §12 harness before optimizing | +| Attachment cap on column-heavy payloads | unchanged: the namespace's `_MAX_WIRE_ATTACHMENTS` single-blob fallback applies to bound figures exactly as today | + +--- + +## Completion record (2026-08) + +All five phases landed together; the acceptance criteria of each phase hold +(the target-DX snippet at the top runs verbatim; every row of the options +doc §5.6 error catalog reproduces at its stated phase; the demo app runs on +the modern API; the full gate is green). Deviations from the letter of the +plan, all recorded in the options doc §8 decision record: + +- **Phase 0** — as written: `tests/reflex_adapter/test_framework_contracts.py` + (R1/R7/R8) and `tests/test_validation_timing.py` (X1–X3, plus the exact + zero-row kind list the factories rely on). Verified against reflex 0.9.8. +- **Phase 1** — as written, except the deprecation warning fires only for + positional *live* sources; the positional static Chart/Figure form stays + undeprecated (it is the only route for arbitrary Charts, e.g. facet + grids). Kernel-only events on static sources now fail `create()`. +- **Phase 2** — as written, with `data.py` renamed `data_vars.py` + (submodule/export shadowing) and one addition beyond the plan: a + registry→namespace error seam (`err {resync}` room fan-out) so a column + republish whose bind fails cannot freeze subscribers silently; the + wrapper bounds consecutive err-driven resyncs. +- **Phase 3** — composed `chart(*nodes, data=...)`, curated re-exports + (`reflex_xy.scatter` *is* `xy.scatter`), and seven more flat kinds (area, + step, stem, column, errorbar, error_band, segments). **Decision point + resolved:** aggregating marks (box, violin, hexbin, contour, heatmap, + stairs, ecdf) and the data-taking composite factories (pie, radar, + wind_rose, sankey) are excluded from the plan tier — options (a)+(b): + static tier or `@reflex_xy.figure` — because their validators need real + values and a synthetic-row probe would validate against made-up data; + the probe refuses them with an error naming both routes + (reflex-integration.md §3.6 "Kind coverage"). Plan format frozen: + `PLAN_VERSION = 1`, golden digest pinned in `test_plan.py`. +- **Phase 4** — as written (`probe="build"`/`"figure"`/`False`, async off + by default with explicit opt-in via `asyncio.run`); the + session-dependence downgrade uses a source-text heuristic + (`self.router`), documented in reflex-integration.md §3.1. + +**Post-landing correction (X4).** First real `reflex run` of a data-bound +app surfaced that fact X4 holds only where the frontend compile runs: +backend-only workers (the dev backend subprocess, prod workers) import the +app module but leave pages unevaluated, so their plan maps were empty and +every plan subscription answered `err {resync}` until the bounded retry +gave up. Fixed structurally, not by weakening the model: `setup(app)`'s +startup lifespan evaluates the app's unevaluated page component functions +once per worker (`app.py _ensure_page_plans`, pinned by +`tests/reflex_adapter/test_page_plan_registration.py`), making "the plan +map is populated in every worker" a guarantee of the integration instead +of an assumption about Reflex. Recorded in reflex-integration.md §3.6 and +the options doc §8. + +Deferred items remain deferred and tracked (keyed dataset collections for +`foreach`, per-mark `data=`, the core-xy metadata registry, both upstream +Reflex PR sites). diff --git a/spec/design/reflex-component-api-options.md b/spec/design/reflex-component-api-options.md new file mode 100644 index 00000000..76f21344 --- /dev/null +++ b/spec/design/reflex-component-api-options.md @@ -0,0 +1,905 @@ +# A component-shaped, compile-checked API for `reflex_xy` — options + +**Status: decided — Option 6 adopted (with Options 1 and 2 as its subsumed +parts), implemented.** See the decision record at the end of this document. +This document records the design space for that revision of the `reflex_xy` +public API. The shipped integration is specified in +[`reflex-integration.md`](reflex-integration.md) (the adopted tier: §3.6), +and the framework-agnostic composition contract in +[`reflex-shaped-api.md`](reflex-shaped-api.md). The file-level work plan +that guided the implementation lives in +[`reflex-component-api-implementation.md`](reflex-component-api-implementation.md). + +It is written to be self-contained: §1 gives a new engineer everything they +need about Reflex, xy, and the current integration; §2 states the problem; +§3 the constraints; §4 the verified framework facts the designs build on; +§5 the six candidate designs (§5.6, the synthesis, is the recommended one); +§6 the comparison; §7 the recommendation. + +--- + +## 1. Background: the three systems involved + +### 1.1 What xy is + +xy is a high-performance charting engine for Python (this repository). Three +layers matter here: + +- **The composition API** (`python/xy/components.py`) — the only public + chart-building surface. Users compose charts declaratively: + + ```python + import xy + chart = xy.scatter_chart( + xy.scatter(x, y, color=mag, colormap="viridis"), + xy.x_axis(label="σ"), + width="100%", height=460, + ) + ``` + + `xy.scatter(...)` returns a `Mark` — a plain dataclass holding whatever you + passed (arrays, lists, or **column-name strings** resolved later against a + `data=` table). `xy.scatter_chart(...)` returns a `Chart` — a lightweight + tree of these dataclass nodes. **Nothing heavy happens at construction**: + no copies, no shape checks, no rendering. + +- **The figure compiler** — `Chart.figure()` compiles the tree into the + internal `Figure` (canonical f64 columns, trace state, the wire spec). + **This is where almost all validation fires**: shape mismatches, bad + colormaps/enums, unresolvable column names, missing axis ids. The split is + bimodal and worth memorizing: + + | fails at construction (eager) | fails at `.figure()` (lazy) | + |---|---| + | unknown kwargs (`TypeError` — no `**kwargs` sinks anywhere) | data shape/length mismatches | + | chrome nodes: `x_axis`, `legend`, `theme`, … validate every field | mark config: `colormap=`, `symbol=`, `bins=`, `mode=`, … | + | unknown attribute on the lazy `xy` module (`AttributeError`) | column-name resolution against `data=` | + | chart-level `class_names` slot allowlist | axis-id references (`y_axis="y2"`) | + + A useful verified fact: `xy.scatter_chart(xy.scatter([], [])).figure()` + builds a valid empty figure — so **binding zero-row columns and calling + `.figure()` exercises the full validation gate without any real data**. + +- **The render client** (`js/src/`, bundled into `python/xy/static/`) — a + WebGL2 client that renders a binary payload (spec JSON + raw f32 buffers, + never JSON numbers) and talks to a Python-side kernel for drilldown, + picking, and selection. The same client serves notebooks, static HTML + export, and Reflex. + +### 1.2 What Reflex is + +[Reflex](https://reflex.dev) is a Python full-stack web framework. You write +Python; it compiles a React frontend and runs a Python backend. The concepts +this document leans on: + +- **State** — a class with typed fields; lives on the backend, synced to the + browser as JSON deltas over one websocket: + + ```python + class Dash(rx.State): + points: int = 200_000 # a base var + + @rx.var + def label(self) -> str: # a computed var: re-derived when deps change + return f"{self.points:,} points" + + @rx.event + def more(self): # an event handler: called from the browser + self.points *= 2 + ``` + +- **Components** — Python classes that compile to React/JSX. A page is a + Python function returning a component tree; props are typed class fields: + + ```python + def index() -> rx.Component: + return rx.vstack( + rx.heading(Dash.label), # a Var in the tree → reactive text + rx.button("more", on_click=Dash.more), # event prop → handler + ) + ``` + +- **Vars** — `Dash.label` accessed on the *class* is not a value; it is a + `Var` object: a typed reference (`_var_type=str`) that compiles to a JS + expression. Components accept Vars as props and children; that is what + makes the tree reactive. + +- **`rx.cond` / `rx.foreach`** — conditionals and loops over state, resolved + in the browser. Crucially, `rx.foreach(Dash.items, render_fn)` calls + `render_fn` **exactly once at compile** with a *placeholder* Var (typed + from the list annotation); the browser instantiates it per row. You cannot + branch in Python on the item's value inside `render_fn` — only `rx.cond`. + +- **The compile timeline.** "Compile" is when `reflex run`/`reflex export` + evaluates your app. What runs when (verified against reflex 0.9.6): + + | phase | what executes | what can fail here | + |---|---|---| + | **import** | module bodies; state classes are built | invalid state var types; anything at module scope | + | **compile** | every page function runs; every `Component.create()` runs; **the default state is instantiated and computed vars are evaluated** (unless `initial_value=` opts out) | prop type errors; event-handler arity/type errors; child whitelist errors; computed-var exceptions | + | **hydrate** | browser loads; real session state is created; deltas flow | everything else | + + Two facts from `Component._post_init` shape every design below: + 1. **Prop type checking happens only for props annotated `Var[T]`** — the + incoming Var's `_var_type` is checked structurally at `create()` (i.e. + at compile). A bare `T` annotation gets no check at all. + 2. **Unknown kwargs are silently absorbed into `style`** — a typo'd prop + becomes a CSS property, not an error. + + Custom Python types become legal state-var values and prop types by + registering an `@rx.serializer` (dataclasses work even without one). + +### 1.3 What reflex_xy is, and how it works today + +`python/reflex_xy/` (import name `reflex_xy`) is the bundled integration. +Its central problem: **chart data can be huge, and Reflex state sync is JSON +diffing** — putting a million rows in state (as e.g. recharts integrations +do) would be catastrophic. The design (full detail: +[`reflex-integration.md`](reflex-integration.md)) splits every chart into two +planes: + +- **Control plane** (Reflex-native): which figure a component shows, style + props, and small semantic events (`on_point_click`, `on_select_end`, …) — + ordinary Reflex state and event handlers. Never data buffers. +- **Data plane** (xy-native): binary payloads, drilldown round-trips, and + streaming appends on a second socket.io namespace (`/_xy`) **multiplexed + onto the app's existing websocket**. Reflex state never sees a data byte. + +The pieces, end to end: + +```python +class Dash(rx.State): + points: int = 200_000 + + @reflex_xy.figure # ← a computed var in disguise + def cloud(self) -> xy.Chart: + x, y = load(self.points) + return xy.scatter_chart(xy.scatter(x, y)) + +def index(): + return reflex_xy.chart(Dash.cloud, height="460px") # ← the wrapper call +``` + +1. **`@reflex_xy.figure`** wraps the method in a `FigureVar` (a Reflex + computed var). Its *value* is only a token string — + `xyv1|||` — and **evaluating the var + is what builds the chart**: the builder runs, the resulting `Figure` is + published into a **per-process registry** under the token, and the token + goes into state. Reflex's dependency tracking re-runs the builder when + state it reads changes; subscribers get a fresh payload pushed over the + data plane. The token is stable, so the DOM never re-renders — pixels + move, DOM doesn't. +2. **The registry** (`registry.py`) is deliberately process-local and *not* + a distributed store. A registry miss (worker restart, reconnect landing + on another node) is recovered by **re-running the builder against session + state** (`state_bridge.py`) — Reflex state is the durable source of + truth; every registered figure is a rebuildable cache. The token *is* the + rebuild recipe. +3. **`reflex_xy.chart(source)`** (`component.py`) is a factory that builds a + private `rx.Component` (`XYChart`). A token/Var source lands in the + `token` prop and rides the socket; an `xy.Chart` passed directly is + compiled to a static payload asset (`src` prop) rendered kernel-less — + works under `reflex export` with no backend. +4. At **hydrate**, the JSX wrapper subscribes (`sub {fig: token}`) on the + `/_xy` namespace; the backend serves the registered figure's binary + payload (or rebuilds it from state on a miss), and interaction messages + round-trip to the kernel. + +One accidental property matters enormously for this document: at compile, +Reflex evaluates computed vars against a default state — but `FigureVar`'s +getter first tries to mint a token from the session's `client_token`, finds +none (no session at compile), and returns `""` **without ever executing the +builder body**. That is why the current design does no data work at compile +(good) — and also why nothing in the builder body is ever checked at compile +(the problem). + +--- + +## 2. The problem + +Two complaints, one root cause (the chart-building code executes at hydrate, +not at compile, and the component seam is untyped): + +### Problem 1 — it doesn't feel like a Reflex component + +Every other Reflex component composes in the tree, takes typed props, and +works inside `rx.cond`/`rx.foreach` with a uniform mental model. +`reflex_xy.chart(State.cloud)` is a special wrapper call around a state var — +a different mental model, and the component class itself is private (built +lazily, no importable type, props not part of the public surface). + +### Problem 2 — no compile-time validation + +If code references a chart API that doesn't exist — say an LLM agent +hallucinates `xy.polar_scatter(...)` inside a builder — **nothing fails at +compile**. The builder body is dead code until a browser session hydrates and +the var evaluates; only then does the `AttributeError` fire, surfacing as an +`err` frame on the data plane and an empty mount. Wrong kwargs, invalid +colormaps, bad axis references: all the same. For a human iterating with +`reflex run` this is slow feedback; for an agent loop (write → compile → +check) it is invisible — the compile is green and the page is silently blank. + +Additionally, the component seam itself is unchecked: passing the wrong var +(`Dash.points`) or a garbage string as the chart source is accepted at +compile, because the `token` prop is `Var[str]` and unknown kwargs become CSS. + +--- + +## 3. Constraints (what must not regress) + +1. **State stays lean.** Chart data must never be serialized into Reflex + state. The registry + token indirection is the whole point of the + integration — every design keeps figures (and their columns) in the + per-process registry, with only a small token/handle in state. +2. **No data ingestion at compile.** Reflex evaluates computed vars at + compile against default state; today the figure var deliberately + short-circuits (returns `""`) so no chart is built just to render a dead + placeholder. Designs that execute anything at compile must bound what + runs (structure-only, zero-row, or explicit opt-in/out). +3. **Rebuildability.** Any worker must be able to recover a figure from + session state alone (the multi-worker/reconnect story). Whatever a design + puts between state and figure must remain a deterministic recipe. +4. **`reflex-shaped-api.md` boundaries.** xy must not grow a parallel + reactive DSL (no xy-owned `field()`/`condition()`); Reflex owns Vars, + conditionals, events, layout. The core `python/xy` package never imports + Reflex. + +--- + +## 4. What the frameworks give us to build on + +Facts verified against reflex 0.9.6 and the current xy codebase, load-bearing +for the designs: + +- **R1.** Prop type checks fire at `create()` (= page evaluation = compile), + but only for `Var[T]`-annotated props; the Var's `_var_type` is compared + structurally. This is how `rx.plotly(data=State.fig)` gets a typed seam. +- **R2.** `@rx.serializer` (or being a dataclass) makes a custom type legal + as a state-var value and usable as `Var[MyType]`. +- **R3.** Computed vars execute at compile against default state unless + `initial_value=` is set. `FigureVar` currently dodges this via the missing + client token — the builder body never runs before hydrate. +- **R4.** `rx.foreach` builds its child once with a placeholder Var typed + from the list annotation; an untyped iterable is a hard compile error. + `rx.cond` builds (and therefore validates) both branches eagerly. + `rx.ComponentState` is explicitly unsupported inside `foreach`. +- **R5.** Custom compile-time validation belongs in a `create()` override + (the pattern reflex's own recharts wrapper uses); there is no post-init + validation hook. +- **R6.** A bare Var as a *child* (`rx.card(State.cloud)`) always renders as + a text node; Reflex has no Var-type→component coercion hook in child + normalization. "No call at all" is not implementable from outside the + framework. +- **X1.** The xy tree is cheap to build without data; string channels are + late-bound column refs; unknown kwargs `TypeError` at call; chrome nodes + validate eagerly; marks validate at `.figure()`. +- **X2.** Zero-row columns compile: binding empty placeholder columns for + every named channel and calling `.figure()` runs the full mark/config + validation gate in milliseconds, with no real data. +- **X3.** `Chart.figure()` memoizes and is never invalidated — rebinding + data means constructing a fresh `Chart`, never mutating one. +- **X4.** Prod backend workers re-evaluate page bodies (the static payload + tier already relies on this) — anything registered during page evaluation + exists in every worker. + +Three more facts were verified empirically for Option 6 — on **both** the +released reflex 0.9.6.post1 and the framework's development checkout +(0.9.7.post41.dev0, `~/code/reflex`), with identical results (a probe +script exercising each; pin these as adapter tests before building on +them): + +- **R7.** A computed var may return a **parametrized generic dataclass** — + `DataHandle[CloudData]` — and the full alias survives as the Var's + `_var_type`: `get_args(...)` recovers the `TypedDict`, `get_type_hints` + on it yields the column names. This holds for base vars too + (`list[DataHandle[CloudData]]`), **and the element Var inside + `rx.foreach` keeps the parametrized type** — so schema-aware compile + checks work per-item inside loops. A prop annotated `Var[DataHandle]` + accepts the parametrized var and rejects an `int` var or a raw string + with a compile-time `TypeError`. +- **R8.** An unknown `on_*` kwarg **already fails at compile** with a + framework `ValueError` listing valid triggers (so "did you mean + `on_select_end`" only needs a better message, not new machinery). An + unknown *non-event* kwarg (`colormapp=`) is still silently absorbed into + `style` — factories must partition kwargs themselves to catch config + typos. +- **R9.** Attribute access on a Var whose `_var_type` is a *parametrized* + generic (`Dash.cloud.token`) currently raises `VarAttributeError` — + reflex's attribute-type resolver does not unwrap generic origins. Root + cause located in the reflex monorepo: `get_attribute_access_type` + (`packages/reflex-base/src/reflex_base/utils/types.py`, the function at + ~:436) ends with an `isinstance(cls, type)` bare-class branch; a + parametrized alias is not a `type`, falls through, and returns `None`. + The fix is a `get_origin()` unwrap (ideally with TypeVar substitution + from `get_args`) before that branch. Typed column *references* + (`x=Dash.cloud.x`) therefore need that upstream fix and are correctly + deferred; nothing in Option 6's v1 reads attributes off the handle Var. + +The design space then reduces to two questions: **when does the +chart-building expression execute** (import / compile / hydrate), and **how +much of the chart's identity is a typed Var** at the component seam? + +--- + +## 5. The options + +### Option 1 — Typed figure handle + first-class component (the plotly pattern) + +Make the chart component a real, exported, prop-typed `rx.Component`, and +make the figure var's value a typed handle instead of a bare `str`. + +```python +class Dash(rx.State): + points: int = 200_000 + + @reflex_xy.figure + def cloud(self) -> xy.Chart: + x, y, mag = load(self.points) + return xy.scatter_chart(xy.scatter(x, y, color=mag)) + +def index(): + return rx.card( + reflex_xy.chart( # a real component, like rx.plotly + figure=Dash.cloud, # Var[FigureHandle] — typed prop + on_select_end=Dash.on_select, + height="460px", + ), + ) +``` + +**Compile-time validation.** `FigureVar` gets +`return_type=FigureHandle` — a frozen dataclass `{token: str}` with a +serializer (R2). The component declares `figure: rx.Var[FigureHandle]` +(R1), so at compile: + +- `reflex_xy.chart(figure=Dash.points)` → `TypeError: Invalid var passed for + prop XYChart.figure, expected FigureHandle, got int`. +- A raw string → compile `TypeError`; `register()`/`inline()` return + `FigureHandle` too, so legitimate paths keep working. +- A `create()` override (R5) rejects semantic-event props on static sources + and malformed options with real errors, instead of the silent + absorb-into-style behavior. + +**Not checked:** the builder body. `xy.polar_scatter` inside `cloud()` still +fails at hydrate. This option fixes the component feel and the seam, not the +builder — pair with Option 2. + +**Plumbing.** Unchanged. The handle serializes to `{"token": "xyv1|..."}` in +deltas; the wrapper reads `.token`. Registry, namespace, rebuild: untouched. + +**cond/foreach.** `rx.cond` works as today. `rx.foreach(Dash.handles, +lambda h: reflex_xy.chart(figure=h))` works and is now type-safe: with +`handles: list[FigureHandle]` the item Var is typed (R4). + +**Migration.** Non-breaking. The positional `chart(source)` form stays as a +deprecated shim; strings accepted one release via `Var[FigureHandle | str]`, +then tightened (the union weakens the check — keep it temporary). + +**Variant 1b (upstream).** The literal "pass `State.cloud` bare into any +slot" needs a Var-type→component coercion hook in Reflex's child +normalization (R6). Both repos live under reflex-dev, so a small framework +hook is proposable — but it is a framework feature, not something the +adapter can fake, and should not gate anything here. + +--- + +### Option 2 — Compile-time builder probe (a gate, not an API) + +Make the builder body execute once at compile. Orthogonal to every other +option. + +```python +@reflex_xy.figure # probed at compile by default +def cloud(self) -> xy.Chart: ... + +@reflex_xy.figure(probe=False) # opt out: builder needs a live session +def heavy(self) -> xy.Chart: ... +``` + +**Mechanics.** `XYPlugin`'s compile hook walks state classes, finds +`FigureVar`s, constructs a default state instance (Reflex already does this +for `initialState`), and calls the builder directly — bypassing the +token-minting short-circuit that currently keeps it dead (R3). Three levels: + +- `probe="build"` (default): run the body only. Catches hallucinated `xy.*` + names (`AttributeError`), wrong kwargs (`TypeError`), eager chrome-node + errors (X1). Cost = the builder's own cost against *default* state. +- `probe="figure"`: additionally call `.figure()` — full config/shape + validation, at the price of compiling one real figure per var at compile. +- `probe=False`: today's behavior. The default for `async def` builders + (compile is sync; awaiting a DB at compile is what constraint 2 forbids). + +Errors re-raise wrapped with state class, var name, and source location, so +the failure reads like a native Reflex compile error. Builders that touch +`self.router` (session-dependent) degrade to a warning, not a compile +failure. + +**The tension, stated plainly:** the probe runs user code that may generate +`self.points = 200_000` rows at compile. That is bounded by *default* state, +happens once per compile, and mirrors the cost Reflex already accepts for +ordinary computed vars — but it is a behavior change, hence the escape +hatch and the session-access downgrade. + +**Plumbing / cond / foreach / migration:** no changes anywhere; additive. +The only decision with teeth is defaulting `probe="build"` on. + +--- + +### Option 3 — Chart components: structure in the tree, data by reference + +*Kept for the record; subsumed by Option 6, which realizes this grammar as +its Level 2 with marks as plain xy nodes instead of components.* + +The maximal option, anticipated by [`reflex-shaped-api.md`](reflex-shaped-api.md) +§6 ("a thin codegen layer... each factory maps 1:1 to a component"). Split +the chart into **structure** (built and validated at page evaluation, as real +Reflex components) and **data** (a state-backed source resolved at hydrate +through the existing registry). + +```python +class Dash(rx.State): + points: int = 200_000 + + @reflex_xy.data # columns only — no chart API to hallucinate + def cloud(self) -> dict[str, Any]: + rng = np.random.default_rng(7) + xs = rng.normal(size=self.points) + return {"x": xs, "y": xs * 0.6 + rng.normal(scale=0.6, size=self.points), + "mag": abs(xs)} + +def index(): + return reflex_xy.scatter_chart( # real components, mirroring xy 1:1 + reflex_xy.scatter(x="x", y="y", color="mag", colormap="viridis"), + reflex_xy.x_axis(label="σ"), + data=Dash.cloud, # Var[DataHandle] — the only reactive input + on_select_end=Dash.on_select, + height="460px", + ) +``` + +**Compile-time validation — the strongest of any option, all of it existing +xy validation moved to page-evaluation time:** + +- `reflex_xy.polar_scatter(...)` → `AttributeError` at import of the page + module. Hallucinations die where they are written. +- Wrong kwargs → `TypeError` from the real `xy.scatter(...)` factory, which + each component factory calls underneath to build the actual dataclass tree + (string channels, no data — X1). +- Bad enums/colormaps/axis refs → the factory binds zero-row placeholder + columns for every named channel and calls `.figure()` once (X2): the full + mark validation gate runs at compile, in milliseconds, with no data. +- **Cannot be checked without data:** whether the named columns exist in the + runtime table, and real shapes/dtypes. Those surface at first publish with + a spec-aware error ("mark `scatter` binds column `'mag'`; data var + `Dash.cloud` produced columns {x, y}") — a far better hydrate failure than + today's, because the spec is known. + +**Plumbing.** The factory serializes the validated tree (dataclass nodes → +canonical data-free JSON) and content-addresses it: `spec_digest`. Because +prod workers re-evaluate page bodies (X4), every worker's spec registry is +populated at startup by the same evaluation. The token becomes +`xysp1||||`. On `sub`: look up the +spec by digest, run the data var against session state (the existing +`state_bridge` machinery, fetching columns instead of a Chart), bind columns +into a fresh Chart (X3), `figure()`, register under the token. Data lives in +the registry exactly as today; state holds only the small handle. The +rebuild recipe = spec (deterministic from source, present in every worker) + +data var (state) — strictly *more* recoverable than an opaque builder. + +**cond/foreach.** These *are* components: composition is native, and eager +`rx.cond` branches mean both structures get validated. In `rx.foreach`, the +structure is fixed per render function (built once with the placeholder Var +— R4) and the `data` prop is the typed item Var. Structure *varying per +item* is impossible — but that is Reflex's universal foreach contract, not a +chart limitation; `rx.cond`/`rx.match` inside the render fn covers discrete +cases. + +**Side benefits.** Live charts get automatic Tailwind class discovery +(class strings are structure, not data — today live sources need the manual +`tailwind_classes=` inventory). Later, scalar config props could accept Vars +compiled into a small spec-patch channel (Reflex-owned reactivity, no data +in state) without changing the transport. + +**Migration.** Additive: `@reflex_xy.figure` + the Option-1 component remain +as the "full-Python tier" for charts whose *structure* genuinely depends on +state (dynamic mark counts, data-driven annotations). Cost is real: a +generated factory layer (~90 factories mirroring `components.py`), a spec +serialization format that must track the grammar, and a second token family +in the namespace. + +--- + +### Option 4 — `ChartState`: a ComponentState fusion + +A chart as a self-contained, instantiable component with its own state — the +Reflex-native answer to "a chart is a thing I drop into a page". + +```python +class Cloud(reflex_xy.ChartState): + points: int = 200_000 + + def build(self) -> xy.Chart: + x, y = make(self.points) + return xy.scatter_chart(xy.scatter(x, y)) + + @rx.event + def more(self): + self.points *= 2 + +def index(): + return rx.vstack( + Cloud.create(height="460px"), + Cloud.create(height="200px"), # an independent second instance + rx.button("more", on_click=Cloud.more), + ) +``` + +**Mechanics.** `ChartState` subclasses `rx.ComponentState`; `get_component` +wires the Option-1 typed component to a figure var auto-derived from +`build`. Each `.create()` mints a fresh state class (`Cloud_n1`, `Cloud_n2`) +at compile — Reflex's own per-instance mechanism — so the token +(`xyv1|client|Cloud_n1|cloud`) resolves through the existing rebuild path +(minted classes are registered for pickling; resolution needs a test, not +new machinery). Uniquely, this gives **per-mount isolation**: today all +mounts of one figure var share kernel drill state. + +**Compile-time validation.** Class creation checks `build`'s signature and +return annotation; the body is still deferred — pair with Option 2's probe. +Prop checks come from the underlying Option-1 component. + +**cond/foreach.** `rx.cond`: fine. `rx.foreach`: **hard-blocked by Reflex** +(R4) — the framework cannot mint N state classes for a runtime-length list. +That hole disqualifies this as the primary API; it is sugar for the +dashboard-widget case. + +**Migration.** Pure sugar over Options 1+2; nothing breaks. + +--- + +### Option 5 — Eager spec on the state class (structure at import, data by method) + +*Kept for the record; subsumed by Option 6, which keeps this option's +structure/data split but moves the declaration into the page tree and hides +the spec object entirely.* + +Option 3's structure/data split, but keeping the declaration on the state +class — and moving execution from hydrate to **import**. + +```python +class Dash(rx.State): + points: int = 200_000 + + cloud = reflex_xy.figure_spec( + xy.scatter_chart( # ← real xy call, executes AT IMPORT + xy.scatter(x="x", y="y", color="mag"), + width="100%", + ) + ) + + @cloud.data # columns only, resolved at hydrate + def _cloud_data(self) -> dict[str, Any]: + ... + +def index(): + return reflex_xy.chart(figure=Dash.cloud, height="460px") # Option-1 component +``` + +**Compile-time validation.** The spec expression is module-level Python: +`xy.polar_scatter` → `AttributeError` **at import**, before Reflex even +starts compiling — the earliest failure any option achieves, and the +friendliest to agent loops (`python -c "import app"` catches it). +`figure_spec` runs the zero-row `.figure()` probe (X2) at construction, so +config errors are import errors too. The uncheckable remainder matches +Option 3: column existence and shapes in real data, which fail at publish +with spec-aware messages. + +**Plumbing.** `figure_spec` is a descriptor that installs a `FigureVar` +whose builder is *derived*: run the data method, bind the columns into a +fresh copy of the spec tree (fresh `Chart`, never mutation — X3), +`figure()`, publish under the standard `xyv1|...` token. No new token +family, no spec-distribution question — the spec is a module-level object +present in every worker by definition. Registry, namespace, rebuild: +byte-identical to today. + +**cond/foreach.** Identical to Option 1 (the tree side *is* the Option-1 +component). No new composition power — the trade against Option 3. + +**Migration.** Additive. `@reflex_xy.figure` keeps covering +structure-from-state charts; `figure_spec` becomes the recommended default +for the majority case where only data is reactive. + +--- + +### Option 6 — Data-bound chart components (the synthesis; recommended) + +Review feedback on Options 3 and 5 converged on a sharper shape: the chart +should be declared **exactly once, where it is rendered**, with state +supplying only reactive data. No figure method for the common case, no +spec/template object in user code, no manual handle. Two user-visible +levels, plus the existing builder as escape hatch. + +**Level 1 — flat, single-mark (the common case):** + +```python +from typing import TypedDict +import numpy as np +import reflex as rx +import reflex_xy as rxy + +class CloudData(TypedDict): + x: np.ndarray + y: np.ndarray + mag: np.ndarray + +class Dash(rx.State): + points: int = 200_000 + + @rxy.data # columns only — no chart API to hallucinate + def cloud(self) -> CloudData: + rng = np.random.default_rng(7) + x = rng.normal(size=self.points) + return {"x": x, "y": x * 0.6 + rng.normal(scale=0.6, size=self.points), + "mag": np.abs(x)} + +def index() -> rx.Component: + return rxy.scatter_chart( + data=Dash.cloud, + x="x", y="y", color="mag", colormap="viridis", + x_axis=rxy.x_axis(label="σ"), + height="460px", + on_select_end=Dash.select, + ) +``` + +**Level 2 — composed, multi-mark:** + +```python +def index() -> rx.Component: + return rxy.chart( + rxy.scatter(x="x", y="y", color="mag", colormap="viridis"), + rxy.line(x="x", y="trend", width=2), + rxy.x_axis(label="Time"), + rxy.y_axis(label="Value"), + data=Dash.cloud, + height="460px", + ) +``` + +**Level 3 — escape hatch (unchanged, structure genuinely from state):** + +```python +class Dash(rx.State): + @rxy.figure + def dynamic(self) -> xy.Chart: + return xy.scatter_chart(...) if self.mode == "scatter" else xy.line_chart(...) + +def index(): + return rxy.chart(figure=Dash.dynamic) # Option 1's typed component +``` + +**`@rxy.data` and the handle.** The decorator produces a `DataVar` — a +computed var in the exact mold of today's `FigureVar` — whose *value* is a +tiny `DataHandle` (`{"token": "xyd1||Dash|cloud"}`; frozen +dataclass with a one-line `@rx.serializer` → dict for the delta path). +Evaluating the var runs the data method and publishes the **columns** into +the per-process registry under the token; dependency tracking points at the +method body, so a state change republishes columns and pushes fresh +payloads to every chart bound to that data. Like `FigureVar`, the getter +short-circuits before a session exists — **user data code never executes at +compile** (constraint 2 holds by the same mechanism that holds today). + +The generic annotation is the schema channel (R7, verified): the class-level +`Dash.cloud` Var carries `_var_type = DataHandle[CloudData]`; the factory +recovers `CloudData` via `get_args` and its column names via +`get_type_hints` — *without executing anything*. A data method annotated +plain `dict[str, np.ndarray]` degrades gracefully: no compile-time column +check, validated on first execution with a spec-aware error. + +**Compile-time validation timeline** (each mechanism labeled): + +| failure | fires at | mechanism | +|---|---|---| +| `rxy.polar_scatter_chart(...)` | **import** | explicit export surface (`AttributeError`) | +| unknown mark/chart kwarg, near-miss typo (`colormapp=`, `on_selection_end=`) | **compile** | factory kwarg partition + difflib suggestion (R8: events already error; others need the partition) | +| `colormap="virids"`, bad enums, bad axis refs | **compile** | build the real xy tree, bind zero-row placeholder columns, `.figure()` once (X1/X2) | +| `x="timestamp"` against a `TypedDict`-annotated data var | **compile** | schema from `_var_type` (R7): *Unknown column "timestamp" for Dash.cloud. Available: x, y, mag* | +| `data=Dash.points` / `data="raw string"` | **compile** | `Var[DataHandle]` prop check (R1/R7, verified) | +| column existence (untyped data), lengths, shapes, dtypes | **hydrate/publish** | inherent — requires real data; error names the spec's bindings vs the produced columns | + +**Plumbing.** The factory compiles kwargs → real xy tree → validated, +data-free plan → canonical JSON → `spec_digest`, registered in a +process-local spec registry during page evaluation (every worker evaluates +pages — X4). The component carries two props: `spec` (literal digest, baked +into the JSX at compile) and `data` (the handle Var, resolved from state at +runtime). On `sub {spec, data_token}`: look up the plan by digest, resolve +columns (registry hit, or rebuild via the state bridge running the data +method against session state), bind columns into a fresh `Chart` (X3), +`figure()`, cache per `(spec_digest, data_token)`, serve binary payload. +Rebuild recipe = plan (deterministic from source, present in every worker) + +data method (state) — the same two-plane, registry-as-cache architecture as +today, with the figure split into its two independently-cacheable halves. +Users never see `ChartPlan`, digests, or the spec registry. + +**cond/foreach.** Both levels return ordinary components: `rx.cond` between +two charts works (and eagerly validates both). `rx.foreach(Dash.handles, +lambda h: rxy.scatter_chart(data=h, x="x", y="y"))` type-checks per item — +the element Var keeps `DataHandle[CloudData]` (R7), so even column names +are compile-checked inside the loop. + +**Design decisions the review forced, recorded:** + +1. **Marks are xy nodes, not Reflex components.** Reflex child validation + rejects arbitrary dataclasses (`ChildrenTypeError`), so `rxy.chart(...)` + is a *factory* that consumes mark/chrome nodes before any component is + created — they never enter the Reflex tree. `rxy.scatter` can literally + re-export `xy.scatter` (zero duplication; hallucinated names still die + at import). Consequence: `rx.cond` cannot switch marks *inside* one + chart — the spec is server-compiled, so conditional structure is + `rx.cond` between two chart calls, or Level 3. Same boundary as + Option 3, now explicit. +2. **The flat form needs a kwarg partition rule.** `scatter` is clean, but + the collision set is real and small: `width` (chart size vs `line` + stroke), `opacity`, `style`, `class_name`, `key`, `animation` exist at + both mark and component level. Rule: component/chart level wins; + colliding mark options get flat-form aliases (`stroke_width=` — hence + the Level-2 example) or use the composed form. The partition table is + part of the public contract and must be generated, not hand-listed. +3. **Generate the flat layer from signatures, not a new registry.** xy's + own convention — positional params are data channels, keyword-only are + options, uniformly across every mark — means `inspect.signature` + mechanically yields the flat form's accepted kwargs, and the zero-row + probe reuses the real validators in `marks.py` (no parallel enum + tables to drift). A full `ScatterDefinition`-style metadata registry in + core xy (also generating stubs and docs) remains attractive, but it is + a separate core investment, not a prerequisite — this defuses the + "~90 handwritten parallel factories" objection to Option 3 without it. +4. **Typed column references (`x=Dash.cloud.x`) are deferred.** Verified + broken today (R9: attribute access on a parametrized-generic Var raises + `VarAttributeError`); needs an upstream reflex fix. Strings + TypedDict + give nearly the same validation with none of the machinery. + +**Open questions (tracked, not hand-waved):** + +- **Dynamic dataset collections.** `foreach` over charts works when a + `list[DataHandle[...]]` exists in state — but handles are minted by + computed vars (one var = one dataset). A runtime-length *collection* of + datasets needs either a keyed-dataset extension (a data var returning a + dict of tables, charts binding `(handle, key)`) or N declared vars. + Design before implementing; do not ship an accidental contract. +- **Per-mark `data=`** (two marks, two sources, one chart): the xy grammar + allows it; v1 binds one chart-level data var. Extend the spec format + only when a real use case lands. +- **Static tier symmetry**: `data=` accepting concrete arrays (not a Var) + could route to the existing static payload asset tier, mirroring + `chart(xy.Chart)` today. Cheap, optional. + +**Migration.** Additive: today's `chart(source)` and `@rxy.figure` keep +working (Level 3 *is* the current model behind Option 1's typed seam). +Options 3 and 5 are subsumed — 3's grammar survives as Level 2 with marks +as re-exports instead of components; 5's structure/data split survives as +the plan/data-var split, minus the user-visible spec object. + +--- + +## 6. Comparison + +| | 1 · Typed handle | 2 · Compile probe | 3 · Chart components | 4 · ChartState | 5 · Eager spec | 6 · Data-bound | +|---|---|---|---|---|---|---| +| Feels like a Reflex component | ✅ (plotly-style) | — (no API change) | ✅✅ (is the tree) | ✅✅ per-instance | ✅ (via 1) | ✅✅ (declared where rendered) | +| Hallucinated chart API fails at | hydrate ✗ | **compile** | **import/compile** | hydrate (compile w/ 2) | **import** | **import/compile** | +| Wrong kwargs / bad config fails at | hydrate ✗ | compile | **compile** (zero-row probe) | via 2 | **import** (zero-row probe) | **compile** (partition + zero-row probe) | +| Wrong var into chart slot | **compile** ✅ | — | **compile** ✅ | **compile** ✅ | **compile** ✅ | **compile** ✅ (verified) | +| Column names | hydrate | via `probe="figure"` | hydrate, spec-aware error | hydrate | hydrate, spec-aware error | **compile** (TypedDict, R7) | +| Data shapes / lengths / dtypes | hydrate (inherent) | via `probe="figure"` | hydrate | hydrate | hydrate | hydrate (inherent) | +| Constraint 1 (no data in state) | ✅ unchanged | ✅ unchanged | ✅ (data registry) | ✅ unchanged | ✅ unchanged | ✅ (data registry) | +| Constraint 2 (no compile ingestion) | ✅ | ⚠️ builder runs on default state (opt-out) | ✅ zero-row only | ✅/⚠️ with 2 | ✅ zero-row only | ✅ zero-row only | +| `rx.cond` | ✅ | — | ✅ | ✅ | ✅ | ✅ (between charts) | +| `rx.foreach` | ✅ typed item | — | ✅ (fixed structure per item) | ❌ framework-blocked | ✅ typed item | ✅ schema-checked per item (R7) | +| Structure reactive to state | ✅ (builder) | ✅ | ❌ (data/cond only) | ✅ | ❌ (data only) | ❌ (Level 3 escape hatch) | +| Breaking changes | none (deprecations) | none | none (additive tier) | none | none | none (additive; old forms become Level 3) | +| Implementation cost | small | small | **large** (factory layer + spec format + token family) | medium | medium | medium-large (signature-derived flat layer + plan format + data vars) | + +*"Compile" = page evaluation during `reflex run`/`export`; "import" = plain +`import app`, even earlier.* + +## 7. Recommendation + +**Build Option 6 as the primary API. It subsumes the others**: Option 1's +typed component seam becomes Level 3's rendering path, Option 5's +structure/data split becomes the internal plan/data-var split (minus the +user-visible spec object), and Option 3's grammar becomes Level 2 with +marks as plain xy re-exports instead of components. Option 2's probe +remains valuable for exactly one tier — the Level 3 `@rxy.figure` escape +hatch, the only place left where chart-building user code is deferred to +hydrate. Option 4 stays on the shelf as later sugar for self-contained +dashboard widgets. + +Phasing that keeps every step shippable: + +1. **Seam first** (Option 1 mechanics): typed `DataHandle`/`FigureHandle`, + the public component, compile-time prop rejection. Small, non-breaking, + immediately kills the wrong-var-in-the-slot class. +2. **`@rxy.data` + Level 1 flat factories** for the top few chart kinds + (scatter, line, histogram, bar), generated from `inspect.signature`, + with the kwarg partition, zero-row probe, and TypedDict column checks. + This is the DX headline and proves the plan/data split end to end. +3. **Level 2 composed `rxy.chart(...)`** + the remaining chart kinds + + spec-format stabilization. +4. **Probe for Level 3** (Option 2), so the escape hatch fails at compile + too. +5. Revisit: keyed dataset collections for `foreach`, per-mark `data=`, + the core-xy metadata registry (stubs/docs), upstream reflex fixes. + Both upstream sites are located in the reflex monorepo: generic + attribute access (R9) in `get_attribute_access_type` + (`packages/reflex-base/src/reflex_base/utils/types.py` ~:436 — add a + `get_origin()` unwrap before the bare-class branch), and Var-type + child coercion (R6) in `Component.create`'s child normalization + (`packages/reflex-base/src/reflex_base/components/component.py` + ~:1314, where a Var child currently becomes + `Bare.create(contents=...)` — a `_var_type` → component-factory + registry consulted just before that fallback would make + `rx.card(State.cloud)` render a chart). + +**The "pass it directly in the component tree" instinct** is Option 6 +literally: the chart is declared once, in the tree, and the state var +passes directly into its `data=` slot — the `rx.plotly(data=State.fig)` +shape generalized. The only unimplementable reading remains the bare +`rx.card(State.cloud)` with no call at all (R6); the upstream coercion +hook is worth proposing but gates nothing. + +**Implementation cautions.** Option 6 rests on verified-but-unpinned +behavior in two systems: reflex carrying parametrized generics through +vars, foreach element inference, and `Var[T]` prop rejection (R7); xy +compiling zero-row figures and validating marks only at `.figure()` +(X1/X2). Pin all of it as adapter tests *before* building, so a reflex or +grammar upgrade fails loudly. `DataHandle` needs its one-line serializer +registered. The flat-form kwarg partition table is public contract: +generate it and test it. When any option ships, +[`reflex-integration.md`](reflex-integration.md) §5 must record the new +API tiers and the validation-timing table, per the spec-first rule. + +--- + +## 8. Decision record (2026-08) + +**Adopted: Option 6 — data-bound chart components — as the primary API**, +with Option 1's typed seam as the rendering path of every tier and +Option 2's compile probe for the Level 3 escape hatch. Options 3 and 5 are +subsumed as anticipated in §7; Option 4 stays on the shelf. + +Implementation notes recorded against the plan (deviations are deliberate +and small): + +- The `@reflex_xy.data` module is **`data_vars.py`**, not `data.py`: a + `reflex_xy.data` submodule shadows the `reflex_xy.data` function export + the moment any sibling imports it (Python submodule-attribute collision). + The public name is unchanged. +- The flat-form collision aliases are generated as `mark_`, with one + natural special case: a mark `width` becomes `stroke_width` when the mark + hasn't already claimed that name (so `line`'s width is `stroke_width=`, + `bar`'s is `mark_width=` — `bar` natively owns `stroke_width`). The table + is pinned in `tests/reflex_adapter/test_factories.py`. +- `x_axis=`/`y_axis=` in the flat form are type-dispatched: an Axis node is + chrome, a string is the mark's axis-id option; `legend=` takes a Legend + node or bool, `theme=` a Theme node. Other chrome composes via + `reflex_xy.chart(*nodes, ...)`. +- The positional `chart(source)` shim warns only for live sources + (vars/handles/token strings). The positional **static** Chart/Figure form + is not deprecated — it remains the only route for arbitrary Charts (e.g. + facet grids) and predates no replacement. +- The client bounds consecutive server-initiated `err {resync}` retries + (5 per mount without an intervening payload), so a permanently stale + plan digest or failing bind degrades to a visible console error, not a + subscribe loop. +- Kernel-only event props on a static source now fail `create()` with a + `ValueError` (previously silent no-ops), completing the §5.6 error + catalog; every row of that catalog is reproduced by the adapter tests at + the stated phase. +- **Fact X4 needed narrowing.** "Page bodies run in every worker" is true + of workers that run the frontend compile, but backend-only workers (dev + backend subprocess, prod workers) import the app without evaluating + pages — verified live when the first data-bound app served nothing but + plan-miss errs from a dev backend. The adapter now evaluates the app's + unevaluated pages in its startup lifespan, making the plan-distribution + property a guarantee of the integration rather than an observed Reflex + behavior. diff --git a/tests/reflex_adapter/test_framework_contracts.py b/tests/reflex_adapter/test_framework_contracts.py new file mode 100644 index 00000000..4458a986 --- /dev/null +++ b/tests/reflex_adapter/test_framework_contracts.py @@ -0,0 +1,107 @@ +"""Pins for the Reflex framework facts the component API design rests on. + +Facts R1/R7/R8 from spec/design/reflex-component-api-options.md §4, verified +against reflex 0.9.6-0.9.8. These tests use a local ``Handle`` type (not the +shipped ``reflex_xy`` handles) on purpose: they pin the *framework* contract +itself, so a Reflex upgrade that moves the ground fails here first, named +after the design fact that broke — independent of any adapter code. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Generic, TypedDict, TypeVar, get_args, get_type_hints + +import pytest +import reflex as rx + +S = TypeVar("S") + + +@dataclass(frozen=True) +class ContractHandle(Generic[S]): + """Stand-in for reflex_xy's handle types: frozen, generic, tiny.""" + + token: str = "" + + +@rx.serializer +def _serialize_contract_handle(handle: ContractHandle) -> dict: + return {"token": handle.token} + + +class ContractSchema(TypedDict): + x: list[float] + y: list[float] + mag: list[float] + + +class ContractState(rx.State): + points: int = 5 + handles: list[ContractHandle[ContractSchema]] = [] + + @rx.var + def cloud(self) -> ContractHandle[ContractSchema]: + return ContractHandle("tok") + + +class ContractComponent(rx.Component): + """Minimal component with one ``Var[T]``-typed prop (the R1 seam).""" + + tag = "ContractComponent" + figure: rx.Var[ContractHandle] + + +def test_computed_var_preserves_parametrized_generic_alias(): + """R7: the class-level Var of a computed var returning a parametrized + generic frozen dataclass carries the full alias; ``get_args`` recovers + the TypedDict and ``get_type_hints`` its column names — the schema + channel, readable without executing anything.""" + var_type = ContractState.cloud._var_type + args = get_args(var_type) + assert args == (ContractSchema,) + assert set(get_type_hints(args[0])) == {"x", "y", "mag"} + + +def test_list_base_var_and_loop_element_keep_the_alias(): + """R7 (foreach half): a base var annotated ``list[Handle[Schema]]`` + keeps the alias, and so do the element Vars produced by indexing and by + ``rx.foreach`` — schema-aware checks work per item inside loops.""" + assert get_args(ContractState.handles._var_type) == (ContractHandle[ContractSchema],) + assert ContractState.handles[0]._var_type == ContractHandle[ContractSchema] + + element_types: list[object] = [] + + def render(item: rx.Var) -> rx.Component: + element_types.append(item._var_type) + return rx.text("item") + + rx.foreach(ContractState.handles, render).render() + assert element_types == [ContractHandle[ContractSchema]] + + +def test_var_typed_prop_checks_fire_at_create(): + """R1: a prop annotated ``rx.Var[Handle]`` accepts the parametrized var + and rejects an int var and a raw string with ``TypeError`` at + ``create()`` — i.e. at page evaluation, which is compile time.""" + ContractComponent.create(figure=ContractState.cloud) + + with pytest.raises(TypeError, match="figure"): + ContractComponent.create(figure=ContractState.points) + with pytest.raises(TypeError, match="figure"): + ContractComponent.create(figure="raw-token-string") + + +def test_unknown_event_kwarg_fails_but_unknown_prop_becomes_style(): + """R8: an unknown ``on_*`` kwarg already raises ``ValueError`` at + ``create()`` (framework machinery, only the message needs help). An + unknown *non-event* kwarg is silently absorbed into ``style`` — this + half documents the hazard the flat factories' kwarg partition exists to + compensate for. If Reflex ever starts rejecting these, the partition + layer gets simpler; this test failing on the second clause is that + signal.""" + with pytest.raises(ValueError, match="on_select_typo"): + ContractComponent.create(on_select_typo=rx.console_log("x")) + + absorbed = ContractComponent.create(colormapp="viridis") + assert "colormapp" in absorbed.style diff --git a/tests/test_validation_timing.py b/tests/test_validation_timing.py new file mode 100644 index 00000000..d3c4f2e6 --- /dev/null +++ b/tests/test_validation_timing.py @@ -0,0 +1,99 @@ +"""Pins for the composition API's validation timing (facts X1-X3). + +The Reflex component API (spec/design/reflex-component-api-options.md §4, +implementation plan in reflex-component-api-implementation.md) compiles +chart *plans* by binding zero-row placeholder columns and calling +``.figure()`` once at page evaluation. That only works while: + +- X1: the tree is cheap to build without data; chrome nodes validate + eagerly; mark config validates at ``.figure()``. +- X2: zero-row columns compile — the probe exercises the full validation + gate with no real data. +- X3: ``Chart.figure()`` memoizes and is never invalidated — rebinding data + means a fresh ``Chart``, never mutating one. + +A grammar change that moves any of these fails here first, named after the +fact it broke. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +import xy + +EMPTY = np.empty(0, dtype=np.float64) + +# Every mark kind the flat/composed Reflex factories cover with the zero-row +# probe (Phase 2 kinds first, then the Phase 3 signature-derived set). Kinds +# whose validators require at least one finite value (stairs, ecdf, box, +# violin, hexbin, heatmap, contour) are excluded from the plan/zero-row model +# by the Phase 3 decision recorded in the implementation plan. +ZERO_ROW_CHARTS = { + "scatter": lambda: xy.scatter_chart(xy.scatter(EMPTY, EMPTY)), + "line": lambda: xy.line_chart(xy.line(EMPTY, EMPTY)), + "histogram": lambda: xy.histogram_chart(xy.histogram(EMPTY)), + "bar": lambda: xy.bar_chart(xy.bar(EMPTY, EMPTY)), + "area": lambda: xy.area_chart(xy.area(EMPTY, EMPTY)), + "step": lambda: xy.step_chart(xy.step(EMPTY, EMPTY)), + "stem": lambda: xy.stem_chart(xy.stem(EMPTY, EMPTY)), + "column": lambda: xy.column_chart(xy.column(EMPTY, EMPTY)), + "errorbar": lambda: xy.errorbar_chart(xy.errorbar(EMPTY, EMPTY, yerr=EMPTY)), + "error_band": lambda: xy.error_band_chart(xy.error_band(EMPTY, EMPTY, upper=EMPTY)), + "segments": lambda: xy.segments_chart(xy.segments(EMPTY, EMPTY, x1=EMPTY, y1=EMPTY)), +} + + +@pytest.mark.parametrize("kind", sorted(ZERO_ROW_CHARTS)) +def test_zero_row_construction_compiles(kind): + """X2: binding empty columns and calling .figure() runs the full mark + validation gate without any real data.""" + figure = ZERO_ROW_CHARTS[kind]().figure() + assert figure is not None + + +def test_zero_row_columns_resolve_through_chart_data(): + """X2, the form the plan tier uses: string channels resolved against a + chart-level table of zero-row columns.""" + data = {"x": EMPTY, "y": EMPTY, "mag": EMPTY} + figure = xy.scatter_chart(xy.scatter("x", "y", color="mag"), data=data).figure() + assert figure is not None + + +def test_mark_config_validates_at_figure_not_construction(): + """X1: mark factories are lazy about config — a bad colormap constructs + fine and fails only at .figure().""" + mark = xy.scatter([1.0], [1.0], colormap="bogus") # constructs + with pytest.raises(ValueError, match="colormap"): + xy.scatter_chart(mark).figure() + + +def test_unknown_mark_kwarg_fails_at_construction(): + """X1: no ``**kwargs`` sinks anywhere — a typo'd mark option is an + immediate TypeError, before any figure exists.""" + with pytest.raises(TypeError): + xy.scatter([1.0], [1.0], colormapp="viridis") + + +def test_chrome_nodes_validate_eagerly(): + """X1: chrome constructors validate every field at call time.""" + with pytest.raises(ValueError, match="type_"): + xy.x_axis(type_="bogus") + + +def test_figure_memoizes_and_rebinding_needs_a_fresh_chart(): + """X3: .figure() is built once and cached; swapping the data table on an + existing Chart does not rebuild — a fresh Chart does.""" + mark = xy.scatter("x", "y") + chart = xy.scatter_chart(mark, data={"x": [1.0], "y": [2.0]}) + figure = chart.figure() + assert chart.figure() is figure + + chart.data = {"x": [1.0, 3.0], "y": [2.0, 4.0]} + assert chart.figure() is figure # memoized: rebind must not rely on mutation + assert chart.figure().traces[0].n_points == 1 + + rebound = xy.scatter_chart(mark, data={"x": [1.0, 3.0], "y": [2.0, 4.0]}) + assert rebound.figure() is not figure + assert rebound.figure().traces[0].n_points == 2