Skip to content

WIP: feat: native React client via client_type="react" - #69

Open
patrickoleary wants to merge 2 commits into
masterfrom
react-client-type
Open

WIP: feat: native React client via client_type="react"#69
patrickoleary wants to merge 2 commits into
masterfrom
react-client-type

Conversation

@patrickoleary

Copy link
Copy Markdown
Member

Summary

Add a React client (react-app/) as a peer of vue2-app/vue3-app. The server
side is unchanged: when client_type == "react", the widget tree serializes
to a JSON component tree (utils/react.py) pushed through the same
trame__template_* state keys, and the client renders it natively with
React.createElement.

  • widgets/core.py: structured attribute capture alongside the Vue string
    formatting (vue output byte-identical, regression-tested), react_node
    property, and r_* directive aliases (r_if/r_show/r_for/r_model/r_bind_/
    r_on_
    ) for React-flavored apps
  • react-app/: registry + renderer (expression evaluation against the shared
    state, conditionals, lists, controlled-component models, scoped slots,
    event modifiers), 14 built-in component ports, vite build into
    module/react-www
  • module/react.py + ui/core.py flush branch for the react client type

Add a React client (react-app/) as a peer of vue2-app/vue3-app. The server
side is unchanged: when client_type == "react", the widget tree serializes
to a JSON component tree (utils/react.py) pushed through the same
trame__template_* state keys, and the client renders it natively with
React.createElement.

- widgets/core.py: structured attribute capture alongside the Vue string
  formatting (vue output byte-identical, regression-tested), react_node
  property, and r_* directive aliases (r_if/r_show/r_for/r_model/r_bind_*/
  r_on_*) for React-flavored apps
- react-app/: registry + renderer (expression evaluation against the shared
  state, conditionals, lists, controlled-component models, scoped slots,
  event modifiers), 14 built-in component ports, vite build into
  module/react-www
- module/react.py + ui/core.py flush branch for the react client type
- examples/react/, serializer unit tests, Playwright e2e, react steps in CI,
  react-www in wheel data
@@ -0,0 +1,2 @@
/*! coi-serviceworker v0.1.7 - Guido Zuidhof and contributors, licensed under MIT */

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That file can be removed. We don't need it anymore. No need to keep it for react

return body, arg, []


def _apply_directive(dirs, js_key, expr):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove directives since they are not part of the react logic.

Comment thread src/trame_client/utils/react.py Outdated
if isinstance(elem, str):
return {"tag": "__fragment", "children": split_text(elem)}

server = getattr(elem, "server", None)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we pass the first if, we should be an abstract element which should have a server even if it is initialized to None...

We could have an explicit is_instance in an assert.

I can be wrong but that code like AI generated with lot of guarding while it should not needed.

