Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
41 changes: 41 additions & 0 deletions examples/react/dynamic_template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from trame.app import get_server
from trame.ui.html import DivLayout
from trame.widgets import html, react

from trame_client.utils.testing import enable_testing
from trame_client.widgets.core import VirtualNode

server = get_server(client_type="react")
state = server.state

state.count = 1

# React analogue of `examples/vue2/dynamic_template.py`: a VirtualNode is
# cleared and refilled, then re-flushed into the layout template.
dynamic = VirtualNode(server)


def update_ui():
state.count += 1
with dynamic.clear():
html.Div(f"Static text {state.count}", classes="staticDiv")
html.Div(["count = ", react.Bind("count")], classes="countDiv")


with DivLayout(server) as layout:
html.Button(
"Update template",
classes="updateBtn",
on_click=react.Callback(update_ui),
)
html.Button(
"count++",
classes="plusBtn",
on_click=react.Callback("count++"),
)
dynamic(layout)

update_ui()

enable_testing(server, "count")
server.start()
4 changes: 3 additions & 1 deletion react-app/src/components/TrameApp.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { Fragment, useEffect, useMemo, useState } from "react";
import { TrameContext } from "../runtime/trameContext";
import { createRefRegistry } from "../runtime/refs";
import { registerTag } from "../runtime/tags";
Expand All @@ -18,6 +18,8 @@ import TrameSizeObserver from "./TrameSizeObserver.jsx";
// Registered once at module load, before first render (§6 of the plan).
registerTag("ReactIf", ReactIf);
registerTag("ReactFor", ReactFor);
// Transparent container emitted by core.VirtualNode under client_type="react".
registerTag("ReactFragment", Fragment);
registerTag("trame-loading", TrameLoading);
registerTag("trame-template", TrameTemplate);
registerTag("trame-exec", TrameJSEval);
Expand Down
4 changes: 3 additions & 1 deletion react-app/src/components/TrameTemplate.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,7 @@ export default function TrameTemplate({ templateName = "main", urlKey = "ui", us
(cb) => trame.state.watch([stateKey], cb),
() => trame.state.get(stateKey),
);
return <TrameNode nodes={tree} scope={undefined} />;
// Key by template name so reusing this component for a different template
// remounts the subtree instead of reusing the previous template's DOM nodes.
return <TrameNode key={stateKey} nodes={tree} scope={undefined} />;
}
89 changes: 89 additions & 0 deletions react-app/tests/components/TrameTemplate.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, useEffect } from "react";
import { cleanup, render } from "@testing-library/react";
import { TrameContext } from "../../src/runtime/trameContext";
import { createRefRegistry } from "../../src/runtime/refs";
import { registerTag } from "../../src/runtime/tags";
import TrameTemplate from "../../src/components/TrameTemplate.jsx";
import { createFakeTrame } from "../helpers/fakeTrame";

afterEach(cleanup);

function renderTemplate(trame, templateName) {
const ctx = { trame, getRefCallback: createRefRegistry(trame) };
return render(
<TrameContext.Provider value={ctx}>
<TrameTemplate templateName={templateName} />
</TrameContext.Provider>,
);
}

describe("TrameTemplate", () => {
it("remounts its subtree when a reused instance switches template name", () => {
const mountSpy = vi.fn();
const unmountSpy = vi.fn();

function Probe() {
useEffect(() => {
mountSpy();
return () => unmountSpy();
}, []);
return <div data-testid="probe" />;
}
registerTag("test-probe", Probe);

const probeTree = { tag: "test-probe", props: {}, children: [] };
const { trame } = createFakeTrame({
trame__template_a: probeTree,
trame__template_b: probeTree,
});

const { rerender } = renderTemplate(trame, "a");
expect(mountSpy).toHaveBeenCalledTimes(1);
expect(unmountSpy).toHaveBeenCalledTimes(0);

const ctx = { trame, getRefCallback: createRefRegistry(trame) };
rerender(
<TrameContext.Provider value={ctx}>
<TrameTemplate templateName="b" />
</TrameContext.Provider>,
);

expect(unmountSpy).toHaveBeenCalledTimes(1);
expect(mountSpy).toHaveBeenCalledTimes(2);
});

it("does not remount its subtree when the same template's content updates", () => {
const mountSpy = vi.fn();
const unmountSpy = vi.fn();

function Probe() {
useEffect(() => {
mountSpy();
return () => unmountSpy();
}, []);
return <div data-testid="probe" />;
}
registerTag("test-probe-stable", Probe);

const { trame, setState } = createFakeTrame({
trame__template_a: { tag: "test-probe-stable", props: {}, children: [] },
});

renderTemplate(trame, "a");
expect(mountSpy).toHaveBeenCalledTimes(1);

act(() =>
setState({
trame__template_a: {
tag: "test-probe-stable",
props: {},
children: ["updated"],
},
}),
);

expect(mountSpy).toHaveBeenCalledTimes(1);
expect(unmountSpy).toHaveBeenCalledTimes(0);
});
});
174 changes: 174 additions & 0 deletions src/trame_client/widgets/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
from __future__ import annotations

import json
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any

