From b476f17f863f2519c5d357fb831068a69b609d58 Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Tue, 1 Sep 2026 15:14:00 -0600 Subject: [PATCH 01/22] docs: capture design decisions --- .../react-fine-grained-reactivity.md | 67 +++ .../react-for-vue-developers.md | 155 +++++++ .../react-getting-started.md | 339 ++++++++++++++++ docs/adding-support-for-react/react-refs.md | 200 +++++++++ .../react-scoped-slots.md | 250 ++++++++++++ .../react-text-interpolation.md | 162 ++++++++ .../vue-for-react-developers.md | 193 +++++++++ .../vue-vs-react-with-trame.md | 383 ++++++++++++++++++ docs/adding-support-for-react/vue-vs-react.md | 146 +++++++ 9 files changed, 1895 insertions(+) create mode 100644 docs/adding-support-for-react/react-fine-grained-reactivity.md create mode 100644 docs/adding-support-for-react/react-for-vue-developers.md create mode 100644 docs/adding-support-for-react/react-getting-started.md create mode 100644 docs/adding-support-for-react/react-refs.md create mode 100644 docs/adding-support-for-react/react-scoped-slots.md create mode 100644 docs/adding-support-for-react/react-text-interpolation.md create mode 100644 docs/adding-support-for-react/vue-for-react-developers.md create mode 100644 docs/adding-support-for-react/vue-vs-react-with-trame.md create mode 100644 docs/adding-support-for-react/vue-vs-react.md diff --git a/docs/adding-support-for-react/react-fine-grained-reactivity.md b/docs/adding-support-for-react/react-fine-grained-reactivity.md new file mode 100644 index 0000000..945a8ec --- /dev/null +++ b/docs/adding-support-for-react/react-fine-grained-reactivity.md @@ -0,0 +1,67 @@ +# Fine-Grained Reactivity for trame's React `client_type` + +> **Status: design exploration, just getting started.** Follow-on from +> [`vue-vs-react-with-trame.md`](./vue-vs-react-with-trame.md) (section 6, item 7) +> and [`react-scoped-slots.md`](./react-scoped-slots.md) (section 6, "Freshly-minted +> functions on every render"). Nothing here is decided yet — this is a working +> comparison to reason from, not a conclusion. + +## 1. The problem + +Vue's `client_type` gets fine-grained reactivity for free. `TrameTemplate.js` +wraps every trame state key in its own `customRef` (`toRef()`), so a binding +like `{{ count }}` only triggers a re-render of the exact spot in the template +that reads `count` — nothing else re-evaluates when `count` changes. + +The React JSON-tree design (`vue-vs-react-with-trame.md`, section 2) has no such +guarantee by construction: `TrameNode` walks a plain JSON tree and calls +`state.get(key)` wherever a `{ "js": ... }` leaf needs a value. Unless something +scopes *which* component re-renders on *which* state key changing, any state +update risks re-rendering the whole tree — and the scoped-slots design compounds +this, since `resolveProp` mints a fresh render-prop closure on every render +unless the underlying state primitive itself is fine-grained enough to avoid +re-invoking components that don't need it. + +So the real question isn't just "what store do we use" — it's "what store lets +a generic, dynamically-shaped `TrameNode` component subscribe to *exactly* the +state keys its own subtree binds, with no upfront knowledge of what those keys +are, since the tree comes from Python and can be anything." + +## 2. Candidate libraries + +| Library | Model | Fine-grained by default? | Notes | +| --- | --- | --- | --- | +| **Redux** (+ Redux Toolkit) | Single store, reducers/actions, selectors | No — needs manual selector + memoization (`reselect`) discipline | Still common in large/legacy codebases; heavier boilerplate than the alternatives below; no longer the default choice for new projects | +| **Zustand** | Single store (or several), plain functions to read/set | Opt-in via selectors — `useStore(s => s.count)` re-renders only if the selected value changes | The closest thing to "the mainstream default" today; very low boilerplate; fine-graininess is something *you* write per usage, not automatic | +| **Jotai** | Atomic — one `atom` per piece of state | Yes, per-atom, by construction | `useAtom(atom)` subscribes to exactly that atom; maps naturally onto trame state since it's already a flat dict of named keys — one atom per key | +| **Valtio** | Proxy-based — mutate a plain object directly, subscribe via `useSnapshot` | Yes, per-key-actually-read, by construction | Structurally the closest analog to Vue's own reactivity system (which is also proxy-based) — the same mental model trame's Vue path already exploits | +| **Recoil** | Atomic, same idea as Jotai | Yes, per-atom | Meta-authored; largely superseded by Jotai in new adoption; mentioned for completeness | + +## 3. Why this matters more than usual here + +In a normal React app, you know your component tree at build time, so you can +hand-write `useAtom(countAtom)` or `useStore(s => s.count)` exactly where +needed. trame's tree is **dynamic and server-driven** — `TrameNode` doesn't +know in advance which state keys a given JSON subtree will reference; it finds +out by walking `{ "js": "..." }` leaves at render time. Whatever store trame +picks has to support **subscribing to a key computed at runtime**, not just a +key known at author time. That's a real constraint: Zustand's typical selector +usage assumes you write `s => s.count` in source code; Jotai's `atom` family +pattern (`atomFamily(key => ...)`) is built for exactly this "atom per +dynamically-named key" case; Valtio's proxy just reacts to whatever properties +were actually read on a given render, so runtime-computed key names fall out +for free. + +## 4. Open questions to work through next + +- Does `atomFamily` (Jotai) or a Valtio proxy keyed by state name end up + simpler to wire into the existing `trame.state.get/set` wire protocol + (`_event_value_processing` in `widgets/core.py`, and the `toRef()` pattern in + `TrameTemplate.js`) that both `client_type`s share underneath? +- How does whichever store is picked interact with `react.For`'s per-iteration + local scope and `react.Slot`'s widget-supplied scope (`react-scoped-slots.md`) + — those aren't global state keys at all, so they sit outside whatever + store manages the `trame.state` proxy. +- What does list re-rendering look like under `react.For` for a large array — + does the chosen store help avoid re-rendering every row when only one row's + backing data changes, or is that a separate problem the store doesn't solve? diff --git a/docs/adding-support-for-react/react-for-vue-developers.md b/docs/adding-support-for-react/react-for-vue-developers.md new file mode 100644 index 0000000..a44b8c5 --- /dev/null +++ b/docs/adding-support-for-react/react-for-vue-developers.md @@ -0,0 +1,155 @@ +# React for trame Developers Coming from Vue + +> **Status: design exploration — nothing here is implemented yet.** This is a +> practical, syntax-first companion for people who already build trame apps +> with `client_type="vue3"` and want the direct translation to +> `client_type="react"`. If you have no Vue background, use +> [`react-getting-started.md`](./react-getting-started.md) instead — it +> teaches the React side on its own terms, with no Vue references at all. For +> the reverse mapping (React → Vue), see +> [`vue-for-react-developers.md`](./vue-for-react-developers.md) — note that +> one describes real, already-shipped behavior, since `client_type="vue3"` +> exists today and `client_type="react"` here does not. For the design +> rationale behind *why* each translation looks the way it does, see +> [`vue-vs-react-with-trame.md`](./vue-vs-react-with-trame.md). + +## Dynamic text + +Vue's `{{ }}` mustache syntax becomes its own item in the children list: + +```python +# Vue +html.Div("count = {{ count }}") + +# React +html.Div(["count = ", react.Bind("count")]) +``` + +`html.Div` takes one `children` argument, not variadic `*args` — multiple +children (literal text and bindings both) go in a single list/tuple. + +📖 [`react-text-interpolation.md`](./react-text-interpolation.md) + +## Bound props and two-way binding + +Vue's `v-model` (and its modifiers) is a single directive that implies both a +controlled value and a change handler. React has no equivalent directive, so +it splits into two explicit pieces: + +```python +# Vue +html.Input(type="range", min=0, max=10, step=1, v_model_number=("count", 2)) + +# React +html.Input( + type="range", min=0, max=10, step=1, + value=react.Bind("count", count=2), + onChange=react.Callback("count = Number(e.target.value)"), +) +``` + +📖 [`vue-vs-react-with-trame.md`](./vue-vs-react-with-trame.md), section 4 + +## Events and triggers + +```python +# Vue +html.Button("Reset", click=self.reset) +html.Button("Reset to 4", click=(self.reset, "[4]", "{}")) +html.Input(v_on_dblclick_prevent="count = 2 * count") + +# React +html.Button("Reset", onClick=react.Callback(self.reset)) +html.Button("Reset to 4", onClick=react.Callback(self.reset, "[4]", "{}")) +html.Input( + onDoubleClick=react.Callback("count = 2 * count", modifiers=["prevent"]) +) +``` + +Vue's `v_on__=` naming convention becomes an explicit +`modifiers=[...]` list on `react.Callback`. + +📖 [`vue-vs-react-with-trame.md`](./vue-vs-react-with-trame.md), section 3 + +## Conditional rendering + +Vue's `v_if=` is a per-element attribute; React expresses conditionals +structurally, as a wrapping block: + +```python +# Vue +html.Div("Count is high", v_if="count > 5") + +# React +with react.If(value="count > 5"): + html.Div("Count is high") +``` + +📖 [`vue-vs-react-with-trame.md`](./vue-vs-react-with-trame.md), section 4 + +## List rendering + +```python +# Vue +with html.Ul(): + html.Li("{{ item.name }}", v_for="item in items", key="item.id") + +# React +with html.Ul(): + with react.For(items="items", name="item"): + html.Li(react.Bind("item.name"), key=react.Bind("item.id")) +``` + +Vue's implicit list diffing still requires a `key=`, same as React — the +difference is `v_for=` folds the loop into the element's own attributes, +while `react.For` wraps the templated child as its own block. + +📖 [`vue-vs-react-with-trame.md`](./vue-vs-react-with-trame.md), section 4 + +## Scoped slots + +```python +# Vue +with VDataTable(items=("items", data)): + with Template(v_slot_item_name="{ item }"): + html.Strong("{{ item.name }}") + +# React +with react.Slot(params=["item"]) as render_item_name: + html.Strong([react.Bind("item.name")]) + +VDataTable(items=react.Bind("items"), renderItemName=render_item_name) +``` + +Vue's `v_slot_="{ destructure }"` string becomes an explicit +`react.Slot(params=[...])` block, defined outside the widget's own `with` +block and passed in as an ordinary prop. + +📖 [`react-scoped-slots.md`](./react-scoped-slots.md) + +## Refs and imperative calls + +No translation needed — this part of the API is identical for both +`client_type`s: + +```python +html.Input(ref="my_input") +self.server.js_call("my_input", "focus") +``` + +📖 [`react-refs.md`](./react-refs.md) + +## Cheat sheet + +| Vue | React | +| --- | --- | +| `"{{ count }}"` in children | `react.Bind("count")` as its own child in a list | +| `v_if="expr"` | `with react.If(value="expr"):` | +| `v_for="item in items"` | `with react.For(items="items", name="item"):` | +| `v_model_number="count"` | `value=react.Bind("count")`, `onChange=react.Callback("count = Number(e.target.value)")` | +| `@click="trigger('fn')"` / `click=self.fn` | `onClick=react.Callback(self.fn)` | +| `click=(self.fn, "[4]", "{}")` | `onClick=react.Callback(self.fn, "[4]", "{}")` | +| `v_on_dblclick_prevent="expr"` | `onDoubleClick=react.Callback("expr", modifiers=["prevent"])` | +| `Template(v_slot_item_name="{ item }")` | `with react.Slot(params=["item"]) as x: ...` passed as `some_prop=x` | +| `ref="name"` | `ref="name"` (unchanged) | +| `self.server.js_call("name", "method", *args)` | unchanged | diff --git a/docs/adding-support-for-react/react-getting-started.md b/docs/adding-support-for-react/react-getting-started.md new file mode 100644 index 0000000..5a4225d --- /dev/null +++ b/docs/adding-support-for-react/react-getting-started.md @@ -0,0 +1,339 @@ +# Getting Started with trame + React (Python Guide) + +> **Status: design exploration — nothing in this guide is implemented yet.** +> `client_type="react"` does not exist in `trame-client` today. This document +> writes up the *intended* Python-facing API as if it already existed, so it +> reads like a normal getting-started guide rather than a design discussion. +> The reasoning behind each piece — and the open questions still unresolved — +> lives in the companion docs, linked at the bottom of each section. +> +> The helper classes shown here (`Bind`, `Callback`, `If`, `For`, `Slot`) are +> proposed to live in `trame.widgets.react`, mirroring how `trame.widgets.html` +> holds the framework-agnostic HTML elements — imported and used the same way, +> as a namespace: `from trame.widgets import react`, then `react.Bind(...)`. +> That module doesn't exist yet either — the import lines below are the +> proposed location, not a confirmed one. + +## All imports used in this guide + +```python +from trame.app import TrameApp +from trame.decorators import change, trigger, controller, life_cycle +from trame.ui.html import DivLayout +from trame.widgets import html, react +``` + +Every example below uses some subset of these. Nothing else is needed for the +use cases covered here. + +## 1. Minimal app + +```python +from trame.app import TrameApp +from trame.ui.html import DivLayout +from trame.widgets import html + + +class MyApp(TrameApp): + def __init__(self, server=None): + super().__init__(server, client_type="react") + self._build_ui() + + def _build_ui(self): + with DivLayout(self.server) as self.ui: + html.Div("Hello, trame + React") + + +if __name__ == "__main__": + app = MyApp() + app.server.start() +``` + +A `TrameApp` subclass with `client_type="react"` and a layout is all it takes. +`self.state`, `self.ctrl`, `DivLayout`, and `html.*` all work exactly the way +you'd expect from any other trame app. + +## 2. Static content and static props + +Plain strings and plain attribute values need nothing special — they're +static data: + +```python +html.Div("This text never changes") +html.Input(type="range", min=0, max=10, step=1) +html.Button("Click me", classes="my-button") +``` + +## 3. Showing a value that changes over time + +To display a value that updates as state changes, add a `react.Bind(...)` +entry directly in the children list, right next to whatever literal text +surrounds it: + +```python +html.Div(["count = ", react.Bind("count", count=2)]) +``` + +Multiple children (literal text and bindings both) always go in a single +list/tuple argument — `html.Div` takes one `children` argument, not variadic +`*args`: + +```python +html.Div([ + "Reset count to ", react.Bind("default_value", default_value=1), + " then double it to ", react.Bind("double_default", double_default=2), +]) +``` + +`react.Bind`'s first argument is a JavaScript expression string, evaluated +against the current state. A plain state key name (`"count"`) is itself a +trivially valid expression, so no special-casing is needed for the common +case. + +📖 Deeper dive: [`react-text-interpolation.md`](./react-text-interpolation.md) + +## 4. Binding a dynamic value to a prop + +The same `react.Bind` also works as a prop value. Its signature is: + +```python +react.Bind(js_expression, **state_defaults) +``` + +- **The first, positional argument is a JavaScript expression string**, + evaluated client-side against the current scope (trame state, plus any + `react.For`/`react.Slot` local scope in effect). A plain state key name + (`"count"`) is the trivial case of a valid expression; it doesn't have to + be just a bare key. +- **Every keyword argument sets a default on trame's shared state**, one + `state.setdefault(key, value)` call per kwarg — not a single "default value + for the bound key" slot. + +The common case binds and defaults the same single key: + +```python +html.Input( + type="range", min=0, max=10, step=1, + value=react.Bind("count", count=2), # expression: "count", default: state.count = 2 +) +``` + +But because the kwargs are independent of the expression, an expression that +combines several state keys can default all of them in one call: + +```python +# expression: "count + offset" — defaults both count and offset, +# neither of which needs to already exist in state +html.Div(["total = ", react.Bind("count + offset", count=2, offset=1)]) +``` + +Kwarg names don't have to match anything in the expression either — you could +default a key elsewhere in state that this particular binding doesn't +reference, though in practice keeping kwarg names aligned with the keys the +expression actually reads is the common, readable pattern. + +## 5. Handling events and calling Python + +`react.Callback` wraps either a raw JS expression or a Python callable: + +```python +# Raw JS expression, evaluated client-side +html.Input(onChange=react.Callback("count = Number(e.target.value)")) + +# Python callable, invoked server-side +html.Button("Reset", onClick=react.Callback(self.reset)) + +# Python callable with extra positional/keyword arguments +# (JS expression strings, evaluated client-side and sent along with the call) +html.Button("Reset to 4", onClick=react.Callback(self.reset, "[4]", "{}")) + +# Event modifiers - run e.preventDefault(), e.stopPropagation(), etc. +# before the callback fires +html.Input( + onDoubleClick=react.Callback( + "count = 2 * count", + modifiers=["prevent"], + ) +) +``` + +```python +def reset(self, value=2): + self.state.count = value +``` + +📖 Deeper dive: [`vue-vs-react-with-trame.md`](./vue-vs-react-with-trame.md), +section 3 + +## 6. Building a controlled input (two-way binding) + +To create an input whose value both reflects and updates state, pair +`react.Bind` (for `value=`) with `react.Callback` (for `onChange=`): + +```python +html.Input( + type="range", min=0, max=10, step=1, + value=react.Bind("count", count=2), + onChange=react.Callback("count = Number(e.target.value)"), +) +``` + +## 7. Rendering content conditionally + +To render content only when a condition holds, wrap it in `react.If`: + +```python +with react.If(value="count > 5"): + html.Div("Count is high") +``` + +📖 Deeper dive: [`vue-vs-react-with-trame.md`](./vue-vs-react-with-trame.md), +section 4 + +## 8. Rendering a list of items + +`name=` introduces the loop variable, referenced in children the same way any +other bound value is — via `react.Bind`: + +```python +with html.Ul(): + with react.For( + items=react.Bind("items", items=["Apple", "Banana", "Cherry"]), + name="item", + ): + html.Li( + react.Bind("item.name"), + key=react.Bind("item.id"), + ) +``` + +**Don't forget `key=`.** Each list item needs a stable, unique key so the +renderer can track which piece of content corresponds to which piece of data +across updates. `key` is a plain shared attribute, bound the same way as any +other prop. + +📖 Deeper dive: [`vue-vs-react-with-trame.md`](./vue-vs-react-with-trame.md), +section 4 + +## 9. Letting a widget hand data back into content you provide + +Some widgets need to hand data back into the content you give them — for +example, a data table that lets you control exactly how each row's cells look, +while it decides which row is currently being rendered. Define that content +with `react.Slot` and pass the resulting handle as an ordinary prop — not +nested inside the widget's own `with` block: + +```python +with react.Slot(params=["item"]) as render_item_name: + html.Strong([react.Bind("item.name")]) + +VDataTable( + items=react.Bind("items"), + renderItemName=render_item_name, +) +``` + +The widget calls `renderItemName(item)` once per row; you never need to know +or care how that invocation happens from the Python side. + +📖 Deeper dive: [`react-scoped-slots.md`](./react-scoped-slots.md) + +## 10. Referencing an element or component to call its methods + +`ref=` lets you attach a name to an element or component so you can call +methods on it later: + +```python +html.Input(ref="my_input") +html.Canvas(ref="my_chart") +``` + +Calling a method on a ref uses `server.js_call(ref, method, *args)` — an +existing, already-implemented API (`trame_server.core.Server.js_call`), used +today by widgets like `JSEval` and `ClientTriggers`: + +```python +def focus_input(self): + self.server.js_call("my_input", "focus") + +def reset_zoom(self): + self.server.js_call("my_chart", "resetZoom") +``` + +For a plain DOM element (`html.Input`), the method called is whatever the +native DOM API provides (`focus()`, `scrollIntoView()`, ...). For a custom +widget, it's whatever that widget's author chose to expose — trame doesn't +standardize or restrict this. + +📖 Deeper dive: [`react-refs.md`](./react-refs.md) + +## 11. Full worked example + +Putting it together — a counter with a derived value, a reset button, and a +todo list: + +```python +from trame.app import TrameApp +from trame.decorators import change +from trame.ui.html import DivLayout +from trame.widgets import html, react + + +class TodoApp(TrameApp): + def __init__(self, server=None): + super().__init__(server, client_type="react") + self.state.todos = ["Write docs", "Review PR", "Ship it"] + self._build_ui() + + @change("count") + def update_count(self, count, **_): + self.state.double = 2 * int(count) + + def reset(self, value=2): + self.state.count = value + + def _build_ui(self): + with DivLayout(self.server) as self.ui: + html.Div(["count = ", react.Bind("count", count=2)]) + html.Div(["2 x count = ", react.Bind("double", double=4)]) + html.Input( + type="range", min=0, max=10, step=1, + value=react.Bind("count", count=2), + onChange=react.Callback("count = Number(e.target.value)"), + ) + html.Button("Reset", onClick=react.Callback(self.reset)) + + with react.If(value="todos.length > 0"): + with html.Ul(): + with react.For(items="todos", name="todo"): + html.Li([react.Bind("todo")], key=react.Bind("todo")) + + with react.If(value="todos.length === 0"): + html.Div("Nothing left to do!") + + +if __name__ == "__main__": + app = TodoApp() + app.server.start() +``` + +## 12. Where to read more + +- [`react-scoped-slots.md`](./react-scoped-slots.md) — scoped slots / render + props, in depth +- [`react-refs.md`](./react-refs.md) — refs, imperative handles, `TrameNode` + impact +- [`react-text-interpolation.md`](./react-text-interpolation.md) — the + reasoning behind `react.Bind` in children +- [`react-fine-grained-reactivity.md`](./react-fine-grained-reactivity.md) — + the state-management library landscape and why it matters for a + dynamically-shaped tree +- [`vue-vs-react-with-trame.md`](./vue-vs-react-with-trame.md) — the overall + design rationale, and what's still missing for full parity (notably: the + widget ecosystem, deliberately out of scope for now) +- Coming from trame's Vue-based `client_type` and want a direct syntax + comparison? See + [`react-for-vue-developers.md`](./react-for-vue-developers.md) +- Coming from React and want to pick up trame's existing (real, + already-shipped) Vue-based `client_type`? See + [`vue-for-react-developers.md`](./vue-for-react-developers.md) diff --git a/docs/adding-support-for-react/react-refs.md b/docs/adding-support-for-react/react-refs.md new file mode 100644 index 0000000..72f3464 --- /dev/null +++ b/docs/adding-support-for-react/react-refs.md @@ -0,0 +1,200 @@ +# Refs and Imperative Method Calls in trame's React `client_type` + +> **Status: design exploration, just getting started.** Follow-on from +> [`vue-vs-react-with-trame.md`](./vue-vs-react-with-trame.md), section 6, item 4 +> ("Refs"). Nothing here is decided yet. + +## 1. Can you call methods on a React component? + +Two different cases, with very different answers: + +- **Plain DOM elements** (``, ``, ...) — yes, + trivially. `ref.current` is the actual DOM node, so the full native element + API is available with no extra work (`.focus()`, `.scrollIntoView()`, + `.value`, ...). +- **Custom components** — no, not by default. Function components have no + instance at all; `` yields `null` (and a dev warning) + unless the component explicitly opts in. + +## 2. The recommended pattern: `useImperativeHandle` + +A component that wants to expose a callable API declares exactly what +`ref.current` will be: + +```jsx +function MyChart({ ref, ...props }) { + const canvasRef = useRef(null); + + useImperativeHandle(ref, () => ({ + resetZoom() { + /* ... */ + }, + exportPNG() { + return canvasRef.current.toDataURL(); + }, + })); + + return ; +} +``` + +```jsx +// parent +const chartRef = useRef(null); +; +chartRef.current.resetZoom(); +``` + +Notes: + +- Pre-React 19, the component had to be wrapped in + `forwardRef((props, ref) => ...)` to even receive `ref` as an argument. + React 19 allows function components to accept `ref` as an ordinary prop + directly (as above); `forwardRef` still works but is being phased out. +- React's own guidance is that the exposed handle should be **deliberately + minimal and intentional** — a curated set of methods, not "everything the + component happens to have internally." This is closer to Vue 3's + ` + + +``` + +### React (function component + hooks) + +```jsx +import { useState, useMemo } from 'react'; + +// --- props: plain function arguments (destructured object) --- +// --- events: passed in as a callback prop, e.g. onCountChanged --- +function Counter({ items, onCountChanged }) { + const [count, setCount] = useState(0); + const [showDetails, setShowDetails] = useState(false); + const [filter, setFilter] = useState(''); + + function increment() { + const next = count + 1; + setCount(next); + onCountChanged?.(next); // calling the callback prop == emitting an event + } + + const filteredItems = useMemo( + () => items.filter((item) => item.includes(filter)), + [items, filter] + ); + + return ( + <> + {/* v-model equivalent: controlled input (value + onChange) */} + setFilter(e.target.value)} + placeholder="Filter items..." + /> + + + + {/* v-if equivalent: JS conditional (&&, ternary, or early return) */} + {showDetails &&