("r_model_lazy", "v-model.lazy"),
("r_model_number", "v-model.number"),
("r_model_trim", "v-model.trim"),
("r_slot", "v-slot"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove all r_ as it is not part of react logic

return "\n".join(out_buffer)

@property
def react_node(self):

@jourdain jourdain Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should match the AbstractLayout react() property name.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually since in react they are different. The current name make sense.

name
for name in self._py_attr.keys()
if name.startswith("v_model_") or name.startswith("v_bind_")
if name.startswith(("v_model_", "r_model_", "v_bind_", "r_bind_"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove r_*


# smart key handling
if name.startswith("v_model_"):
if name.startswith(("v_model_", "r_model_")):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove r_*

else:
js_key = f"v-model:{model_name}{'.' if len(modifiers) else ''}{'.'.join(modifiers)}"
elif name.startswith("v_bind_"):
elif name.startswith(("v_bind_", "r_bind_")):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove r_*

}
return self

def attrs(self, *names):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that method should be revisited to better handle vue vs react logic. Having both at the same time is confusing.

return f"<{self._elem_name} html-error />"

@property
def react_node(self):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should match the AbstractLayout react() property name.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually since in react they are different. The current name make sense.

Comment thread src/trame_client/utils/react.py Outdated
def to_react_template(root):
"""Serialize a layout root into the JSON string pushed to the client"""
node = root.react_node if hasattr(root, "react_node") else to_react_node(root)
return json.dumps({"version": 1, "root": node})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should not convert to a string... We should keep the native structure as it will speedup network and handling on the client side.

function parsePayload(payload: unknown): TrameJsonNode | null {
if (!payload) return null;
try {
const { root } = JSON.parse(payload as string) as TrameTemplatePayload;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be the native structure rather than a json string

stateKey?: string | null;
}) {
const trame = useTrame();
useTrameState(); // template-wide re-render on any dirty state

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that full re-render is quite bad, I'm wondering if there is no other path that will make it cleaner. Maybe something based on Zustand for state handling?

State key now holds the payload dict directly instead of a
pre-serialized JSON string; client keeps a string parse path for
backward compatibility. Drop unused coi-serviceworker from the
react app.
@patrickoleary

Copy link
Copy Markdown
Member Author

Pushed 89c4008 — review fixes for the native-dict template payload:

  • trame__template_* now carries the payload dict directly instead of a pre-serialized JSON string (server to_react_template, client extractRoot); client keeps a JSON-string parse path for backward compatibility, and the error payloads (errorPayload) now push the native dict like the vue clients push their native template string.
  • Fixed boolean v-* flags (r_else=True, v-pre, v-once, ...) being recorded as static attrs instead of directives in AbstractElement.attrs, which broke the r_if/r_else_if/r_else chain in the react renderer.
  • extractRoot returns null (not undefined) when the payload lacks root.
  • Removed unused loadScript import and the coi-serviceworker from the react app.

@patrickoleary

Copy link
Copy Markdown
Member Author

The hardest thing for non-web developers is mixing javascript and Python. The 'r_*' directives are not for react developers, they are for non-web developers defining there ui on the Python side. So I push back that the directives are totally appropriate for this user base. look at the following examples. Which of these would be more appropriate for the non-web developers.

"""Same UI written twice: with r_* directives vs without.

Run and compare:
  python directives_vs_plain.py --port 8080
    http://localhost:8080/?ui=directives   (r_* directives, client-side)
    http://localhost:8080/?ui=plain        (python rebuild + JS expressions)
"""

from trame.app import get_server
from trame.ui.html import DivLayout
from trame.widgets import html

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

# -----------------------------------------------------------------------------
# Shared state
# -----------------------------------------------------------------------------

state.name = "trame"
state.visible = True
state.mode = "A"
state.items = ["alpha", "beta", "gamma"]
state.value = 50
state.rich = "<b>bold</b> from state"

# -----------------------------------------------------------------------------
# 1. With r_* directives: template is flushed once, everything below reacts
#    on the client without any server round-trip or re-flush.
# -----------------------------------------------------------------------------

with DivLayout(server, template_name="directives"):
    # r_text / r_html
    html.Div(r_text="`Hello ${name}`")
    html.Div(r_html=("rich",))

    # r_show
    html.Div("Toggle me", r_show=("visible",))

    # r_if / r_else_if / r_else
    html.Div("mode is A", r_if="mode === 'A'")
    html.Div("mode is B", r_else_if="mode === 'B'")
    html.Div("mode is something else", r_else=True)

    # r_for
    with html.Ul():
        html.Li("{{ idx }}: {{ item }}", r_for="(item, idx) in items", key="item")

    # r_model (+ modifier variant)
    html.Input(type="text", r_model=("name",))
    html.Input(type="range", min=0, max=100, r_model_number=("value",))
    html.Div("value = {{ value }}")

    # r_bind_<prop> / r_on_<event>_<mods>
    html.Div("hover for tooltip", r_bind_title=("name",))
    html.Button("visible = !visible", r_on_click_stop="visible = !visible")
    html.Button(
        "cycle mode",
        click="mode = mode === 'A' ? 'B' : mode === 'B' ? 'C' : 'A'",
    )

    # r_bind / r_on object forms
    html.Div("bound object", r_bind="{ title: name, id: `item-${mode}` }")
    html.Span("event object", r_on="{ mouseenter: () => { visible = true } }")

# -----------------------------------------------------------------------------
# 2. Without directives: same rendered result, but conditionals and loops are
#    resolved in python, so the layout must be re-flushed on every state
#    change; two-way binding is spelled out as bind + event in JS.
# -----------------------------------------------------------------------------


@state.change("visible", "mode", "items", "name")
def rebuild_plain(**_):
    with DivLayout(server, template_name="plain"):
        # text/html via interpolation and python formatting (frozen at flush)
        html.Div("Hello {{ name }}")
        html.Div(state.rich)  # python injects the markup at build time

        # show via a bound style expression (JS ternary)
        html.Div("Toggle me", style=("visible ? '' : 'display: none'",))

        # if/elif/else resolved in python (requires the re-flush)
        if state.mode == "A":
            html.Div("mode is A")
        elif state.mode == "B":
            html.Div("mode is B")
        else:
            html.Div("mode is something else")

        # for-loop resolved in python (requires the re-flush)
        with html.Ul():
            for idx, item in enumerate(state.items):
                html.Li(f"{idx}: {item}")

        # two-way binding spelled out: bind value + event writing back
        html.Input(type="text", value=("name",), input="name = $event.target.value")
        html.Input(
            type="range",
            min=0,
            max=100,
            value=("value",),
            input="value = Number($event.target.value)",
        )
        html.Div("value = {{ value }}")

        # bind/event without helper prefixes
        html.Div("hover for tooltip", title=("name",))
        html.Button("visible = !visible", click="visible = !visible")
        html.Button(
            "cycle mode",
            click="mode = mode === 'A' ? 'B' : mode === 'B' ? 'C' : 'A'",
        )


rebuild_plain()

# -----------------------------------------------------------------------------
# Start server
# -----------------------------------------------------------------------------

if __name__ == "__main__":
    server.start()```

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants