From 30b5912c2c93c5cfdb7ace7624645a6c2c8fcbd2 Mon Sep 17 00:00:00 2001 From: Thibault Pelletier Date: Fri, 11 Sep 2026 16:59:43 +0200 Subject: [PATCH 1/3] fix(react): add support for virtual nodes --- examples/react/dynamic_template.py | 41 +++++++++++++++++++++++++++ react-app/src/components/TrameApp.jsx | 4 ++- src/trame_client/widgets/core.py | 40 +++++++++++++------------- src/trame_client/widgets/react.py | 11 +++++++ src/trame_client/widgets/vue.py | 17 +++++++++++ tests/test_react_browser.py | 22 ++++++++++++++ tests/test_virtual_node.py | 26 +++++++++++++++++ 7 files changed, 141 insertions(+), 20 deletions(-) create mode 100644 examples/react/dynamic_template.py create mode 100644 tests/test_virtual_node.py diff --git a/examples/react/dynamic_template.py b/examples/react/dynamic_template.py new file mode 100644 index 0000000..d82c391 --- /dev/null +++ b/examples/react/dynamic_template.py @@ -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() diff --git a/react-app/src/components/TrameApp.jsx b/react-app/src/components/TrameApp.jsx index 193863e..42fdc9d 100644 --- a/react-app/src/components/TrameApp.jsx +++ b/react-app/src/components/TrameApp.jsx @@ -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"; @@ -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); diff --git a/src/trame_client/widgets/core.py b/src/trame_client/widgets/core.py index 2b94ec9..5b86241 100644 --- a/src/trame_client/widgets/core.py +++ b/src/trame_client/widgets/core.py @@ -1,9 +1,10 @@ """ The trame widget tree, shared by every `client_type`. -`ElementContextManager`/`HTML_CTX` and `VirtualNode` are framework-agnostic: -they only manage the python-side parent/child stack used by the -`with widget:` context-manager idiom. +`ElementContextManager`/`HTML_CTX` manage the python-side parent/child stack +used by the `with widget:` context-manager idiom. `VirtualNode.html` is the +only client-specific part: the active client module renders it (a template +string fragment for Vue, a dict node for React). `AbstractElement` is the base every trame widget (across the whole trame ecosystem) is built on, so it stays defined here rather than moving behind a @@ -148,19 +149,13 @@ def clear(self): @property def html(self): """ - Return a string representation of the HTML component + Return the active client's representation of this node. + + A template string fragment for Vue, a dict node for React. """ - out_buffer = [] - try: - for elem in self._children: - if isinstance(elem, str): - out_buffer.append(elem) - else: - out_buffer.append(elem.html) - except Exception as e: - print(e) - - return "\n".join(out_buffer) + return _get_client_module(self._server.client_type).render_virtual_node( + self._children, self._server + ) # ------------------------------------------------------------------------- # Resource manager @@ -197,19 +192,26 @@ def __call__(self, layout=None, **kwargs): HTML_CTX.add_child(self) -def _get_impl_class(client_type): +def _get_client_module(client_type): + """ + Return the `vue` or `react` module for the given `client_type`. + """ if client_type in VUE_CLIENT_TYPES: from . import vue - return vue.HtmlElement - elif client_type == "react": + return vue + if client_type == "react": from . import react - return react.HtmlElement + return react raise TypeError(f"Unsupported client_type={client_type!r}") +def _get_impl_class(client_type): + return _get_client_module(client_type).HtmlElement + + class AbstractElement(TrameComponent): """ A widget which can integrate with the rest of trame diff --git a/src/trame_client/widgets/react.py b/src/trame_client/widgets/react.py index 4fa9c81..b48ecc8 100644 --- a/src/trame_client/widgets/react.py +++ b/src/trame_client/widgets/react.py @@ -158,6 +158,17 @@ def _serialize_children(children, server): return out_buffer +def render_virtual_node(children, server): + """ + Serialize a `core.VirtualNode` to a transparent `ReactFragment` node. + """ + return { + "tag": "ReactFragment", + "props": {}, + "children": _serialize_children(children, server), + } + + # ----------------------------------------------------------------------------- # Structural nodes - If / For / Slot # ----------------------------------------------------------------------------- diff --git a/src/trame_client/widgets/vue.py b/src/trame_client/widgets/vue.py index 9cc2db5..eee7212 100644 --- a/src/trame_client/widgets/vue.py +++ b/src/trame_client/widgets/vue.py @@ -221,6 +221,23 @@ def register_directive(py_name, js_name=None): AVAILABLE_DIRECTIVES.append((py_name, js_name)) +def render_virtual_node(children, server): + """ + Serialize a `core.VirtualNode` to a Vue template-fragment string. + """ + out_buffer = [] + try: + for elem in children: + if isinstance(elem, str): + out_buffer.append(elem) + else: + out_buffer.append(elem.html) + except Exception as e: + print(e) + + return "\n".join(out_buffer) + + class HtmlElement: """ Vue2/Vue3 rendering implementation for a single `AbstractElement` diff --git a/tests/test_react_browser.py b/tests/test_react_browser.py index b737154..6da76a8 100644 --- a/tests/test_react_browser.py +++ b/tests/test_react_browser.py @@ -70,3 +70,25 @@ def test_react_if_for(server, page): expect(page.locator(".todoList")).to_have_count(0) expect(page.locator(".emptyMsg")).to_have_text("No todos left") assert server.get("todos") == [] + + +@pytest.mark.parametrize("server_path", ["examples/react/dynamic_template.py"]) +def test_react_dynamic_template(server, page): + url = f"http://127.0.0.1:{server.port}/" + page.goto(url) + + static_div = page.locator(".staticDiv") + count_div = page.locator(".countDiv") + + expect(static_div).to_have_text("Static text 2") + expect(count_div).to_have_text("count = 2") + + # react.Bind updates client-side without a server round trip. + page.locator(".plusBtn").click() + expect(count_div).to_have_text("count = 3") + + # VirtualNode.clear() + refill re-flushes the layout template. + page.locator(".updateBtn").click() + expect(static_div).to_have_text("Static text 4") + expect(count_div).to_have_text("count = 4") + assert server.get("count") == 4 diff --git a/tests/test_virtual_node.py b/tests/test_virtual_node.py new file mode 100644 index 0000000..f51962f --- /dev/null +++ b/tests/test_virtual_node.py @@ -0,0 +1,26 @@ +from trame.app import get_server +from trame.widgets import html + +from trame_client.widgets.core import VirtualNode + + +def test_vue_virtual_node_renders_template_string(): + server = get_server("test_vue_virtual_node", client_type="vue3") + node = VirtualNode(server) + with node: + html.Div("hello") + + assert node.html.strip() == "
\nhello\n
" + + +def test_react_virtual_node_renders_fragment(): + server = get_server("test_react_virtual_node", client_type="react") + node = VirtualNode(server) + with node: + html.Div("hello") + + assert node.html == { + "tag": "ReactFragment", + "props": {}, + "children": [{"tag": "div", "props": {}, "children": ["hello"]}], + } From 49659d5eacff3d96ca6e203ed682de7cb5bb5781 Mon Sep 17 00:00:00 2001 From: Thibault Pelletier Date: Fri, 11 Sep 2026 20:32:28 +0200 Subject: [PATCH 2/3] fix(react): remount TrameTemplate subtree on template name change --- react-app/src/components/TrameTemplate.jsx | 4 +- .../tests/components/TrameTemplate.test.jsx | 89 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 react-app/tests/components/TrameTemplate.test.jsx diff --git a/react-app/src/components/TrameTemplate.jsx b/react-app/src/components/TrameTemplate.jsx index a95b77c..06a945d 100644 --- a/react-app/src/components/TrameTemplate.jsx +++ b/react-app/src/components/TrameTemplate.jsx @@ -18,5 +18,7 @@ export default function TrameTemplate({ templateName = "main", urlKey = "ui", us (cb) => trame.state.watch([stateKey], cb), () => trame.state.get(stateKey), ); - return ; + // 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 ; } diff --git a/react-app/tests/components/TrameTemplate.test.jsx b/react-app/tests/components/TrameTemplate.test.jsx new file mode 100644 index 0000000..b2f2492 --- /dev/null +++ b/react-app/tests/components/TrameTemplate.test.jsx @@ -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( + + + , + ); +} + +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
; + } + 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( + + + , + ); + + 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
; + } + 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); + }); +}); From 5b9b7dfc04c4e7661f2f8be54d358c77376a5fd0 Mon Sep 17 00:00:00 2001 From: Thibault Pelletier Date: Sat, 12 Sep 2026 10:21:20 +0200 Subject: [PATCH 3/3] feat(adapter): add vue / react adapter Add client adapter responsible for converting events and style between vue and react. --- src/trame_client/widgets/adapter.py | 174 ++++++++++++++++++++++++++ src/trame_client/widgets/client.py | 2 + src/trame_client/widgets/react.py | 4 + src/trame_client/widgets/vue.py | 2 + tests/test_client_adapter.py | 182 ++++++++++++++++++++++++++++ tests/test_events_browser.py | 152 +++++++++++++++++++++++ 6 files changed, 516 insertions(+) create mode 100644 src/trame_client/widgets/adapter.py create mode 100644 tests/test_client_adapter.py create mode 100644 tests/test_events_browser.py diff --git a/src/trame_client/widgets/adapter.py b/src/trame_client/widgets/adapter.py new file mode 100644 index 0000000..c905776 --- /dev/null +++ b/src/trame_client/widgets/adapter.py @@ -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) diff --git a/src/trame_client/widgets/client.py b/src/trame_client/widgets/client.py index 48712bf..5dc2c1a 100644 --- a/src/trame_client/widgets/client.py +++ b/src/trame_client/widgets/client.py @@ -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 ( @@ -26,6 +27,7 @@ "LifeCycleMonitor", "SizeObserver", "Handler", + "ClientAdapter", "register_external_script", ] diff --git a/src/trame_client/widgets/react.py b/src/trame_client/widgets/react.py index b48ecc8..83966fd 100644 --- a/src/trame_client/widgets/react.py +++ b/src/trame_client/widgets/react.py @@ -358,6 +358,10 @@ def to_json(self, server): ("on_touch_move", "onTouchMove"), ("on_touch_end", "onTouchEnd"), ("on_touch_cancel", "onTouchCancel"), + ("on_animation_start", "onAnimationStart"), + ("on_animation_end", "onAnimationEnd"), + ("on_animation_iteration", "onAnimationIteration"), + ("on_transition_end", "onTransitionEnd"), ] diff --git a/src/trame_client/widgets/vue.py b/src/trame_client/widgets/vue.py index eee7212..ae205c8 100644 --- a/src/trame_client/widgets/vue.py +++ b/src/trame_client/widgets/vue.py @@ -96,7 +96,9 @@ "input", "change", "focus", + "focusin", "blur", + "focusout", ] V_ON_TYPE_TOUCH = [ "touchstart", diff --git a/tests/test_client_adapter.py b/tests/test_client_adapter.py new file mode 100644 index 0000000..ddd33af --- /dev/null +++ b/tests/test_client_adapter.py @@ -0,0 +1,182 @@ +import uuid + +import pytest +from trame.app import get_server +from trame.widgets import react + +from trame_client.widgets.adapter import VUE_TO_REACT_EVENT_NAMES, ClientAdapter +from trame_client.widgets.react import SHARED_EVENTS as REACT_SHARED_EVENTS + + +def _server(client_type: str): + return get_server(f"test_client_adapter_{uuid.uuid4()}", client_type=client_type) + + +def _adapter(client_type: str) -> ClientAdapter: + return ClientAdapter(_server(client_type)) + + +def _handler(*_args): + return None + + +def test_vue_adapter_renders_a_css_style_string(): + adapter = _adapter("vue3") + + assert ( + adapter.style({"background-color": "black", "flex-grow": "1"}) + == "background-color: black; flex-grow: 1;" + ) + + +def test_vue_adapter_also_accepts_camel_case_keys(): + adapter = _adapter("vue3") + + assert adapter.style({"backgroundColor": "black"}) == "background-color: black;" + + +def test_react_adapter_camel_cases_the_style_keys(): + adapter = _adapter("react") + + assert adapter.style({"background-color": "black", "flex-grow": "1"}) == { + "backgroundColor": "black", + "flexGrow": "1", + } + + +def test_vue_adapter_uses_the_dom_event_name(): + adapter = _adapter("vue3") + + assert adapter.event("mouseenter", _handler) == {"mouseenter": _handler} + + +def test_vue_adapter_uses_the_bubbling_focus_event_names(): + adapter = _adapter("vue3") + + assert adapter.event("focusin", _handler) == {"focusin": _handler} + assert adapter.event("focusout", _handler) == {"focusout": _handler} + + +def test_react_adapter_maps_bubbling_focus_events(): + adapter = _adapter("react") + + assert set(adapter.event("focusin", _handler)) == {"on_focus"} + assert set(adapter.event("focusout", _handler)) == {"on_blur"} + + +def test_vue_adapter_applies_event_modifiers(): + adapter = _adapter("vue3") + + assert adapter.event("click", _handler, modifiers=["stop", "prevent"]) == { + "v_on_click_stop_prevent": _handler + } + + +def test_react_adapter_applies_event_modifiers(): + adapter = _adapter("react") + + result = adapter.event("click", _handler, modifiers=["prevent"]) + + assert list(result) == ["on_click"] + assert result["on_click"].modifiers == ["prevent"] + + +def test_react_adapter_maps_animation_and_transition_events(): + adapter = _adapter("react") + + assert set( + adapter.events( + animationstart=_handler, + animationend=_handler, + animationiteration=_handler, + transitionend=_handler, + ) + ) == { + "on_animation_start", + "on_animation_end", + "on_animation_iteration", + "on_transition_end", + } + + +def test_react_event_map_matches_the_react_renderer(): + react_event_kwargs = { + name if isinstance(name, str) else name[0] for name in REACT_SHARED_EVENTS + } + assert set(VUE_TO_REACT_EVENT_NAMES.values()) == react_event_kwargs + + +def test_react_adapter_wraps_the_event_callback(): + server = _server("react") + adapter = ClientAdapter(server) + + result = adapter.event("mouseenter", _handler) + + assert list(result) == ["on_mouse_enter"] + assert result["on_mouse_enter"].to_json(server) == { + "callback": {"trigger": server.trigger_name(_handler)} + } + + +def test_react_adapter_maps_several_events(): + adapter = _adapter("react") + + assert set(adapter.events(click=_handler, mouseleave=_handler)) == { + "on_click", + "on_mouse_leave", + } + + +def test_react_adapter_rejects_an_unknown_event(): + adapter = _adapter("react") + + with pytest.raises(ValueError, match="not_an_event"): + adapter.event("not_an_event", _handler) + + +def test_adapter_expression_is_client_specific(): + assert _adapter("vue3").expression("count + 1") == ("count + 1",) + assert isinstance(_adapter("react").expression("count + 1"), react.Bind) + + +def test_vue_dynamic_style_becomes_a_bound_style_object(): + adapter = _adapter("vue3") + + style = adapter.style( + { + "position": "relative", + "width": "100%", + "cursor": adapter.expression("`${active_view_cursor}`"), + } + ) + + assert style == ( + "{position: 'relative', width: '100%', cursor: `${active_view_cursor}`}", + ) + + +def test_vue_dynamic_style_camel_cases_bound_keys(): + adapter = _adapter("vue3") + + style = adapter.style( + {"background-color": adapter.expression("color"), "width": "100%"} + ) + + assert style == ("{backgroundColor: color, width: '100%'}",) + + +def test_adapter_resolves_the_server_from_the_active_widget_context(): + from trame.widgets import html + + server = _server("react") + + with html.Div(trame_server=server): + adapter = ClientAdapter() + + assert adapter.server is server + + +def test_adapter_is_exposed_from_the_client_widget_module(): + from trame.widgets import client + + assert client.ClientAdapter is ClientAdapter diff --git a/tests/test_events_browser.py b/tests/test_events_browser.py new file mode 100644 index 0000000..845e854 --- /dev/null +++ b/tests/test_events_browser.py @@ -0,0 +1,152 @@ +import asyncio +import time +import uuid + +import pytest +from playwright.sync_api import expect, sync_playwright + +from trame.app import get_server +from trame.ui.html import DivLayout +from trame.widgets import html + +from trame_client.widgets.adapter import ClientAdapter + +MOUSE_EVENTS = [ + "click", + "contextmenu", + "dblclick", + "mousedown", + "mouseup", + "mouseenter", + "mouseleave", + "mousemove", + "mouseover", + "mouseout", +] +KEY_EVENTS = ["keydown", "keyup", "keypress"] +FOCUS_EVENTS = ["focusin", "focusout"] +TOUCH_EVENTS = ["touchstart", "touchmove", "touchend", "touchcancel"] +ANIMATION_EVENTS = ["animationstart", "animationend", "animationiteration"] +TRANSITION_EVENTS = ["transitionend"] + +TARGET_EVENTS = ( + MOUSE_EVENTS + + KEY_EVENTS + + FOCUS_EVENTS + + TOUCH_EVENTS + + ANIMATION_EVENTS + + TRANSITION_EVENTS +) +EVENT_NAMES = TARGET_EVENTS + ["input", "change", "submit"] + +CLIENT_TYPES = ["vue3", "react"] + + +def _state_key(event_name): + return f"event_{event_name}" + + +def build_events_app(server): + for name in EVENT_NAMES: + server.state.setdefault(_state_key(name), False) + + def make_handler(event_name): + def handler(**_): + server.state[_state_key(event_name)] = True + + return handler + + handlers = {name: make_handler(name) for name in EVENT_NAMES} + adapter = ClientAdapter(server) + + with DivLayout(server): + html.Button( + "target", + classes="eventTarget", + **adapter.events(**{name: handlers[name] for name in TARGET_EVENTS}), + ) + with html.Form( + classes="eventForm", **adapter.events(submit=handlers["submit"]) + ): + html.Input( + classes="eventInput", + **adapter.events( + input=handlers["input"], + change=handlers["change"], + focusin=handlers["focusin"], + focusout=handlers["focusout"], + ), + ) + + +def run_interactions(url): + """Drive every event through a real browser interaction (or dispatch).""" + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + try: + page = browser.new_page() + page.goto(url) + + target = page.locator(".eventTarget") + text_input = page.locator(".eventInput") + form = page.locator(".eventForm") + expect(target).to_be_visible() + + target.click() + target.dblclick() + target.click(button="right") + + target.hover() + page.mouse.move(1000, 600) + + target.focus() + text_input.focus() + + target.focus() + page.keyboard.press("a") + + text_input.fill("hello") + target.focus() + form.dispatch_event("submit") + + for name in ("touchstart", "touchmove", "touchend", "touchcancel"): + target.dispatch_event(name) + + for name in ( + "animationstart", + "animationend", + "animationiteration", + "transitionend", + ): + target.dispatch_event(name) + + # Let the event triggers round-trip to the (same-process) server + # before the browser is closed. + page.wait_for_timeout(500) + finally: + browser.close() + + +async def wait_for_event(server, event_name, timeout=5.0): + deadline = time.monotonic() + timeout + while server.state[_state_key(event_name)] is not True: + if time.monotonic() > deadline: + raise AssertionError(f"Event {event_name!r} was not triggered") + await asyncio.sleep(0.05) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("client_type", CLIENT_TYPES) +async def test_events_trigger_for_the_same_interactions(client_type, unused_tcp_port): + server = get_server(f"test_events_{uuid.uuid4()}", client_type=client_type) + build_events_app(server) + + server.start(port=unused_tcp_port, exec_mode="task") + try: + await server.ready + await asyncio.to_thread(run_interactions, f"http://127.0.0.1:{server.port}/") + + for name in EVENT_NAMES: + await wait_for_event(server, name) + finally: + await server.stop()