from .core import HTML_CTX
from .react import Bind, Callback

if TYPE_CHECKING:
from trame_server import Server

__all__ = [
"ClientAdapter",
]

VUE_TO_REACT_EVENT_NAMES: dict[str, str] = {
"click": "on_click",
"contextmenu": "on_context_menu",
"dblclick": "on_double_click",
"mousedown": "on_mouse_down",
"mouseup": "on_mouse_up",
"mouseenter": "on_mouse_enter",
"mouseleave": "on_mouse_leave",
"mousemove": "on_mouse_move",
"mouseover": "on_mouse_over",
"mouseout": "on_mouse_out",
"keydown": "on_key_down",
"keyup": "on_key_up",
"keypress": "on_key_press",
"submit": "on_submit",
"input": "on_input",
"change": "on_change",
"focusin": "on_focus",
"focusout": "on_blur",
"touchstart": "on_touch_start",
"touchmove": "on_touch_move",
"touchend": "on_touch_end",
"touchcancel": "on_touch_cancel",
"animationstart": "on_animation_start",
"animationend": "on_animation_end",
"animationiteration": "on_animation_iteration",
"transitionend": "on_transition_end",
}


class ClientAdapter:
"""
Client-type aware UI helpers.

Callers use the Vue syntax (the default); the adapter translates it to the
active client so widgets stay client-agnostic.
"""

def __init__(self, server: Server | None = None):
self._server = server if server is not None else self._current_server()

@property
def server(self) -> Server:
return self._server

def style(self, style: Mapping[str, Any]) -> Any:
"""
Return a client-specific ``style`` value from a CSS mapping.

Keys may be kebab-case or camelCase. Dynamic values (see
:meth:`expression`) are supported on both clients.
"""
if self._is_react:
return {self._to_camel_case(name): value for name, value in style.items()}

if any(self._is_js_expression(value) for value in style.values()):
entries = ", ".join(
f"{self._to_camel_case(name)}: {self._style_value(value)}"
for name, value in style.items()
)
return (f"{{{entries}}}",)

return " ".join(
f"{self._to_kebab_case(name)}: {value};" for name, value in style.items()
)

def event(
self, name: str, handler: Any, modifiers: list[str] | None = None
) -> dict[str, Any]:
"""
Return the kwargs binding ``handler`` to the DOM event ``name``.

``name`` uses the Vue spelling and ``modifiers`` are Vue-style. Use the
bubbling ``"focusin"``/``"focusout"`` names rather than
``"focus"``/``"blur"`` for cross-client focus events.
"""
if not self._is_react:
if modifiers:
return {f"v_on_{name}_{'_'.join(modifiers)}": handler}
return {name: handler}

try:
react_name = VUE_TO_REACT_EVENT_NAMES[name]
except KeyError as error:
_error_msg = f"Event {name!r} is not supported by the react client"
raise ValueError(_error_msg) from error
return {react_name: Callback(handler, modifiers=modifiers)}

def events(self, **events: Any) -> dict[str, Any]:
"""
Return the kwargs for several DOM events at once.
"""
kwargs: dict[str, Any] = {}
for name, handler in events.items():
kwargs.update(self.event(name, handler))
return kwargs

def expression(self, js_expression: str) -> Any:
"""
Return a client-specific wrapper for a dynamic JS expression.

Usable as a prop value or inside :meth:`style`.
"""
if self._is_react:
return Bind(js_expression)
return (js_expression,)

@classmethod
def _current_server(cls) -> Server:
if HTML_CTX.element_stack:
server = HTML_CTX.element_stack[-1].server
if server is not None:
return server
return HTML_CTX._server

@property
def _client_type(self) -> str:
return self._server.client_type

@property
def _is_react(self) -> bool:
return self._client_type == "react"

@property
def _is_vue(self) -> bool:
return self._client_type in ("vue2", "vue3")

@classmethod
def _to_kebab_case(cls, name: str) -> str:
return "".join(f"-{char.lower()}" if char.isupper() else char for char in name)

@classmethod
def _to_camel_case(cls, name: str) -> str:
head, *rest = name.split("-")
return head + "".join(part[:1].upper() + part[1:] for part in rest)

@classmethod
def _is_js_expression(cls, value: Any) -> bool:
return (
isinstance(value, tuple) and len(value) == 1 and isinstance(value[0], str)
)

@classmethod
def _js_string(cls, value: Any) -> str:
if isinstance(value, str):
escaped = value.replace("\\", "\\\\").replace("'", "\\'")
return f"'{escaped}'"
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
return "null"
return json.dumps(value)

@classmethod
def _style_value(cls, value: Any) -> str:
if cls._is_js_expression(value):
return value[0]
return cls._js_string(value)
2 changes: 2 additions & 0 deletions src/trame_client/widgets/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import uuid
from pathlib import Path

from .adapter import ClientAdapter
from .core import AbstractElement
from .react import Bind as ReactBind
from ..external_script_handler import (
Expand All @@ -26,6 +27,7 @@
"LifeCycleMonitor",
"SizeObserver",
"Handler",
"ClientAdapter",
"register_external_script",
]

Expand Down
Loading