Skip to content

Commit 67652fa

Browse files
committed
docs: describe the live event proxy from #1352
The event your handler receives is a live proxy, not a payload, so the pages which still described a serialized snapshot are rewritten around it: - handlers are coroutines, and the failure shapes are separated (coroutine object -> attribute fall-through -> orjson TypeError on send, vs. a real None from a non-handler); 'synchronous handler' and 'may be async def' leftovers in the state pages are gone - reads are awaited, batched with read()/gather, never subscripted; a missing await yields a truthy proxy rather than None, and hasattr() on a proxy always lies - debounce coalesces client->server events (it never paced server values), the server echo is the per-event _reactpy_ack_seq mapping, the target prefix is a key path and not the patch path, and the six model keys come from types.ALLOWED_VDOM_KEYS rather than the wire schema - partial with a bound leading argument defeats bytecode detection and changes the handler's arity - corrected in the handler page and the tutorial - client-api covers how handleIncoming answers event-proxy-request itself, and the stopImmediatePropagation wire key is documented
1 parent 6c56313 commit 67652fa

23 files changed

Lines changed: 494 additions & 202 deletions

‎AGENT_CONTEXT.md‎

Lines changed: 156 additions & 37 deletions
Large diffs are not rendered by default.

‎docs/src/about/docs.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Things that do not survive a rename:
5555

5656
!!! warning "Pitfall"
5757

58-
A page can pass every automated check while teaching something false, because the checks are syntactic. The ReactJS FIXME that sat in `thinking-in-react.md` claiming `event["target"]["checked"]` does not work was wrong — a checkbox `onChange` receives `checked=True`. The only defence is running the example and reading what the client actually sent.
58+
A page can pass every automated check while teaching something false, because the checks are syntactic. The ReactJS FIXME that sat in `thinking-in-react.md` claiming `checked` was not delivered was wrong — a checkbox `onChange` handler reads it back as `#!python await event.target.checked`. The only defence is running the example against a real layout and reading what the client actually answered.
5959

6060
## Page structure
6161

‎docs/src/learn/communicate-data-between-server-and-client.md‎

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22

33
<p class="intro" markdown>
44

5-
ReactPy connects a Python process and a browser tab with one websocket and one message type in each direction. That single constraint decides how every kind of data moves between the two: values the server knows flow down as rendered models, values the user produces flow up as event payloads, and anything else needs a deliberate detour. This page is a map of those routes — what crosses, what cannot, and the honest workaround for each.
5+
ReactPy connects a Python process and a browser tab with one websocket. Everything crossing it is JSON, and nothing is pushed: the server sends rendered models, the client sends events, and a handler which wants to know something about the browser has to ask for it one property at a time. That constraint decides how every kind of data moves — values the server knows flow down as models, values the user produces flow up as events, and anything else needs a deliberate detour. This page is a map of those routes — what crosses, what cannot, and the honest workaround for each.
66

77
</p>
88

99
!!! summary "You will learn"
1010

1111
- Which channels actually exist, and the one direction that is not supported
1212
- How to push a server-side value into a view without the client asking
13-
- What an event payload really contains — including why file uploads do not work the way people expect
13+
- What an event actually gives your handler — including why file uploads do not work the way people expect
1414
- How to get real file bytes across, and how to keep blocking code off the loop
1515

1616
## The channels that exist
@@ -19,9 +19,12 @@ ReactPy connects a Python process and a browser tab with one websocket and one m
1919
| --- | --- | --- |
2020
| server → client | the rendered model, sent as `#!python layout-update` patches | total — return it from the component |
2121
| client → server | an event handler, receiving `#!python layout-event` | total — read `#!python event` |
22+
| server → client, on demand | `#!python event-proxy-request` / `#!python event-proxy-response`, driven by `#!python await` on an event | indirect — you ask by awaiting, ReactPy frames the message |
2223
| server → client, arbitrary message | **none** | not supported |
2324
| shared mutable state | **none** | the two sides hold different objects |
2425