Details are visible

} + + + {/* v-for equivalent: Array.prototype.map with a key prop */} +
    + {filteredItems.map((item) => ( +
  • {item}
  • + ))} +
+ + ); +} + +// Parent usage — passing a prop and listening to the "event" + console.log('count is', n)} /> +``` + +### Key takeaways from the example + +| Concept | Vue | React | +| --- | --- | --- | +| Props | `defineProps` | Function arguments (destructured object) | +| Events | `defineEmits` + `emit('name', payload)` | A callback prop (e.g. `onCountChanged`) passed down and called directly | +| `v-if` | Directive on the element | Plain JS: `&&`, ternary, or early `return null` | +| `v-for` | Directive with `:key` | `array.map()` returning JSX, with a `key` prop | +| `v-model` | Directive providing automatic two-way binding | Manual "controlled component": `value` + `onChange` | +| State updates | Mutate `ref`/`reactive` directly | Must call the setter function (`setCount`), never mutate directly | + +The overarching pattern: Vue directives are declarative shorthand baked into the template compiler, while React expresses the same ideas as ordinary JavaScript expressions inside JSX — nothing "magic," but more boilerplate for things like two-way binding. From 1a3868b80c7f11d5ec7fd37d92392569a10fe9b0 Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Tue, 1 Sep 2026 15:44:55 -0600 Subject: [PATCH 02/22] test: improve ci and test coverage --- .github/workflows/test_and_release.yml | 53 +--- noxfile.py | 66 ++++ pyproject.toml | 1 + tests/test_template_gotchas.py | 419 +++++++++++++++++++++++++ 4 files changed, 497 insertions(+), 42 deletions(-) create mode 100644 noxfile.py create mode 100644 tests/test_template_gotchas.py diff --git a/.github/workflows/test_and_release.yml b/.github/workflows/test_and_release.yml index 838c8cc..ca1208e 100644 --- a/.github/workflows/test_and_release.yml +++ b/.github/workflows/test_and_release.yml @@ -11,24 +11,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - - uses: actions/setup-python@v7 - with: - python-version: "3.12" - - # Install and run pre-commit - - run: | - pip install pre-commit - pre-commit install - pre-commit run --all-files + - uses: astral-sh/setup-uv@v6 + - run: uvx nox -s pre_commit pytest: - name: Pytest ${{ matrix.config.name }} + name: Pytest ${{ matrix.config.name }} - Python ${{ matrix.python-version }} runs-on: ${{ matrix.config.os }} strategy: fail-fast: false matrix: - python-version: ["3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] config: - { name: "Linux", os: ubuntu-latest } # - { @@ -45,41 +37,18 @@ jobs: shell: bash steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v7 - with: - python-version: ${{ matrix.python-version }} - + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v6 - name: Set Up Node uses: actions/setup-node@v7 with: node-version: 24 - - - name: Build Vue2 App - run: | - cd vue2-app - npm ci - npm run build - - - name: Build Vue3 App - run: | - cd vue3-app - npm ci - npm run build - - - name: Install and Run Tests + - name: Run Tests (via nox) run: | - pip install .[test] - # Install requirements for playwright - playwright install - # Run the tests with coverage so we get a coverage report too - pip install coverage - coverage run --source . -m pytest -s . - # Print the coverage report - coverage report -m + # noxfile.py builds the Vue2/Vue3 client bundles, installs the + # package with test extras, installs Playwright, and runs pytest + # under coverage for this specific Python version. + uvx nox -s "tests-${{ matrix.python-version }}" - name: Upload Coverage to Codecov uses: codecov/codecov-action@v7 diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..a8df2b6 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,66 @@ +""" +Nox automation for trame-client. + +Usage: + uvx nox # run every default session (tests + pre-commit) + uvx nox -s tests # run the test suite across all supported Pythons + uvx nox -s "tests-3.12" # run the test suite against a single version + uvx nox -s pre_commit # run pre-commit hooks against the whole repo + +Sessions use the `uv` backend (https://docs.astral.sh/uv/), so `nox` doesn't +need pre-installed interpreters for every Python version - uv fetches +whichever one a session asks for. +""" + +import shutil +from pathlib import Path + +import nox + +nox.options.default_venv_backend = "uv" +nox.options.sessions = ["tests", "pre_commit"] + +PYTHON_VERSIONS = ["3.10", "3.11", "3.12", "3.13", "3.14"] + +# The Python package serves these prebuilt JS bundles (see pyproject.toml's +# [tool.hatch.build] include list); the browser-driven tests need them built +# once before pytest can exercise them. +VUE_APPS = { + "vue2-app": Path("src/trame_client/module/vue2-www"), + "vue3-app": Path("src/trame_client/module/vue3-www"), +} + + +def _ensure_vue_apps_built(session): + if shutil.which("npm") is None: + session.warn( + "npm not found - skipping Vue2/Vue3 client build. " + "Browser-driven tests will fail without it." + ) + return + + for app_dir, output_dir in VUE_APPS.items(): + if output_dir.exists(): + session.log(f"{output_dir} already built, skipping `{app_dir}` build") + continue + with session.chdir(app_dir): + session.run("npm", "ci", external=True) + session.run("npm", "run", "build", external=True) + + +@nox.session(python=PYTHON_VERSIONS) +def tests(session): + """Run the test suite against a given Python version, installed via uv.""" + _ensure_vue_apps_built(session) + + session.install("-e", ".[test]", "coverage") + session.run("playwright", "install") + session.run("coverage", "run", "--source", ".", "-m", "pytest", "-s", ".") + session.run("coverage", "report", "-m") + + +@nox.session(python="3.12") +def pre_commit(session): + """Run every pre-commit hook against the full codebase.""" + session.install("-e", ".[dev]") + session.run("pre-commit", "run", "--all-files") diff --git a/pyproject.toml b/pyproject.toml index fe73985..ea2b096 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ test = [ dev = [ "pre-commit", "ruff", + "nox", ] [build-system] diff --git a/tests/test_template_gotchas.py b/tests/test_template_gotchas.py new file mode 100644 index 0000000..0898b5e --- /dev/null +++ b/tests/test_template_gotchas.py @@ -0,0 +1,419 @@ +""" +Locks down `AbstractElement`/`Template` serialization behavior that isn't +covered elsewhere, with a focus on behavior that either: + +- must stay identical across `client_type`s (vue2/vue3 today; a future + "react" client_type should be checked against these the same way), or +- is a deliberate, known per-`client_type` divergence (currently just the + `ref=` handling for vue3). + +This file exists to give a future `client_type="react"` a clear baseline to +be validated against, and to catch accidental regressions in the string-based +template generation used by the Vue paths today. +""" + +import pytest + +from trame.app import get_server +from trame_client.utils.defaults import TrameDefault +from trame_client.widgets.html import ( + Div, + Input, + Li, + Ul, + Component, + Transition, + TransitionGroup, + KeepAlive, + Teleport, + Suspense, + Template, +) + +CLIENT_TYPES = ["vue2", "vue3"] + + +# ----------------------------------------------------------------------------- +# ref= is the one attribute that is genuinely client_type-specific today +# ----------------------------------------------------------------------------- + + +@pytest.mark.parametrize("client_type", CLIENT_TYPES) +def test_ref_handling_per_client_type(client_type): + server = get_server(f"test_ref_{client_type}", client_type=client_type) + widget = Div(trame_server=server, ref="my_ref") + + if client_type == "vue3": + # vue3 rewrites ref= into a callback that registers the element + # into trame.refs, so JS-side method calls (server.js_call) work. + assert widget.html == ("
trame.refs['my_ref'] = el\" />") + elif client_type == "vue2": + # vue2 has no equivalent rewrite - ref stays a plain Vue template ref. + assert widget.html == '
' + else: + assert False, "Invalid client type" + + +# ----------------------------------------------------------------------------- +# Attribute value type handling - identical regardless of client_type +# ----------------------------------------------------------------------------- + + +@pytest.mark.parametrize("client_type", CLIENT_TYPES) +def test_boolean_attribute_serialization(client_type): + server = get_server(f"test_bool_{client_type}", client_type=client_type) + + # True -> bare attribute, no value + assert Div(trame_server=server, hidden=True).html == "