Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b476f17
docs: capture design decisions
jourdain Sep 1, 2026
1a3868b
test: improve ci and test coverage
jourdain Sep 1, 2026
9363da7
feat(js-lib): migrate to typescript
jourdain Sep 1, 2026
25242d3
test: add js-lib testing
jourdain Sep 1, 2026
25f9f93
feat(vue)!: externalize attribute handling into vue specific module
jourdain Sep 2, 2026
dd961aa
fix(client_type): add placeholder for react
jourdain Sep 2, 2026
30023a0
fix(react): implement py side
jourdain Sep 3, 2026
14c9322
fix(html): add support for react attributes
jourdain Sep 3, 2026
c7ea07b
feat(react): add initial implementation of client
jourdain Sep 3, 2026
c799541
fix(client): rename trame.py to client.py to match main namespace
jourdain Sep 3, 2026
4754495
test(react): add python tests
jourdain Sep 3, 2026
118949b
fix(react): implement client widgets
jourdain Sep 4, 2026
830266a
fix(react): allow widgets to be registered
jourdain Sep 4, 2026
9cc1f45
fix(react): rename e to in Callback
jourdain Sep 4, 2026
2fb3c68
feat(react): add support for literal children option
jourdain Sep 7, 2026
bc838bb
ci: build react client in ci
jourdain Sep 7, 2026
6349ae1
fix(react): allow style to have Bind() values
jourdain Sep 7, 2026
db74ab6
ci: force server to be >=3.15
jourdain Sep 7, 2026
3241298
ci: add some wait for boxSize
jourdain Sep 7, 2026
b4b8ada
fix(react): improve export perf
jourdain Sep 8, 2026
ce5b13a
fix(literal_children): allow definition at construction
jourdain Sep 8, 2026
d674a0d
fix(react): more literal_children fix
jourdain Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 31 additions & 42 deletions .github/workflows/test_and_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
# - {
Expand All @@ -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
Expand All @@ -101,6 +70,7 @@ jobs:
cd js-lib
npm ci
npm run typecheck
npm test
npm run build

- name: Build Vue2 App
Expand All @@ -115,6 +85,13 @@ jobs:
npm ci
npm run build

- name: Build React App
run: |
cd react-app
npm ci
npm test
npm run build

release:
needs: [pre-commit, pytest, test-npm-build]
runs-on: ubuntu-latest
Expand All @@ -137,6 +114,12 @@ jobs:
with:
node-version: 24

- name: Build js-lib
run: |
cd js-lib
npm ci
npm run build

- name: Build Vue2 App
run: |
cd vue2-app
Expand All @@ -149,6 +132,12 @@ jobs:
npm ci
npm run build

- name: Build React App
run: |
cd react-app
npm ci
npm run build

- name: Python Semantic Release
id: release
uses: relekang/python-semantic-release@v10.6.2
Expand Down
5 changes: 4 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@ Python environment setup
pre-commit install --hook-type commit-msg

# Run pre-commit
pre-commit run --all-files
nox -s pre_commit

# Run tests for only 1 python version
nox -s tests-3.12


JavaScript dependency
Expand Down
526 changes: 526 additions & 0 deletions docs/adding-support-for-react/react-app-implementation-plan.md

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions docs/adding-support-for-react/react-fine-grained-reactivity.md
Original file line number Diff line number Diff line change
@@ -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?
155 changes: 155 additions & 0 deletions docs/adding-support-for-react/react-for-vue-developers.md
Original file line number Diff line number Diff line change
@@ -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($event.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_<event>_<modifier>=` 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_<name>="{ 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($event.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 |
Loading
Loading