diff --git a/.github/workflows/test_and_release.yml b/.github/workflows/test_and_release.yml index 838c8cc..6746be9 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 @@ -101,6 +70,7 @@ jobs: cd js-lib npm ci npm run typecheck + npm test npm run build - name: Build Vue2 App @@ -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 @@ -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 @@ -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 diff --git a/README.rst b/README.rst index 7fcaf81..c07e3cc 100644 --- a/README.rst +++ b/README.rst @@ -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 diff --git a/docs/adding-support-for-react/react-app-implementation-plan.md b/docs/adding-support-for-react/react-app-implementation-plan.md new file mode 100644 index 0000000..84c5906 --- /dev/null +++ b/docs/adding-support-for-react/react-app-implementation-plan.md @@ -0,0 +1,526 @@ +# `react-app/` — React client for `client_type="react"` + +> **Status: implementation plan, not yet built.** Written after +> `src/trame_client/widgets/react.py`, `widgets/core.py`'s `client_type` +> dispatch, and `widgets/generator.py`'s React-flavored prop generation were +> already implemented and committed (covered by `tests/test_react.py`). +> `module/__init__.py`'s `client_type == "react"` branch is still a no-op +> because no client bundle exists yet — this document designs that missing +> piece. Cross-referenced against the design docs in this directory +> (`react-getting-started.md`, `react-refs.md`, `react-scoped-slots.md`, +> `react-text-interpolation.md`, `vue-vs-react-with-trame.md`) and against the +> existing `vue3-app/` and `js-lib/` implementations it's meant to mirror. + +## Context + +`trame-client` currently ships two Vue-based clients (`vue2-app`, `vue3-app`, built into `src/trame_client/module/{vue2,vue3}-www` and served by `module/vue2.py`/`module/vue3.py`). The Python side has been extending support for a third, `client_type="react"`: `src/trame_client/widgets/react.py` (a full `AbstractElement._impl` implementation producing a serializable `{tag, props, children}` JSON tree instead of a Vue template string), `widgets/core.py` (dispatches `_impl` by `client_type`), and `widgets/generator.py` (emits React-flavored camelCase prop names for every generated `html.*` element) are already implemented, committed on this branch, and covered by `tests/test_react.py`. `module/__init__.py`'s `client_type == "react"` branch is still a no-op (`pass`) because **no client bundle exists yet** — there is no `react-app/` to build one. + +This plan designs that missing piece: a Vite/React app, structurally mirroring `vue3-app/`, that (a) connects to the trame server using `js-lib` (published as `@kitware/trame`, already a complete, framework-agnostic reimplementation of the connect/state/wslink layer — do not duplicate it, the way `vue3-app` still duplicates its own older copy of that logic), and (b) walks the JSON tree `react.py` produces via a generic `` renderer, per the design worked out in `docs/adding-support-for-react/`. + +**Scope, per discussion with the user:** core primitive layer only — the app shell, ``, `Bind`/`Callback`/`If`/`For`/`Slot`/`ref` resolution, and just enough of `widgets/trame.py`'s helper widgets to boot (`ServerTemplate`, `Loading`). The wider widget ecosystem and the rest of `widgets/trame.py` (`Getter`, `DeepReactive`, `Handler`, `Style`, `Script`, `ClientTriggers`, `SizeObserver`, `LifeCycleMonitor`, `ClientStateChange`) stay Vue-only for now — several of them currently poke raw Vue `v-slot`/`v-bind` strings directly and aren't react-compatible; fixing that is explicitly deferred, noted as follow-up. + +**Reactivity, per discussion with the user:** no new state-management dependency (no Redux/Zustand/Jotai/Valtio). Build on React's `useSyncExternalStore` wired to js-lib's `State.watch(keys, fn)` (already a dynamic per-key subscription primitive), plus a small home-grown expression evaluator that auto-discovers which state keys/scope vars each `{js: ...}` expression actually reads (Valtio-style dependency tracking via a `Proxy`, but self-contained). + +--- + +## 1. Scaffold + +``` +react-app/ + package.json + vite.config.js + index.html + src/ + main.jsx + style.css + setup.js # page-resource loading, ported from vue3-app/src/core/trame/setup.js + messageChannel.js # verbatim port of vue3-app/src/messageChannel.js + components/ + TrameApp.jsx + TrameTemplate.jsx + TrameLoading.jsx + TrameReconnect.jsx + TrameNode.jsx + ReactIf.jsx + ReactFor.jsx + runtime/ + trameContext.js # React context wrapping {trame, getRefCallback} + scope.js # scope-chain (extendScope, buildMergedScope, hasOwnLocal) + expr.js # compile cache + Proxy dependency tracking (evalTracked) + resolveNode.js # useResolvedNode: the one hook-call-site per TrameNodeOne + refs.js # callback-ref registry + tags.js # tag -> component registry (resolveTag/registerTag) + tests/ (vitest, mirrors js-lib/tests/ conventions) +``` + +### `package.json` + +```json +{ + "name": "trame-app-react", + "private": true, + "type": "module", + "scripts": { + "build": "vite build --emptyOutDir", + "debug": "vite build --sourcemap -m dev", + "dev": "vite", + "test": "vitest run" + }, + "dependencies": { + "@kitware/trame": "file:../js-lib", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.0", + "@testing-library/react": "^16.0.0", + "jsdom": "^27.4.0", + "vite": "^8.0.11", + "vitest": "^4.1.11" + } +} +``` + +- **React 19**: `react-refs.md` §2 relies on function components taking `ref` as an ordinary prop with no `forwardRef` boilerplate. +- **`@kitware/trame: file:../js-lib`**: no npm-workspace tooling exists in this repo (confirmed — no root `package.json`, no `pnpm-workspace.yaml`); `file:` is the simplest way to consume the in-repo js-lib during co-development, and resolves through js-lib's real `dist/` build (its `package.json` `main`/`module`/`types` point at `dist/trame.umd.js`/`dist/trame.mjs`/`dist/main.d.ts`) — **so `js-lib` must be built (`npm ci && npm run build`) before `react-app` is `npm ci`'d/built.** +- **One required, additive change to js-lib**: `js-lib/src/main.ts` currently only exports `Trame` (default) + types. `react-app` also needs `configDecorator`/`createClient` (from `js-lib/src/wslink/index.ts`) and `extractURLParameters` (from `js-lib/src/URLExtract.ts`) — neither is re-exported today. Add them as named exports from `main.ts`. This is additive (no signature changes to anything existing), and is the only js-lib change this plan requires. + +### `vite.config.js` + +```js +import react from "@vitejs/plugin-react"; + +export default { + base: "./", + plugins: [react()], + build: { + outDir: "../src/trame_client/module/react-www", + }, +}; +``` + +Mirrors `vue3-app/vite.config.js` exactly, plus the JSX plugin (for react-app's *own* components — unrelated to the "no compiler ships for the widget tree" decision: `TrameNode` still calls `React.createElement` directly for the dynamic tree since `tag` is a runtime string, never JSX). + +### `index.html` + +Same shape as `vue3-app/index.html` (meta tags, `data-app-name`/`data-launcher-retry` dataset attrs consumed by `configDecorator`, `#app` div, the `loading.tpl`-fetch-and-inline-before-connect trick), minus ``. + +### `src/main.jsx` + +Bootstrap sequence ported from `vue3-app/src/main.js`, rebased directly onto js-lib's `Trame` class (no `core/trame`/`core/wslink` duplication): + +1. Cross-origin-safe `window.parent.trameJupyter.init` check (verbatim). +2. `const trame = new Trame(window.WSLINK)`; `configDecorator({application: "trame", useUrl: true})`. +3. URL-param cleanup (verbatim port of the `paramsToClean` block). +4. `await trame.connect(config)` — on failure, render ``/`` directly via `createRoot(...).render(...)` instead of Vue's pre-mount state-key-write trick (React doesn't need a template round-trip through state to render arbitrary content before the app tree exists — simpler than the Vue original, not a gap). +5. `console.error` override forwarding to `trame.client.getRemote().Trame.sendError(...)` (verbatim). +6. `handlePageResources(trame.state.get())` from `./setup.js` — styles/scripts/module_scripts/favicon/title only (see §9, `trame__vue_use` has no React equivalent and is dropped). +7. `createRoot(document.getElementById("app")).render()`. +8. Bottom-of-file service-worker (`enableSharedArrayBufferServiceWorker`) and `wsChannel`/`MessageChannel` proxy bootstrapping — verbatim port of the corresponding block in `vue3-app/src/main.js`. + +### `src/setup.js`, `src/messageChannel.js` + +Ports of `vue3-app/src/core/trame/setup.js` and `vue3-app/src/messageChannel.js` — both are framework-agnostic DOM/browser code with no Vue dependency; copy near-verbatim. `setup.js` drops the `trame__vue_use` → `state.trame__vue_use.map(...)` tail of `handlePageResources` (no plugin-registration concept in React). + +--- + +## 2. `` — the generic JSON-tree renderer + +`src/components/TrameNode.jsx` is two components: + +```jsx +export default function TrameNode({ nodes, scope }) { + if (nodes == null) return null; + const trame = useTrame(); + const list = Array.isArray(nodes) ? nodes : [nodes]; + return list.map((node, i) => ( + + )); +} + +function TrameNodeOne({ node, scope }) { + if (typeof node === "string") return node; + if (isBindLeaf(node)) return ; + if (!node || node.tag === undefined) return null; + + const Component = resolveTag(node.tag); + const { props } = useResolvedNode(node, scope); // the ONE hook-call-site, see §2.2 + + if (isStructuralTag(node.tag)) { + return createElement(Component, { ...props, rawChildren: node.children, scope }); + } + const children = node.children?.length + ? createElement(TrameNode, { nodes: node.children, scope }) + : undefined; + return createElement(Component, props, children); +} +``` + +- **`computeNodeKey`** (plain function, no hooks): if `node?.props?.key` is a `{js: expr}` leaf, evaluate it synchronously (via `evalTracked`, no subscription — the enclosing `TrameNode` already re-renders whenever its own reactive inputs change, so a fresh read here is always current); if it's a plain scalar, use it directly; otherwise fall back to the array index `i`. This has to happen at the *parent's* `.map()` step, not inside `TrameNodeOne`, because React needs an element's `key` before that element is even created/mounted. +- **Structural tags** (`ReactIf`, `ReactFor`) get their raw, unresolved `children` + the current `scope` passed as ordinary props (`rawChildren`, `scope`) instead of a pre-built `` — they need to control *whether* and *with what extended scope* their children render, which a pre-resolved child element can't express. Every other tag (DOM host tags, and any future custom-widget tag) gets its children pre-wrapped in a nested ``, exactly like any other prop. +- **`key=`** needs no special-casing beyond `computeNodeKey` above: React's `createElement` already strips a `key` found in the props object before handing `props` to the component, so `key` also going through the normal reactive-prop path in `useResolvedNode` (§2.2) alongside everything else is harmless, if slightly redundant. + +### 2.1 Prop-resolution order + +By **prop key** first, then by **value shape**, matching `react-refs.md` §5 / `react-scoped-slots.md` §4 exactly: + +1. `key === "ref"` → dispatch to the ref-callback registry (§5). `ref`'s value is *always* a plain string (a name to register under), never wrapped — checked by key, not shape. +2. Everything else, checked by value shape: `"js" in value` → reactive expression; `"callback" in value` → event handler; `"slot" in value` → render-prop generator; otherwise → plain passthrough. + +### 2.2 `useResolvedNode` — one hook-call-site per node (`runtime/resolveNode.js`) + +A naive design calls a hook (`useTrameExpr`, `useMemo` for each callback, ...) once per prop while iterating `Object.entries(node.props)` — that's a **Rules-of-Hooks violation risk** (hook-call count/order would depend on how many reactive/callback props a given node happens to have) and needlessly creates one `useSyncExternalStore` subscription per reactive prop instead of one per node. Instead, `useResolvedNode(node, scope)` is a **single hook**, always called the same way regardless of node shape, that internally batches all of a node's reactive props into one subscription: + +```js +export function useResolvedNode(node, scope) { + const trame = useTrame(); + + // Pure, non-hook classification of node.props — cheap object walk. + const { reactive, callbacks, slots, ref, static_ } = classifyProps(node.props); + + // One subscription for the whole node's reactive props (Bind-valued + // props, including a Bind-valued `key`), keyed on the UNION of every + // state key any of them actually reads. + const trackedKeys = useMemo(() => { + const merged = buildMergedScope(scope, trame.state); + const keys = new Set(); + reactive.forEach(([, expr]) => evalTracked(expr, merged).keys.forEach((k) => keys.add(k))); + return [...keys]; + }, [reactive, scope, trame.state]); + + const reactiveValues = useSyncExternalStore( + (onChange) => trame.state.watch(trackedKeys, onChange), + () => Object.fromEntries( + reactive.map(([key, expr]) => [key, evalTracked(expr, buildMergedScope(scope, trame.state)).value]), + ), + ); + + // One memo for all callback props, content-keyed (not identity-keyed) so + // a re-render doesn't mint fresh handler functions unless the underlying + // js/trigger/args/modifiers actually changed (see §2.3, §6). + const callbackDepKey = callbacks.map(([k, v]) => k + JSON.stringify(v)).join("|"); + const callbackValues = useMemo( + () => Object.fromEntries(callbacks.map(([key, spec]) => [key, makeCallbackHandler(spec, scope, trame)])), + [callbackDepKey, scope, trame], + ); + + // Slots: no subscription needed - just capture children/scope, cheap to rebuild. + const slotValues = Object.fromEntries(slots.map(([key, spec]) => [key, makeSlotRenderProp(spec, scope)])); + + const props = { ...Object.fromEntries(static_), ...reactiveValues, ...callbackValues, ...slotValues }; + if (ref) props.ref = getRefCallback(ref); + return { props }; +} +``` + +This gives a **fixed hook sequence** (`useTrame`/context read → `useMemo` → `useSyncExternalStore` → `useMemo`) per `TrameNodeOne` instance, independent of how many props of which kind that node has — safe under the Rules of Hooks, and far cheaper than one subscription per reactive prop. + +### 2.3 Reactive expression evaluator (`runtime/expr.js`) + +```js +const compiledCache = new Map(); // expression string -> Function, never evicted (finite, server-authored set) + +export function compile(jsExpression) { + let fn = compiledCache.get(jsExpression); + if (!fn) { + // eslint-disable-next-line no-new-func -- same trust model Vue's compiled + // templates already accept: expression strings are server/app-author-controlled. + fn = new Function("scope", `with (scope) { return (${jsExpression}); }`); + compiledCache.set(jsExpression, fn); + } + return fn; +} + +export function evalTracked(jsExpression, mergedScope) { + const tracked = new Set(); + const proxy = new Proxy(mergedScope, { + has() { return true; }, // force every free identifier through `get`, see below + get(target, prop) { + if (typeof prop === "string" && !hasOwnLocal(target, prop)) tracked.add(prop); + return target[prop]; + }, + }); + return { value: compile(jsExpression)(proxy), keys: [...tracked] }; +} +``` + +- `with (scope) { ... }` inside a `Function`-constructed body works even though the calling module is strict (bodies created via the `Function` constructor are *not* strict by default) — this is what lets a bare identifier like `count` in a Python-authored expression resolve against an arbitrary runtime object without the caller having named it as a parameter. +- `with` probes the scope's `has` trap for every free identifier before falling through to outer/global scope; returning `true` unconditionally forces every identifier through `get`, which is where tracking happens. +- `hasOwnLocal` (§3) distinguishes "resolved from a `For`/`Slot`-introduced local variable" (not tracked — shadowing, not state) from "resolved from trame state" (tracked, becomes a `state.watch` dependency). +- **Cost**: two evaluations of a small cached `Function` over a small object per render pass (one via `useMemo` to compute `trackedKeys`, one inside `getSnapshot`) — required because `useSyncExternalStore`'s `getSnapshot` must be a pure function of only its own closure, not of a value computed earlier in the same render. +- **Resubscription semantics**: `state.watch(keys, fn)`'s dependency list is fixed for the life of one subscription, so an expression like `state[mode === "a" ? "x" : "y"]` correctly triggers a new subscription (via `trackedKeys` changing identity in the `useMemo`) when `mode` flips — matches how Valtio's own dependency tracking behaves, not a shortcut. + +### 2.4 Scope chain (`runtime/scope.js`) + +Chosen shape: **`Object.create`-based prototype chain**, not an explicit array — JS property lookup along a prototype chain already implements "nearest enclosing scope wins, else fall through" for free, and composes directly with the `Proxy` in §2.3 (the proxy wraps the chain head; `target[prop]` triggers the walk automatically). Nesting (`For` inside `For`, `Slot` inside `For`) is just another `Object.create(currentFrame)` call — no special nesting logic (resolves the "nested scoped slots" open question in `react-scoped-slots.md` §6 for the common case). + +```js +export function extendScope(parentScope, names, values) { + const frame = Object.create(parentScope ?? STATE_FACADE); + names.forEach((name, i) => { frame[name] = values[i]; }); + return frame; +} + +const STATE_FACADE = { __isStateFacade: true }; + +export function buildMergedScope(scope, trameState) { + const facade = new Proxy(STATE_FACADE, { + get: (_t, prop) => (prop === "__isStateFacade" ? true : trameState.get(String(prop))), + }); + return scope ? Object.assign(Object.create(facade), scope) : facade; + // (see note below on why this needs one more pass than a naive "just set + // scope's ultimate prototype to facade" — scope's own chain was already + // built against a static STATE_FACADE marker at extendScope() time, not a + // live trameState-backed proxy, so buildMergedScope re-roots it per call.) +} + +function hasOwnLocal(frame, prop) { + while (frame && !frame.__isStateFacade) { + if (Object.prototype.hasOwnProperty.call(frame, prop)) return true; + frame = Object.getPrototypeOf(frame); + } + return false; +} +``` + +This is the single fiddliest piece in the whole plan — worth deliberate unit tests (§10): a loop variable or slot param shadowing a same-named state key must never be recorded as a `state.watch` dependency. + +--- + +## 3. `ReactIf` / `ReactFor` (`components/ReactIf.jsx`, `components/ReactFor.jsx`) + +Both are plain presentational components — `value`/`items` arrive already resolved (via `useResolvedNode` in the parent `TrameNodeOne`, §2.2), so neither needs its own hooks: + +```jsx +export default function ReactIf({ value, rawChildren, scope }) { + return value ? : null; +} +``` + +```jsx +export default function ReactFor({ items, name, rawChildren, scope }) { + return (items ?? []).map((item, index) => ( + + )); +} +``` + +`ReactFor` uses the array index as its own React key (no other generically-available stable identity — `react.For` doesn't mandate items carry an id). A widget author wanting stable row identity across reordering puts an explicit `key=` on the element(s) inside the `For` body, which is honored one level down by `computeNodeKey` (§2) inside the nested `` — not by `ReactFor`'s own `.map()`. Per-row fine-grained memoization beyond this is out of scope (§10). + +--- + +## 4. Callback resolution (`makeCallbackHandler`, part of `runtime/resolveNode.js`) + +```js +const MODIFIER_HANDLERS = { prevent: (e) => e.preventDefault(), stop: (e) => e.stopPropagation() }; + +function makeCallbackHandler({ callback, modifiers }, scope, trame) { + return (event) => { + modifiers?.forEach((m) => MODIFIER_HANDLERS[m]?.(event)); + const merged = buildMergedScope(extendScope(scope, ["$event"], [event]), trame.state); + if ("js" in callback) { + compile(callback.js)(merged); + return; + } + const args = callback.args ? compile(callback.args.js)(merged) : []; + const kwargs = callback.kwargs ? compile(callback.kwargs.js)(merged) : {}; + trame.trigger(callback.trigger, args, kwargs); + }; +} +``` + +- The DOM event is exposed as an ordinary scope binding under the name `$event` (matching `react.py`/`react-getting-started.md`'s convention exactly, e.g. `onChange=react.Callback("count = Number($event.target.value)")`), via the same `extendScope` mechanism as loop vars. +- `callback.args`/`callback.kwargs` are themselves `{js: "..."}`-wrapped expression strings (per `react.py`'s `Callback.to_json`), evaluated fresh **at call time** against the scope captured when the handler was created — correct, since Python's `args`/`kwargs` are meant to be re-evaluated per call, not memoized as a static value. +- **Memoization**: `useResolvedNode` (§2.2) already memoizes the whole `callbacks` map per node via one `useMemo` keyed on a content hash (`key + JSON.stringify(spec)`) plus `scope`/`trame` identity — avoids minting a fresh function every render (the "freshly-minted functions" problem flagged in `react-scoped-slots.md` §6 / `react-fine-grained-reactivity.md` §1) while still correctly re-minting when `scope` changes (e.g., each `For` row is a distinct component instance via `key`, so this mostly guards the same instance's scope value changing across renders without a full remount). + +--- + +## 5. Ref callback registry (`runtime/refs.js`) + +Direct transcription of `react-refs.md` §5, instantiated once per `trame` instance (since `trame.refs` — from `js-lib/src/trame.ts` — is per-connection: a fresh `Trame()` on reconnect gets a fresh `refs` map): + +```js +export function createRefRegistry(trame) { + const cache = new Map(); + return function getRefCallback(name) { + if (!cache.has(name)) { + cache.set(name, (el) => { + if (el) trame.refs[name] = el; + else { delete trame.refs[name]; cache.delete(name); } + }); + } + return cache.get(name); + }; +} +``` + +Exposed via `TrameContext` (`runtime/trameContext.js`, `createContext({trame, getRefCallback})`) alongside `trame` itself, so `useResolvedNode` can call it for `ref=` props (§2.1). + +--- + +## 6. Tag registry (`runtime/tags.js`) + +```js +const registry = { ReactIf, ReactFor }; +export function registerTag(name, Component) { registry[name] = Component; } +export function isStructuralTag(tag) { return tag === "ReactIf" || tag === "ReactFor"; } +export function resolveTag(tag) { + if (tag in registry) return registry[tag]; + if (/^[a-z]/.test(tag)) return tag; // lower-case first char => real DOM host tag + console.warn(`TrameNode: unknown tag "${tag}", rendering nothing`); + return () => null; +} +``` + +`tags.js` never imports from `components/` (avoids an import cycle with `TrameNode.jsx`); `TrameApp.jsx` calls `registerTag("trame-loading", TrameLoading)` / `registerTag("trame-template", TrameTemplate)` once at module load, before first render. This flat registry **is** the extension point a future custom-widget ecosystem would grow into — no dynamic registration API beyond `registerTag` is designed now, since there's nothing real yet to register against it (§10). + +--- + +## 7. App shell + +### `components/TrameApp.jsx` + +Responsibilities ported from `vue3-app/src/components/TrameApp.js`, **minus** per-`trame__template_*`-name dynamic-component registration (no Vue-style "register a component by string name" system to feed — `TrameTemplate` just reads its state key directly and hands the dict to `TrameNode`). Keeps: connection-ready gating, `onClose`/reconnect-driven re-render (`refreshTS`), the `beforeunload` → `lifeCycleUpdate("client_exited")` hookup, `client_unmounted` on unmount. + +### `components/TrameTemplate.jsx` + +Direct analog of `vue3-app/src/components/TrameTemplate.js`'s default export (not its separate `setup()` helper, which exists only to build a Vue-instance-reactive API object for Vue's template compiler — no equivalent need here, since `TrameNode` reads `trame.state`/scope directly): + +```jsx +export default function TrameTemplate({ templateName = "main", urlKey = "ui", useUrl = false }) { + const { trame } = useTrame(); + const params = useUrl ? extractURLParameters() : {}; + const stateKey = `trame__template_${params[urlKey] ?? templateName}`; + const tree = useSyncExternalStore( + (cb) => trame.state.watch([stateKey], cb), + () => trame.state.get(stateKey), + ); + return ; +} +``` + +Simpler than the Vue original precisely because there's no dynamic-component-by-name indirection to replicate — `AbstractLayout.flush_content()` (`src/trame_client/ui/core.py`) already puts the JSON tree dict directly into that state key (confirmed via `tests/test_react.py`: `root.html` is a `dict`). + +### `components/TrameLoading.jsx`, `components/TrameReconnect.jsx` + +Near-verbatim ports of `vue3-app/src/components/TrameLoading.js` / `TrameReconnect.js` — same markup/CSS classes (`trame__loader`/`trame__message`, copy the relevant rules from `vue3-app/src/style.css` into `react-app/src/style.css`), swapping `onMounted`/`onBeforeUnmount`/`inject("trame")` for `useEffect`/`useTrame()`. + +--- + +## 8. Python-side wiring + +### `src/trame_client/module/react.py` (new) + +```python +from pathlib import Path + +www = str(Path(__file__).with_name("react-www").resolve()) +``` + +Exact mirror of `module/vue3.py`. + +### `src/trame_client/module/__init__.py` + +Replace the current no-op branch: + +```python + elif client_type == "react": + from . import react + + server.enable_module(react) + setup_handler_module(server) +``` + +(`setup_handler_module` — already defined in this file — wires up `trame__scripts`/`trame__module_scripts` serving for user-provided external scripts; included for parity since `react-app/src/setup.js` already handles those state keys, §1.) + +### Build tooling + +- `noxfile.py`: `VUE_APPS` (currently `{"vue2-app": ..., "vue3-app": ...}`) gains `"react-app": Path("src/trame_client/module/react-www")`. Since `react-app` additionally depends on `js-lib`'s build output, add a step that builds `js-lib` first (`npm ci && npm run build` inside `js-lib/`, only if `js-lib/dist` doesn't already exist) before `_ensure_vue_apps_built` iterates into `react-app`. +- `pyproject.toml`: `[tool.hatch.build] include` currently lists `/src/trame_client/module/vue2-www/**` and `/src/trame_client/module/vue3-www/**` explicitly — add `/src/trame_client/module/react-www/**` alongside them. + +--- + +## 9. Explicitly deferred / out of scope + +- **Widget ecosystem port** (a Vuetify-equivalent, `VDataTable`, etc.) — `registerTag` (§6) is the only extension point; nothing real registers against it in this plan. +- **`widgets/trame.py` widgets beyond `Loading`/`ServerTemplate`**: `Getter`, `DeepReactive`, `Handler`, `Style`, `Script`, `ClientTriggers`, `SizeObserver`, `LifeCycleMonitor`, `ClientStateChange` stay Vue-only (several poke raw Vue `v-slot`/`v-bind` strings directly today and need Python-side rework first) — follow-up, not attempted here. +- **`trame__vue_use`** (Vue-plugin registration via `state.trame__vue_use`) — no React equivalent; silently dropped from `setup.js`. +- **Per-row `For` memoization beyond index-based `key`** — acceptable at this scope (§3). +- **A real component registry / dynamic tag resolution beyond the flat object in `tags.js`** — fine for two structural tags plus two lifecycle widgets; would need real design once actual custom widgets exist. +- **`react.py`/`widgets/core.py`'s existing contract is untouched** — nothing here proposes changing the already-committed Python tree-serialization code. +- **The one required js-lib change** (§1: additive named exports from `main.ts`) is the sole out-of-band dependency this plan has on code outside `react-app/` itself. + +--- + +## 10. Verification + +### Manual smoke test + +1. `cd js-lib && npm ci && npm run build` +2. `cd react-app && npm ci && npm run build` → produces `src/trame_client/module/react-www` +3. New example, `examples/react/reactive_state.py`, mirroring `examples/vue3/reactive_state.py`'s style (`from trame.app import get_server`, `from trame.widgets import html, react`, `from trame.ui.html import DivLayout` — all confirmed real/working imports via `tests/test_react.py`, which already does `from trame.widgets import html, react`): + +```python +from trame.app import get_server +from trame.widgets import html, react +from trame.ui.html import DivLayout + +server = get_server(client_type="react") +state = server.state +state.count = 2 +state.todos = ["Write docs", "Review PR", "Ship it"] + + +def reset(value=2): + state.count = value + + +with DivLayout(server) as layout: + html.Div(["count = ", react.Bind("count", count=2)]) + html.Input( + type="range", min=0, max=10, step=1, + value=react.Bind("count", count=2), + onChange=react.Callback("count = Number($event.target.value)"), + ) + html.Button("Reset", onClick=react.Callback(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")) + +server.start() +``` + +4. `python examples/react/reactive_state.py`, open the browser. Checklist: + - Page connects and renders (no stuck `TrameLoading`). + - Dragging the range input updates the displayed count live; clicking Reset sets it back to 2. + - The todo list renders from `For`, disappears if `todos` is emptied (via a second trigger, or manually through devtools) and the "empty" `If` branch would need adding to fully exercise both branches. + - Add a temporary `console.log` in an unrelated sibling node to confirm it does *not* re-render when only `count` changes — validates fine-grained subscription (§2.2/§2.3) is actually narrow, not tree-wide. + - `?reconnect=auto` after killing/restarting the server process exercises `TrameReconnect`. + +### Automated tests + +`js-lib/tests/` (vitest + `jsdom`, `describe`/`it`/`expect`, a `helpers/fakeClient.ts`-style fake) is the pattern to follow; add `react-app/vitest.config.js` in the same shape plus `@testing-library/react`. Priority coverage, given §2.4/§2.3 are the novel/fiddly pieces: + +- `runtime/scope.test.js` — `extendScope` nesting (`For` inside `For`, `Slot` inside `For`) preserves shadowing; `hasOwnLocal` stops correctly at the state-facade sentinel. +- `runtime/expr.test.js` — same expression string reuses the cached `Function`; dependency tracking records only identifiers actually read (a ternary only tracks its taken branch); a scope-chain local shadowing a same-named state key is never tracked as a `state.watch` dependency (the critical regression case). +- `components/TrameNode.test.jsx` (`@testing-library/react` + a fake `trame`) — renders a `{tag:"div", props:{className:"x"}, children:["hi"]}` tree to the expected DOM; a `{"js":"count"}` child re-renders only when `count` changes (render-count spy); `ref="name"` populates `trame.refs.name` on mount, removes it on unmount. +- `components/ReactFor.test.jsx` — each row's scope correctly isolates its own loop variable (row *N* must not see row *N-1*'s value). +- `runtime/resolveNode.test.js` — `{callback:{trigger:"foo", args:{js:"[$event.target.value]"}}}` calls `trame.trigger("foo", [value], {})` with correctly-evaluated args; `modifiers:["prevent"]` calls `event.preventDefault()`. + +No browser/e2e suite is proposed here beyond the manual smoke test — the repo's root `tests/` already has Playwright coverage for vue2/vue3 per `noxfile.py`; extending that to drive `react-app` once built is a natural, separate follow-up. + +--- + +## Appendix: grounding notes from codebase review + +Cross-checked against the actual repo state (2026-09-03, branch `add-react-support`) before implementation: + +- `js-lib/src/main.ts` today exports only `Trame` (default) plus `TrameConnectConfig`/`Decorator`/`StateChangeEvent` types — confirms §1's "one required js-lib change" is real and additive. `js-lib/src/wslink/index.ts` exports a default object `{ configDecorator, createClient }` (not named exports) and `js-lib/src/URLExtract.ts` exports a default object `{ toNativeType, extractURLParameters }` — `main.ts`'s new named exports need to destructure these default exports, not just re-export a name that doesn't exist. +- `js-lib/src/trame.ts`'s `Trame` class already exposes `client`, `state`, `config`, `refs`, `connect()`, `trigger()`, `onClose()`, `onError()` exactly as this plan assumes; `state.watch(keys, fn)` (`js-lib/src/state.ts`) calls `fn` immediately with current values on subscribe, which `useSyncExternalStore` depends on for its synchronous initial snapshot. +- `js-lib/tests/helpers/fakeClient.ts` is a ready-made fake `vtkWSLinkClient` (`getState`, `updateState`, `trigger`, `subscribeToStateUpdate`, `subscribeToActions`, connect/disconnect/busy) — reuse this pattern (or the same helper via a relative import) for `react-app`'s own vitest suite rather than re-inventing a fake client. +- `tests/test_react.py` confirms the exact wire shapes this plan relies on: `{"js": ...}` leaves for `Bind`, `{"callback": {...}, "modifiers": [...]}` for `Callback` (with `trigger`/`args`/`kwargs` sub-keys when wrapping a Python callable), `{"tag": "ReactIf"/"ReactFor", "props": {...}, "children": [...]}` for structural nodes, and `{"slot": {"params": [...], "children": [...]}}` for `Slot`. It also confirms `key=` can itself be a `Bind` (`html.Li([...], key=react.Bind("todo"))` serializes to `"key": {"js": "todo"}`), validating §2's `computeNodeKey` needing to handle a `{js: ...}` leaf, not just plain scalars. +- `react-getting-started.md` and the `onChange=react.Callback("count = Number($event.target.value)")` examples confirm the DOM event is referenced as `$event` in author-facing JS expressions — §4 (`makeCallbackHandler`) binds the scope variable as `$event` accordingly. +- `widgets/core.py`'s `AbstractElement.__init__` dispatches `self._impl = _get_impl_class(self.server.client_type)(self, kwargs)`, and already has a `client_type == "react"` branch returning `react.HtmlElement` — so the Python side is fully wired up to produce trees for any widget once `self.server.client_type == "react"`; only the client bundle and `module/__init__.py`'s dispatch (§8) are missing. +- `widgets/trame.py`'s `Loading` (`_elem_name = "trame-loading"`, attr `message`) and `ServerTemplate` (`_elem_name = "trame-template"`, attrs `name`→`templateName`, `use_url`→`useUrl`, `url_key`→`urlKey`) both declare their attrs via `self._attr_names += [...]`, which `widgets/core.py`'s backward-compat aliasing routes to `self._impl.props` — i.e. `react.HtmlElement` (already implemented) picks these up automatically through the existing `props`/`SHARED_PROPS` mechanism; no Python-side change is needed for these two widgets, only client-side `TrameLoading`/`TrameTemplate` components registered under those exact tag strings (§6/§7). +- `noxfile.py`'s existing `VUE_APPS` dict and `_ensure_vue_apps_built` helper, and `pyproject.toml`'s `[tool.hatch.build] include` list, were read directly to confirm the exact edits described in §8 are additive (new dict entry / new glob line) rather than requiring restructuring. 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..009b2cd --- /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($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__=` 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($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 | 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..ccc418e --- /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($event.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 $event.preventDefault(), $event.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($event.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($event.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. diff --git a/examples/react/client_widgets.py b/examples/react/client_widgets.py new file mode 100644 index 0000000..af3c971 --- /dev/null +++ b/examples/react/client_widgets.py @@ -0,0 +1,235 @@ +""" +Exercise every widget in `trame.widgets.client` against the react client: + + - Style inject/update a global +
+
+
Loading...
+
diff --git a/react-app/public/logo.png b/react-app/public/logo.png new file mode 100644 index 0000000..2abe7a0 Binary files /dev/null and b/react-app/public/logo.png differ diff --git a/react-app/src/components/ReactFor.jsx b/react-app/src/components/ReactFor.jsx new file mode 100644 index 0000000..6cb267c --- /dev/null +++ b/react-app/src/components/ReactFor.jsx @@ -0,0 +1,19 @@ +import TrameNode from "./TrameNode.jsx"; +import { extendScope } from "../runtime/scope"; + +// Plain presentational component - `items` arrives already resolved (via +// useResolvedNode in the parent TrameNodeOne), so it needs no hooks of its own. +// Uses the array index as its own React key (no other generically-available +// stable identity - react.For doesn't mandate items carry an id). A widget +// author wanting stable row identity across reordering puts an explicit +// `key=` on the element(s) inside the For body, honored one level down by +// computeNodeKey inside the nested . +export default function ReactFor({ items, name, rawChildren, scope }) { + return (items ?? []).map((item, index) => ( + + )); +} diff --git a/react-app/src/components/ReactIf.jsx b/react-app/src/components/ReactIf.jsx new file mode 100644 index 0000000..bd4505b --- /dev/null +++ b/react-app/src/components/ReactIf.jsx @@ -0,0 +1,7 @@ +import TrameNode from "./TrameNode.jsx"; + +// Plain presentational component - `value` arrives already resolved (via +// useResolvedNode in the parent TrameNodeOne), so it needs no hooks of its own. +export default function ReactIf({ value, rawChildren, scope }) { + return value ? : null; +} diff --git a/react-app/src/components/TrameApp.jsx b/react-app/src/components/TrameApp.jsx new file mode 100644 index 0000000..193863e --- /dev/null +++ b/react-app/src/components/TrameApp.jsx @@ -0,0 +1,76 @@ +import { useEffect, useMemo, useState } from "react"; +import { TrameContext } from "../runtime/trameContext"; +import { createRefRegistry } from "../runtime/refs"; +import { registerTag } from "../runtime/tags"; +import ReactIf from "./ReactIf.jsx"; +import ReactFor from "./ReactFor.jsx"; +import TrameLoading from "./TrameLoading.jsx"; +import TrameTemplate from "./TrameTemplate.jsx"; +import TrameReconnect from "./TrameReconnect.jsx"; +import TrameJSEval from "./TrameJSEval.jsx"; +import TrameStyle from "./TrameStyle.jsx"; +import TrameScript from "./TrameScript.jsx"; +import TrameClientStateChange from "./TrameClientStateChange.jsx"; +import TrameClientTriggers from "./TrameClientTriggers.jsx"; +import TrameLifeCycleMonitor from "./TrameLifeCycleMonitor.jsx"; +import TrameSizeObserver from "./TrameSizeObserver.jsx"; + +// Registered once at module load, before first render (§6 of the plan). +registerTag("ReactIf", ReactIf); +registerTag("ReactFor", ReactFor); +registerTag("trame-loading", TrameLoading); +registerTag("trame-template", TrameTemplate); +registerTag("trame-exec", TrameJSEval); +registerTag("trame-style", TrameStyle); +registerTag("trame-script", TrameScript); +registerTag("trame-client-state-change", TrameClientStateChange); +registerTag("trame-client-triggers", TrameClientTriggers); +registerTag("trame-life-cycle-monitor", TrameLifeCycleMonitor); +registerTag("trame-size-observer", TrameSizeObserver); + +// Responsibilities ported from vue3-app/src/components/TrameApp.js, minus +// per-`trame__template_*`-name dynamic-component registration (no Vue-style +// "register a component by string name" system to feed - TrameTemplate just +// reads its state key directly and hands the dict to TrameNode). +export default function TrameApp({ trame, useUrl = false }) { + const [connected, setConnected] = useState(() => trame.isConnected()); + const [refreshTS, setRefreshTS] = useState(0); + + useEffect(() => { + const unsubscribe = trame.onClose(() => setConnected(false)); + + function onBeforeUnload() { + trame.client?.getRemote()?.Trame?.lifeCycleUpdate("client_exited"); + } + window.addEventListener("beforeunload", onBeforeUnload); + + return () => { + unsubscribe(); + window.removeEventListener("beforeunload", onBeforeUnload); + trame.client?.getRemote()?.Trame?.lifeCycleUpdate("client_unmounted"); + }; + }, [trame]); + + const ctx = useMemo( + () => ({ trame, getRefCallback: createRefRegistry(trame) }), + [trame, refreshTS], + ); + + if (!connected) { + return ( + { + setRefreshTS((v) => v + 1); + setConnected(true); + }} + /> + ); + } + + return ( + + + + ); +} diff --git a/react-app/src/components/TrameClientStateChange.jsx b/react-app/src/components/TrameClientStateChange.jsx new file mode 100644 index 0000000..b244f54 --- /dev/null +++ b/react-app/src/components/TrameClientStateChange.jsx @@ -0,0 +1,28 @@ +import { useEffect, useRef } from "react"; + +// Port of vue3-app/src/components/TrameClientStateChange.js. `value` arrives +// already resolved (react.Bind, evaluated reactively by useResolvedNode), so +// this only needs to notice when it changes. `immediate` (sync vs. Vue's +// nextTick emission) has no meaningful react equivalent - useEffect already +// defers to after commit either way - so it's accepted but unused here; +// `triggerChangeOnCreate` fires `onChange` once on mount, before any change. +export default function TrameClientStateChange({ + value, + triggerChangeOnCreate, + onChange, + children, +}) { + const isFirst = useRef(true); + + useEffect(() => { + if (isFirst.current) { + isFirst.current = false; + if (triggerChangeOnCreate) onChange?.(value); + return; + } + onChange?.(value); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + return children ?? null; +} diff --git a/react-app/src/components/TrameClientTriggers.jsx b/react-app/src/components/TrameClientTriggers.jsx new file mode 100644 index 0000000..05aa060 --- /dev/null +++ b/react-app/src/components/TrameClientTriggers.jsx @@ -0,0 +1,41 @@ +import { useEffect, useImperativeHandle } from "react"; + +// Port of vue3-app/src/components/TrameClientTriggers.js. `events` collects +// every non-ref prop by name - both the "built-in" ones (mounted, created, +// beforeDestroy, beforeUnmount, exit) and any arbitrary custom topic name a +// caller declared - so a single generic `emit(topic, event)` (exposed for +// server-triggered `.call(method, *args)` dispatch via js-lib's ref-action +// mechanism) can fire either kind the same way vue's `emit()` does. +export default function TrameClientTriggers({ ref, children, ...events }) { + useImperativeHandle( + ref, + () => ({ + emit: (topic, event) => events[topic]?.(event), + }), + [events], + ); + + useEffect(() => { + events.created?.(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + events.mounted?.(); + return () => { + events.beforeDestroy?.(); + events.beforeUnmount?.(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + function onExit() { + events.exit?.(); + } + window.addEventListener("beforeunload", onExit); + return () => window.removeEventListener("beforeunload", onExit); + }, [events.exit]); + + return children ?? null; +} diff --git a/react-app/src/components/TrameJSEval.jsx b/react-app/src/components/TrameJSEval.jsx new file mode 100644 index 0000000..9622457 --- /dev/null +++ b/react-app/src/components/TrameJSEval.jsx @@ -0,0 +1,19 @@ +import { useImperativeHandle } from "react"; + +// Port of vue3-app/src/components/TrameExec.js: exposes an imperative +// `exec(arg)` method (invoked server-side via `server.js_call(ref, "exec", +// ...)`, dispatched client-side through the generic ref-action mechanism in +// js-lib's Trame class) - calling it with no argument fires `onExec` with +// the bound `event` prop's current value, calling it with an argument fires +// `onExec` with that argument instead. +export default function TrameJSEval({ event, onExec, ref, children }) { + useImperativeHandle( + ref, + () => ({ + exec: (arg) => onExec?.(arg === undefined ? event : arg), + }), + [event, onExec], + ); + + return children ?? null; +} diff --git a/react-app/src/components/TrameLifeCycleMonitor.jsx b/react-app/src/components/TrameLifeCycleMonitor.jsx new file mode 100644 index 0000000..0793a78 --- /dev/null +++ b/react-app/src/components/TrameLifeCycleMonitor.jsx @@ -0,0 +1,66 @@ +import { useEffect, useRef } from "react"; + +const DEFAULT_EVENTS = [ + "created", + "beforeMount", + "mounted", + "beforeUpdate", + "updated", + "beforeDestroy", + "destroyed", +]; + +// Port of vue3-app/src/components/TrameLifeCycleMonitor.js. React function +// components have no separate before/after hook for mount or destroy (only +// one commit point each), so - matching how TrameClientTriggers already +// conflates beforeDestroy/beforeUnmount at its single unmount point - +// created/beforeMount fire together (synchronously, during first render) and +// beforeDestroy/destroyed fire together (at unmount cleanup). beforeUpdate/ +// updated fire together on every re-render after the first. +export default function TrameLifeCycleMonitor({ + name = "LifeCycleMonitor", + type = "log", + value = "value", + events = DEFAULT_EVENTS, + children, + ...topicHandlers +}) { + const fire = (topicName) => { + if (!events.includes(topicName)) return; + if (type === "emit") { + topicHandlers[topicName]?.({ name, value }); + } else { + // eslint-disable-next-line no-console + console[type]?.(name, topicName, value); + } + }; + + const isFirstRenderRef = useRef(true); + if (isFirstRenderRef.current) { + isFirstRenderRef.current = false; + fire("created"); + fire("beforeMount"); + } else { + fire("beforeUpdate"); + } + + // Runs once per actual mount/unmount (never re-runs on prop changes). + useEffect(() => { + fire("mounted"); + return () => { + fire("beforeDestroy"); + fire("destroyed"); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Runs after every commit; the first commit is "mounted" (already fired + // above), so only count "updated" from the second commit onward. + const commitCount = useRef(0); + useEffect(() => { + commitCount.current += 1; + if (commitCount.current > 1) fire("updated"); + }); + + return children ?? null; +} diff --git a/react-app/src/components/TrameLoading.jsx b/react-app/src/components/TrameLoading.jsx new file mode 100644 index 0000000..655c684 --- /dev/null +++ b/react-app/src/components/TrameLoading.jsx @@ -0,0 +1,17 @@ +export default function TrameLoading({ message = "Loading..." }) { + return ( +
+
+
{message}
+
+ ); +} diff --git a/react-app/src/components/TrameNode.jsx b/react-app/src/components/TrameNode.jsx new file mode 100644 index 0000000..a9588e0 --- /dev/null +++ b/react-app/src/components/TrameNode.jsx @@ -0,0 +1,134 @@ +import { createElement, useMemo, useRef, useSyncExternalStore } from "react"; +import { useTrame } from "../runtime/trameContext"; +import { useResolvedNode } from "../runtime/resolveNode"; +import { createSnapshotCache, evalTracked } from "../runtime/expr"; +import { buildMergedScope } from "../runtime/scope"; +import { resolveTag, isStructuralTag } from "../runtime/tags"; +import { useLiteralChildren } from "../runtime/resolveLiteralChildren"; + +function isBindLeaf(node) { + return node !== null && typeof node === "object" && !Array.isArray(node) && "js" in node; +} + +// A `{js: "..."}` leaf found directly in a `children` array (text +// interpolation, react-text-interpolation.md) rather than as a node prop: +// its own small subscription, isolated from whatever sibling nodes surround +// it, so a change to the expression's inputs re-renders only this leaf. +function ExprLeaf({ jsExpression, scope }) { + const { trame } = useTrame(); + + const trackedKeys = useMemo(() => { + const merged = buildMergedScope(scope, trame.state); + return evalTracked(jsExpression, merged).keys; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [jsExpression, scope, trame.state]); + + const snapshotCacheRef = useRef(null); + if (snapshotCacheRef.current === null) snapshotCacheRef.current = createSnapshotCache(); + + const value = useSyncExternalStore( + (onChange) => trame.state.watch(trackedKeys, onChange), + () => + snapshotCacheRef.current( + evalTracked(jsExpression, buildMergedScope(scope, trame.state)).value, + ), + ); + + return value; +} + +// Plain function (no hooks): has to run at the PARENT's .map() step, not +// inside TrameNodeOne, because React needs an element's `key` before that +// element is even created/mounted. +function computeNodeKey(node, scope, trame, i) { + const keyProp = node?.props?.key; + if (keyProp !== null && typeof keyProp === "object" && "js" in keyProp) { + return evalTracked(keyProp.js, buildMergedScope(scope, trame.state)).value; + } + if (keyProp !== undefined) { + return keyProp; + } + return i; +} + +// Builds one element PER node directly, rather than a single +// element wrapping the whole list - used both by the top-level +// TrameNode component and inline inside TrameNodeOne (below) for a node's +// own children. Passing the array itself as `children` (instead of one +// nested ) matters for a parent Component that inspects its own +// `children` via `React.Children.map`/`toArray` (e.g. MUI's +// Tabs/RadioGroup/ButtonGroup, which clone each item to inject per-item +// props like `onClick`/`selected`): with a single wrapper element in the +// way, React.Children only ever sees that one opaque wrapper, not one +// element per actual child, so there's nothing distinct to clone. +function renderNodeList(nodes, scope, trame) { + if (nodes == null) return undefined; + const list = Array.isArray(nodes) ? nodes : [nodes]; + return list.map((node, i) => ( + + )); +} + +// `createElement(Component, props, childrenArray)` sets `props.children` to +// that ARRAY even when it holds exactly one element - fine for a host that +// reads React.Children as a list (the multi-child case renderNodeList's own +// comment above is about), but not for one that requires its single child to +// BE an element so it can React.cloneElement/isValidElement it directly (MUI +// Snackbar/Tooltip wrap their one child this way to attach a transition +// ref). A real single child stays a real single child; an empty or +// multi-item list is untouched. +function unwrapSingle(children) { + return Array.isArray(children) && children.length === 1 ? children[0] : children; +} + +function TrameNodeOne({ node, scope, ...extraProps }) { + if (typeof node === "string") return node; + if (isBindLeaf(node)) return ; + if (!node || node.tag === undefined) return null; + + const { trame } = useTrame(); + const Component = resolveTag(node.tag); + const { props } = useResolvedNode(node, scope); + + // A parent may have cloned this very element (React.cloneElement) to graft + // its own props onto it - the pattern MUI's Tabs/RadioGroup/ButtonGroup + // use on each of their `children` to inject `onClick`/`selected`/`checked`/ + // etc. React strips `key`/`ref` out before they ever reach here, so + // `extraProps` only ever holds genuine DOM/component props, which take + // precedence over this node's own resolved ones - matching cloneElement's + // own "new props win" semantics. Note this only forwards props FORWARD + // (into what actually renders); `child.props.*` read back by the parent + // BEFORE cloning still sees this node's `{node, scope}`, not real ones - + // components needing that instead (Select's synchronous `child.props.value` + // reads) still need `literal_children` (resolveLiteralChildren.js). + const mergedProps = { ...props, ...extraProps }; + + if (isStructuralTag(node.tag)) { + // Structural tags (ReactIf/ReactFor) get their raw, unresolved children + + // the current scope as ordinary props - they control WHETHER and with + // WHAT EXTENDED SCOPE their children render, which a pre-resolved child + // element can't express. + return createElement(Component, { ...mergedProps, rawChildren: node.children, scope }); + } + + // node.literalChildren (react.py's `literal_children` widget flag) is + // static per tree position - like node.tag, it never toggles across + // re-renders of the same node - so this conditional hook call is safe, + // the same assumption the early returns above already make about a node's + // shape being stable (see resolveLiteralChildren.js for why this exists). + if (node.literalChildren) { + // eslint-disable-next-line react-hooks/rules-of-hooks + const literalChildren = useLiteralChildren(node.children, scope); + return createElement(Component, mergedProps, unwrapSingle(literalChildren)); + } + + const children = node.children?.length + ? unwrapSingle(renderNodeList(node.children, scope, trame)) + : undefined; + return createElement(Component, mergedProps, children); +} + +export default function TrameNode({ nodes, scope }) { + const { trame } = useTrame(); + return renderNodeList(nodes, scope, trame) ?? null; +} diff --git a/react-app/src/components/TrameReconnect.jsx b/react-app/src/components/TrameReconnect.jsx new file mode 100644 index 0000000..ffba882 --- /dev/null +++ b/react-app/src/components/TrameReconnect.jsx @@ -0,0 +1,97 @@ +import { useEffect, useRef } from "react"; +import { extractURLParameters } from "@kitware/trame"; + +// Near-verbatim port of vue3-app/src/components/TrameReconnect.js, swapping +// onMounted/onBeforeUnmount/inject("trame") for useEffect/a `trame` prop +// (rendered outside of - there's no live app tree to +// provide a context through while disconnected). +export default function TrameReconnect({ + trame, + onReconnected, + message = "Click to reconnect...", + maxRetry = 10, + delay = 500, +}) { + const retryCount = useRef(0); + const interval = useRef(null); + + async function connect() { + try { + await trame.reconnect(); + onReconnected?.(); + } catch (e) { + console.error(e); + } + } + + function resetRetry() { + if (interval.current) { + clearInterval(interval.current); + interval.current = null; + } + } + + useEffect(() => { + retryCount.current = 0; + const { reconnect } = extractURLParameters(); + if (reconnect === "auto") { + interval.current = setInterval(() => { + if (retryCount.current++ < maxRetry) { + connect(); + } else { + resetRetry(); + } + }, delay); + } + return resetRetry; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+
+ + + + {message} +
+
+
+ ); +} diff --git a/react-app/src/components/TrameScript.jsx b/react-app/src/components/TrameScript.jsx new file mode 100644 index 0000000..214b45d --- /dev/null +++ b/react-app/src/components/TrameScript.jsx @@ -0,0 +1,33 @@ +import { useEffect, useRef } from "react"; + +// Port of vue3-app/src/components/TrameScript.js: injects/updates a global +// `