You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 67652fa
Browse filesBrowse the repository at this point in the historyBrowse files
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
Copy file name to clipboardExpand all lines: docs/src/about/docs.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -55,7 +55,7 @@ Things that do not survive a rename:
55
55
56
56
!!! warning "Pitfall"
57
57
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.
Copy file name to clipboardExpand all lines: docs/src/learn/communicate-data-between-server-and-client.md
+32-24Lines changed: 32 additions & 24 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2,15 +2,15 @@
2
2
3
3
<pclass="intro"markdown>
4
4
5
-
ReactPy connects a Python process and a browser tab with one websocketand 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.
6
6
7
7
</p>
8
8
9
9
!!! summary "You will learn"
10
10
11
11
- Which channels actually exist, and the one direction that is not supported
12
12
- 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
14
14
- How to get real file bytes across, and how to keep blocking code off the loop
15
15
16
16
## The channels that exist
@@ -19,9 +19,12 @@ ReactPy connects a Python process and a browser tab with one websocket and one m
19
19
| --- | --- | --- |
20
20
| server → client | the rendered model, sent as `#!python layout-update` patches | total — return it from the component |
21
21
| 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 |
22
23
| server → client, arbitrary message |**none**| not supported |
23
24
| shared mutable state |**none**| the two sides hold different objects |
24
25
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
+
25
28
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.
26
29
27
30
!!! 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
32
35
[ReactPy] Received message without handler Object
33
36
```
34
37
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.
36
39
37
40
## Down: push a value the client never asked for
38
41
@@ -62,38 +65,43 @@ Polling is fine for a demo and wrong at scale. For real work, prefer a server-se
62
65
63
66
## Up: read what the client sends
64
67
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`.
68
69
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.
70
71
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:
72
73
73
74
```python linenums="0"
74
-
defhandle_submit(event):
75
+
asyncdefhandle_submit(event):
75
76
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
+
)
78
81
```
79
82
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.
81
86
82
87
## Files: what actually crosses the wire
83
88
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`:
85
90
86
91
```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
+
]
94
102
```
95
103
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.
97
105
98
106
### Reading real bytes
99
107
@@ -169,7 +177,7 @@ Verified: the first patch says `#!python rendering...`, the next says `#!python
169
177
170
178
## Client-only data
171
179
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).
173
181
174
182
Your options, in order of preference:
175
183
@@ -184,14 +192,14 @@ There is no `#!python use_ref`-style handle to a DOM node, and `#!python {"ref":
184
192
185
193
- Only two channels exist: the model down, events up. An arbitrary server→client message is logged as `#!python Received message without handler` and dropped.
186
194
- 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.
188
196
- File inputs send metadata only, never bytes. Read the file in the browser, put the text in a named hidden input, and submit.
189
197
- 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`.
190
198
- State never lives on the component function object; that is a leak across connections.
191
199
192
200
## Related
193
201
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`
195
203
-[Use Async Effect](../reference/use-async-effect.md) and [Use Effect](../reference/use-effect.md) — the push mechanism, and its traps
Copy file name to clipboardExpand all lines: docs/src/learn/creating-backends.md
+6-3Lines changed: 6 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -22,7 +22,9 @@ Strip away the executors and there are exactly four responsibilities. Everything
22
22
3.**Return a document** containing a mount element and a `#!js mountReactPy(...)` call.
23
23
4.**Accept a websocket** and run one `#!python Layout` against it, pumping messages in both directions.
24
24
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.
26
28
27
29
## The job, in one file
28
30
@@ -202,7 +204,8 @@ What it does **not** buy you: performance. `serve_layout` in your own code is th
202
204
## Summary
203
205
204
206
- 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.
206
209
- Skip the `#!python websocket.connect` event in your pump; it is the most common cause of an instantly-dying socket.
207
210
- Speak raw ASGI on the socket; framework websocket wrappers do not have the methods `serve_layout` needs.
208
211
- 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
211
214
212
215
## Related
213
216
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
215
218
-[Client API](../reference/client-api.md) — what `#!js mountReactPy` expects from your URL layout
216
219
-[ReactPy](../reference/reactpy.md) and [ReactPyMiddleware](../reference/reactpy-middleware.md) — the two built-in backends
217
220
-[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