26+
The third row is new, and it is narrower than it looks: the request row exists because a handler *pulled*, on the back of an event the client chose to send. A read carries no application data and starts no conversation — see [Protocol Structure](../reference/protocol-structure.md#the-event-proxy-round-trip). Calling a method is the exception which proves the rule: `#!python await event.getCoalescedEvents()` ships your arguments down as JSON (`_event_proxy.py`, `_begin`), which is data travelling the direction the table says does not exist. Reads are the default; a call is a door you have to choose to open.
27+
2528
The server-to-client push row is the one people look for first, and its absence is a design choice, not an omission. A component's `#!python Connection` has no `#!python send`, no `#!python deliver`, no `#!python close` ([User Connection](../reference/use-connection.md)), and the backend is the only code allowed to write to the socket. That keeps the model the single source of truth: if a component could scribble on the wire, the client's tree and the server's model would drift apart with nothing to reconcile them.
2629

2730
!!! warning "A hand-written message is dropped by the client, silently"
@@ -32,7 +35,7 @@ The server-to-client push row is the one people look for first, and its absence
3235
[ReactPy] Received message without handler Object
3336
```
3437

35-
and discards it. The client dispatches on `#!python message.type` against a fixed registry — `#!python "layout-update"` is the only one registered by the mount path — and an unregistered type warns and returns. Nothing throws, the socket stays up, and the UI never changes. The client bundle exports exactly one function and offers no `#!js onMessage` hook to register against, so there is no supported way to add a handler. Do not build on this path; use one of the routes below.
38+
and discards it. The client dispatches on `#!python message.type` against a fixed registry — `#!python "layout-update"` is the only one registered by the mount path — and an unregistered type warns and returns. Nothing throws, the socket stays up, and the UI never changes. The page's only entry point is `mountReactPy`, and nothing in that path lets you register a handler of your own: `#!js onMessage` exists on the client classes, but a page built by `mountReactPy` never hands you the instance ([The client object](../reference/client-api.md#the-client-object)). Do not build on this path; use one of the routes below.
3639

3740
## Down: push a value the client never asked for
3841

@@ -62,38 +65,43 @@ Polling is fine for a demo and wrong at scale. For real work, prefer a server-se
6265

6366
## Up: read what the client sends
6467

65-
An event handler receives a `#!python dict`, not a live event — the browser serialized it and your code is now on the other side of a network boundary. The client walks the target and copies enumerable properties, plus `#!python value`, `#!python checked`, `#!python files`, `#!python type`, `#!python name` read off the element even though they are accessors, plus `#!python dataset`, plus — on `#!python submit` — the form's named controls. Functions, DOM nodes, and cycles are skipped rather than raising.
66-
67-
Two consequences to internalize:
68+
An event handler receives a live proxy onto the browser's event, not a copy of it. The client keeps the object and hands the server a handle; each `#!python await` on a property is a question asked of the page at that moment (`event-proxy.ts`, `serializeValue`). Nothing about the DOM event arrives unless you ask for it, which is the whole point: an `#!html <input>` used to arrive as the entire `#!python HTMLInputElement` — a measured `#!python onChange` payload carried roughly ninety keys, `#!python accept`, `#!python formAction`, `#!python validity`, and the rest — of which a handler reads `#!python value`.
6869

69-
**An `#!html <input>` arrives as the whole element.** A measured `#!python onChange` payload carried roughly ninety keys — `#!python accept`, `#!python formAction`, `#!python validity`, `#!python validationMessage`, and the rest of `#!python HTMLInputElement` — of which you read `#!python value`. Index what you need; do not count on a specific key surviving, and do not iterate the payload assuming it is small.
70+
What a read gives back is decided by the client. Plain data comes across as itself. A method comes back as a reference, so `await event.target.focus()` is a call the client makes rather than a function you receive. An object too deep, too large, or recursive comes back as a reference too, and you reach further with more `#!python await`s. Blocked members — `#!python window`, `#!python document`, `#!python parentNode`, `#!python eval`, `#!python setTimeout` — are refused outright, so a read cannot be used to walk out of the event and into the page.
7071

71-
**On `#!python submit`, `#!python event["target"]` is a *list*, not a dict.** The named controls are serialized as list entries, so the read shape differs from every other event:
72+
**On `#!python submit`, the target is the form, and the thing you want is one level down.** `#!python HTMLFormControlsCollection` — what `#!python form.elements` holds — is iterable, and the client hands over anything iterable as a list (`serializeValue`), so `await event.target.elements` answers with one entry per control, named or not, each a plain dict of a hundred properties. The form itself is iterable too, so `await event.target` gives you the same controls; `#!python .elements` is just the name which says what you mean:
7273

7374
```python linenums="0"
74-
def handle_submit(event):
75+
async def handle_submit(event):
7576
event.preventDefault()
76-
controls = event["target"] # list of control dicts
77-
payload = next((c["value"] for c in controls if c.get("name") == "payload"), "")
77+
controls = await event.target.elements # list of control dicts
78+
payload = next(
79+
(c["value"] for c in controls if c.get("name") == "payload"), ""
80+
)
7881
```
7982

80-
This is verified: submitting a form with a hidden `#!python name="payload"` input produced `[{"name": "", "type": "file", ...}, {"name": "payload", "type": "hidden", "value": "hello world\n"}, {"name": "", "type": "submit", ...}]`. The full working example is in [Files](#files-what-actually-crosses-the-wire) below. The protocol behind this is documented in [Protocol Structure](../reference/protocol-structure.md).
83+
Note what makes that readable: the list arrives as data, so `c["value"]` is a plain subscript on the answer. The proxy itself is not subscriptable, but the JSON the client sent back is an ordinary `#!python list` of `#!python dict`s. The nested back-references inside each control (`#!python c["form"]`, which points at the form again) come through as references rather than copies. The full working example is in [Files](#files-what-actually-crosses-the-wire) below, and the wire format is in [Protocol Structure](../reference/protocol-structure.md).
84+
85+
Because each read is a round trip, how you write them costs time. Awaiting two properties one after another is two trips; `#!python await event.read("target.value", "key")` or an `#!python asyncio.gather` of them is one. A read you never make costs nothing at all.
8186

8287
## Files: what actually crosses the wire
8388

84-
A file input does not send file contents. It sends file **metadata**. Verified with a real file chooser against a real browser — this is the entire `#!python files` entry that arrived:
89+
A file input does not send file contents. It sends file **metadata**. Re-verified in Chrome against a real `#!html <input type="file">` — this is the entire `#!python files` entry that arrived for a 12-byte `#!python upload.txt`:
8590

8691
```python linenums="0"
87-
{
88-
"name": "upload.txt",
89-
"lastModified": 1791523914968,
90-
"lastModifiedDate": {},
91-
"webkitRelativePath": "",
92-
"type": "text/plain",
93-
}
92+
[
93+
{
94+
"name": "upload.txt",
95+
"lastModified": 1792302335371,
96+
"lastModifiedDate": "2026-10-18T05:45:35.371Z",
97+
"webkitRelativePath": "",
98+
"size": 12,
99+
"type": "text/plain",
100+
}
101+
]
94102
```
95103

96-
No bytes. `#!python File` exposes its content through methods, which are functions, and functions are skipped by the walk. There is no `#!python FormData` in the client and no chunked upload path. If you expected `#!python event["target"]["files"][0]["content"]`, it does not exist and never will from this mechanism.
104+
No bytes. `#!python File` exposes its content through methods, and the client hands methods over as references rather than values, so there is nothing to read. There is no `#!python FormData` in the client and no chunked upload path. If you expected `#!python (await event.target.files)[0]["content"]`, it does not exist and never will from this mechanism.
97105

98106
### Reading real bytes
99107

@@ -169,7 +177,7 @@ Verified: the first patch says `#!python rendering...`, the next says `#!python
169177

170178
## Client-only data
171179

172-
The mirror problem: something in the browser that Python cannot see. Computed style, scroll position, canvas pixels, `#!python localStorage`, geolocation, the clipboard — none of it is in the payload, because the walk copies data properties and skips everything callable.
180+
The mirror problem: something in the browser that Python cannot see. The proxy narrowed this considerably — anything reachable from the event object is now readable, so `#!python await event.target.scrollTop`, `#!python await event.target.className`, or a value a canvas put on its own element are ordinary awaits. What is still out of reach is anything the page will not hand over from that starting point: computed style of an element you never got an event from, `#!python localStorage`, geolocation, the clipboard. The client refuses the paths which would lead there anyway (`#!python window`, `#!python document`, `#!python localStorage`), so a handler cannot walk off the event and into the page — see [Protocol Structure](../reference/protocol-structure.md#the-event-proxy-round-trip).
173181

174182
Your options, in order of preference:
175183

@@ -184,14 +192,14 @@ There is no `#!python use_ref`-style handle to a DOM node, and `#!python {"ref":
184192

185193
- Only two channels exist: the model down, events up. An arbitrary server→client message is logged as `#!python Received message without handler` and dropped.
186194
- Server push means `#!python use_async_effect` plus a state setter — loop, handle `#!python CancelledError`, and remember the dependency array infers by default.
187-
- Event payloads are serialized snapshots: an input arrives as the whole `#!python HTMLInputElement`, and `#!python submit` gives you a **list** of named controls.
195+
- Events are live proxies, not snapshots: each `#!python await` is a round trip to the browser, a read you never make costs nothing, and `#!python submit` hands back the form as a **list** of controls.
188196
- File inputs send metadata only, never bytes. Read the file in the browser, put the text in a named hidden input, and submit.
189197
- Use `#!python asyncio.to_thread` for blocking work. Calling a setter from a raw thread fails with `#!python no running event loop` and no visible symptom — marshal with `#!python call_soon_threadsafe`.
190198
- State never lives on the component function object; that is a leak across connections.
191199

192200
## Related
193201

194-
- [Protocol Structure](../reference/protocol-structure.md) — both message types, `#!python target`, and `#!python seq`
202+
- [Protocol Structure](../reference/protocol-structure.md) — the message types, `#!python target`, and `#!python seq`
195203
- [Use Async Effect](../reference/use-async-effect.md) and [Use Effect](../reference/use-effect.md) — the push mechanism, and its traps
196204
- [Creating VDOM Event Handlers](./creating-vdom-event-handlers.md) — payload shape, inline JavaScript, bytecode detection
197205
- [User Connection](../reference/use-connection.md) — the read-only connection view

‎docs/src/learn/creating-backends.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ Strip away the executors and there are exactly four responsibilities. Everything
2222
3. **Return a document** containing a mount element and a `#!js mountReactPy(...)` call.
2323
4. **Accept a websocket** and run one `#!python Layout` against it, pumping messages in both directions.
2424

25-
The whole of the protocol is two message types. A rendered update goes down as `#!python {"type": "layout-update", "path": ..., "model": ...}`; an event comes up as `#!python {"type": "layout-event", "target": ..., "data": [...]}`. [Protocol Structure](../reference/protocol-structure.md) documents them in full — this page is about plumbing, not the schema.
25+
The protocol is four message types. A rendered update goes down as `#!python {"type": "layout-update", "path": ..., "model": ...}`; an event comes up as `#!python {"type": "layout-event", "target": ..., "data": [...]}`; and when a handler reads a property of that event, the request goes down as `#!python {"type": "event-proxy-request", ...}` and the browser's answer comes up as `#!python {"type": "event-proxy-response", ...}`. [Protocol Structure](../reference/protocol-structure.md) documents them in full — this page is about plumbing, not the schema.
26+
27+
The good news about the last pair: `serve_layout` wires both directions for you. It binds your `send` as the proxy sender, and `Layout.deliver` recognises a response and routes it to the proxy backend instead of treating it as an event. A backend which pumps every received frame into `deliver` and every message out through `send` supports live event access without knowing it exists. A backend that filters frames before `deliver` — the way the ASGI middleware does, matching on `#!python "layout-event"` first — must route the responses itself or every `#!python await` on an event stalls until it times out.
2628

2729
## The job, in one file
2830

@@ -202,7 +204,8 @@ What it does **not** buy you: performance. `serve_layout` in your own code is th
202204
## Summary
203205

204206
- A backend serves the bundle, serves web modules, returns a mount document, and runs one `#!python Layout` per websocket.
205-
- The message shapes are fixed: `#!python layout-update` down, `#!python layout-event` up.
207+
- The message shapes are fixed: `#!python layout-update` and `#!python event-proxy-request` down, `#!python layout-event` and `#!python event-proxy-response` up.
208+
- Feed every received frame to `layout.deliver` and `serve_layout` routes event-proxy responses for you; filter frames yourself and you must route them yourself.
206209
- Skip the `#!python websocket.connect` event in your pump; it is the most common cause of an instantly-dying socket.
207210
- Speak raw ASGI on the socket; framework websocket wrappers do not have the methods `serve_layout` needs.
208211
- Build the `#!python Layout` per connection, and wrap the component in `#!python ConnectionContext` or the connection hooks raise.
@@ -211,7 +214,7 @@ What it does **not** buy you: performance. `serve_layout` in your own code is th
211214

212215
## Related
213216

214-
- [Protocol Structure](../reference/protocol-structure.md) — the two message types, `#!python target`, and `#!python seq`
217+
- [Protocol Structure](../reference/protocol-structure.md) — all four message types, `#!python target`, `#!python seq`, and the event proxy round trip
215218
- [Client API](../reference/client-api.md) — what `#!js mountReactPy` expects from your URL layout
216219
- [ReactPy](../reference/reactpy.md) and [ReactPyMiddleware](../reference/reactpy-middleware.md) — the two built-in backends
217220
- [User Connection](../reference/use-connection.md), [User Scope](../reference/use-scope.md), [User Location](../reference/use-location.md) — what your `Connection` feeds

0 commit comments

Comments
 (0)