Repository navigation
Add live event proxy for accessing browser event data - #1352
Merged
Merged
Conversation
With live access enabled by default every dispatched object was registered with the client, which made even plain synchronous handlers block on the browser. Those handlers are therefore run in a worker thread, where calling set_state() fails with 'no running event loop' because scheduling a render needs the layout's loop. Default the option to off so existing apps keep the plain snapshot behaviour, and enable live access per handler or globally.
The acknowledgement which tells an input that the server has caught up with the keyboard was kept as a single figure per element, and recorded before the handler which consumed the event had finished with it. A render made while a handler was still running could therefore look up to date, and the value it carried - a snapshot of the keystroke before the one the user has just made - was written back over what they had typed. The figure is now per event, since an element bound to more than one event cannot be described by one number: the keystrokes of one event would otherwise make a render which predates the newest keystroke of another look current. It is recorded only once the handler is out of the way, and a request to deliver an acknowledgement is dropped when the client already holds it, when a render already queued will carry it, or when the element is not one which waits on an acknowledgement at all - prompting a render for an ordinary button click spent a round trip on a figure nobody was waiting for, and broke the rule that setting state to an equal value re-renders nothing. Asking for a render as teardown begins is now refused outright. Tasks cancelled on the way out hand control back to the event loop, which is exactly when a handler finishing its business gets its turn, and a render task created then survives the loop over the tasks teardown knows about - only to reach, after everything has been deleted, for the handlers it renders with. Live access to the object behind an event is asked for per handler with EventHandler(fn, event_proxy=True) or @event(event_proxy=True), or globally with REACTPY_ENABLE_EVENT_PROXY. The preference reaches the client as eventProxy in the handler spec, and the client registers only what the server said it would read from, so dispatching an event nobody intends to look behind costs the page nothing to remember. Handlers which do not ask are handed the plain Event snapshot, and the field the protocol uses to carry the acknowledgement is stripped before props are handed to Preact, so it never reaches the DOM.
A return from a finally block swallows whatever exception is unwinding through it, so the check for a layout which has begun closing wraps the recording of an event's sequence number rather than cutting it short. The acknowledgement which tells an input the server has caught up is not a DOM attribute and has no business in the attribute types, so the one line which writes it into the rendered model says so out loud.
The two guards which refuse to schedule on behalf of a layout that has begun closing, and the walk which stops when nothing remains above an element, had no test that reached them - and the project asks that every line be answered for. Each is now asked about directly: a closed layout is asked for an acknowledgement and a render and refuses both without touching what teardown released, and an element whose parent has gone is asked which component owns it and is told that none does.
Both of these waited on how many requests had gone out and then asserted on what the fake client had done with them, which happens a turn later. Windows' coarser loop clock lost that race on every one of the ten attempts CI allows. The fake client now answers a request with no deliberate delay inside the same turn of the loop, and each test waits for the very calls it goes on to assert, so neither depends on how the loop hands out its turns. An unused noqa and some drift in two unrelated test files are tidied away by the same formatter the checks run.
Reading what an event fired from means asking the browser for it, and only a coroutine can await that round trip without stalling the layout. Handlers which were plain functions had to run in a worker thread to keep the loop free, which is the thread where calling set_state() fails with 'no running event loop', and supporting them meant carrying two shapes of event data - a snapshot for one, a live proxy for the other - through the protocol, the types, and the docs. There is now a single shape: the Event snapshot which arrived with the event, with an EventProxy in front of whatever the handler asks to read past it. Anything which is not a coroutine is refused with a TypeError as it is made into a handler, saying why in terms of the round trip it would have to block on. REACTPY_ENABLE_EVENT_PROXY therefore defaults to on, since a coroutine can afford to wait on the browser and a handler which never reads past the snapshot never sends a request in the first place. A handler which knows it will not look behind the event can decline with EventHandler(fn, event_proxy=False), and one which must have the live object can insist with event_proxy=True even while the option is off. What a handler wants is read off its bytecode, and a callable object keeps that bytecode on its class's __call__ rather than on the instance, so the handler check and the inspector both look there now. That is likewise how a partial which wraps such an object is still found to carry a debounce.
Every example which bound a callback wrote it as a lambda, and a lambda cannot be awaited. They are named coroutines now, which is also the shape the guides tell people to write.
The coverage job draws the line at one hundred percent, and the switch to coroutine-only handlers left seven lines on the other side of it. Two belong to reading a handler's bytecode. An object's ``__call__`` is only absent from its class if a metaclass went out of its way to hide it, so that branch is marked off rather than prodded for its own sake. The case a program can actually reach - an object made callable by a built-in, which carries no bytecode at all - is tested for what it is worth, which is that the inspector has nothing to find. The rest were a proxy with nothing behind it. What it says about itself, the attribute nobody can answer for, and the value its snapshot does hold all went unexercised, and so did the one path out of ``_submit`` which is not the loop's own thread. A call made from a worker now has to reach the browser by way of the loop which owns the connection, which is what that thread check is there for in the first place. The typing tests which timed out on a loaded runner wait longer, and the page which churns while the user types churns at a hundred renders a second rather than two hundred. Its render count is polled alongside the field and the model, so the next timeout says whether renders were reaching the page at all - the distinction which decides whether a stuck field is a stalled layout or merely a slow machine.
The guides still described a synchronous handler as the common case and an asynchronous one as the interesting exception - "so long as the event handler is synchronous ... ReactPy waits until all the code in an event handler has run", "by defining your event handler as an asynchronous function instead of a normal synchronous one". There is no synchronous handler left to describe, and what those passages were really getting at is when a render is allowed to start, which turns on whether a handler has yielded rather than whether it was written with a ``def``. The wording is in those terms now, and the couple of snippets which still bound ``def handle_click`` are coroutines like the example they mirror. The changelog was left describing the pair of event types this branch did away with. It now describes the one snapshot and the one proxy, and says plainly in the Changed section that a handler which cannot be awaited is refused.
The example which passes a handler to a component as an argument bound it as ``on_click=handle_click`` rather than in an attributes dict, so it slipped past the pass which converted the rest of them - and it would have been refused at render time.
This change is about the event proxy, not the docs. Every file under docs/ goes back to how it looks on main, so the docs can be updated separately once the move away from Sphinx is done.
`inspect_event_handler` already reads `event.debounce = <milliseconds>` out of the bytecode of the handler a user wrote, so `event.eventProxy = False` is now read the same way. A handler which knows it will not look behind the event can say so itself instead of everyone having to wrap it in `EventHandler(fn, event_proxy=False)`, and one which must have live access can insist with `event.eventProxy = True`. What the handler says about itself wins over what was set on it, matching how `debounce` already behaves. An assignment whose value cannot be settled from the bytecode alone - a local variable, or a branch which may not run - leaves the handler without an opinion rather than a guessed one.
Archmonger
marked this pull request as ready for review
October 11, 2026 23:17
Live access is what a handler gets unless it has refused it for itself, on the handler or from inside its own body, so there is nothing left for a global option to say. The per-handler event_proxy preference is the only knob.
Saying nothing wants live access just as much as saying so does now there is no global switch, so one handler declining cannot take it away from a handler merged alongside it which never refused. The merged handler refuses only when every one of them did.
An event no longer arrives with the browser's data pre-packaged. The client keeps hold of the object it dispatched and sends only a marker carrying its id, alongside the one plain value too cheap, and too useful for telling a handler what it is holding, to withhold: the event "type". A click now costs 154 bytes on the wire where a snapshot cost 32,666. Everything else is read when it is asked for. Every proxy is awaitable, so `await event.target.value` names the same property chain in Python as it does in JavaScript, the only difference being the await which fetches it from the browser. Reads are cached against the event, a read already in flight is shared rather than duplicated, and reads made together leave as one batched message. `read()` takes any number of paths and answers with a tuple of the same length. The opt-outs have gone with the snapshot they existed to skip. There is no `event_proxy` on a handler and no `eventProxy` on the model, because there is no longer any eager work to decline, and the event is no longer a dict - reading it means awaiting a proxy, which is the only way to keep a handler free to set state while a value is on its way from the page. What a read reports belongs to the moment it is awaited rather than to the event which woke the handler. A handler still queued behind another event's work sees the element as it stands by then, not as it was, exactly as the same expression would in JavaScript. Tests which pinned the dispatch-time ordering now say what still holds, and one covers the new behaviour directly. The answers also had nowhere to go. The ASGI middleware recognised `layout-event` alone and logged anything else as an unknown message type, so a reply was answered into the void while the handler waited out its timeout - invisible to every test which did not drive a real browser. The websocket now holds the layout and hands responses to it directly, on the loop which is waiting for them.
The coverage runner holds the suite to every line, and the rewrite to live access left nine of them unexecuted. Two of those are the interesting ones: the paths where a read finds another of the same property already on its way and joins it instead of asking the browser twice. Nothing had driven that, so it is now driven twice over - once through a lone read racing a gather, once through a batch which adopts the read a lone await started first - and each test says what the sharing saves rather than merely that it happens. The rest are the flush which reaches for a sender that the connection no longer has, the debounce a handler sets on itself and reads back, and the truthiness of a proxy whose value is still unknown. An answer arriving outside the life of a layout has no request to satisfy, because the backend belongs to the running layout. It is refused rather than raising: the connection cannot know the difference between early and late, and should not be blamed for the timing. The websocket hands answers to the layout directly, on the loop which is waiting for them. EventProxy._reactpy_description had no caller - a proxy describes itself elsewhere - so it is gone.
A batch of live reads gave up the moment one of its paths raised. Whatever the client had answered in that same message was thrown away with it, so the next read of those properties paid again for a round trip the browser had already carried out - and when one path timed out along the way, the timeout was raised even where the client had gone on to explain that another property simply does not exist. Answers which arrived are now cached even when a neighbour fails, and the failure which says something about the data is the one which reaches the handler rather than the one which only says the client was slow.
Whether the client still answers at all is what the timeout budget is meant to measure, but every request of a batch which went silent raised its own timeout and every one of those was counted. A handler which read four properties of an event at one time therefore exhausted a budget of three all by itself, and switched live access off for a client which had merely been asked several things in one message. Requests are now stamped with the round trip which carried them - one send of a batch, or one answer which came back - and an unanswered one costs a single strike however many properties it was after. Answering anything still resets the budget, so a client which is slow on one path is no longer mistaken for a client which has gone away.
Being switched off was a statement about the connection which was bound at the time - that the client behind it stopped answering - but it used to last for the life of the layout. A single unanswered message, a page which was reloaded or a socket which was quietly replaced, left every handler which came after it reading nothing but ``EventProxyUnavailable`` while a perfectly willing browser sat on the other end of the new connection. Binding a sender which is not the one already in place, which is what a reconnect does, now clears the switch and the timeout budget along with it. Binding the very same sender again does not, and neither does anything at all once the layout has closed.
The ``defaultPrevented`` property promised to be readable with ``await event.defaultPrevented``, which is not something a property which hands back a plain ``bool`` can do - the sentence sent handlers straight to a ``TypeError``. What the property answers is what Python asked the browser to do, and it still answers it synchronously. The docstring now says so and points at the read which answers the other question - what the browser settled on, which other scripts on the page may have had a say in.
Arguments for a live call were walked recursively on their way out with no bound at all, so a value which went deep enough raised ``RecursionError`` from inside the call. That is not a failure a handler can read, and not one it can recover from by asking for something shallower instead. Arguments are now bounded by the depth reading already uses, and refused with the same kind of error the rest of live access raises.
The docstring which shows how to ask the browser what it prevented wrote its single element unpacking the way Python lets you, and the formatter wants the parentheses. Nothing about the behaviour changed.
A handler which was stopped while it was reading - replaced by a re-render, or gone with a component which unmounted - left its request recorded as though somebody still cared, waiting on an answer with nowhere to go and nothing to reclaim it. Nor was there any bound at all on how many requests one connection could have waiting on the client, which only mattered once the client stopped answering. A request is now released as soon as whoever was waiting on it has gone. A client which is being asked beyond a limit is refused in a way a handler can read, rather than buried under requests it will never get round to.
A resolved property is remembered against its event, which is what makes a handler
which reads one property twice cost nothing. It also means the second read hands back
the first answer however much the page has moved on since, and there was nothing a
handler could do about it.
The caching is now spelled out where handlers read about reading, and a read which
wants what the browser holds by now can say so - ``await event.read("target.value",
refresh=True)`` or ``await event.target.value.resolve(refresh=True)`` - which pays for
its own round trip and replaces what was remembered.
Serializing a live value bounded how many properties of an object came over, and how many items of an iterable - arrays were the one collection with no bound at all, so an event which happened to carry a long one handed the whole of it over in a single message. Whatever the iterable bound did cut off simply vanished, with nothing to say there had been more. One bound now covers properties, list items, and the items of anything which iterates, taken from the same option. What did not fit is pointed at rather than dropped, so the tail of an over-long list comes back as a reference to it and a handler can tell "that is all of it" from "there is more over there".
Objects are registered so that the server can keep reading them, and nothing else is coming to take them away. Pruning only happened when a new event was dispatched, so a page which went quiet held every object it had ever handed over - detached nodes included - for the whole of its idle time. The registry is now swept whenever the server asks it anything, not only when something is added to it. An object which is being read is not idle, and is kept as before.
Three bounds were missing on the way in. An argument of a call was walked as deep as it happened to be, so the shape of an argument was the server's to choose and the page's to survive. The depth a request asked for was obeyed without being looked at, which left "negative", "not a number", and "one hundred thousand" all as ways past the bound meant to protect the page. And a batched message was carried out however long its list was, spending the page's one turn on an arbitrarily large number of paths. Arguments are now held to the same depth a read is, that depth is read as a suggestion with a ceiling of its own, and whatever a message asks beyond the bound is refused - with an answer, so the server is not left timing out on a client which is wide awake.
A reference which came out of the result of a method call named a place inside that result, and there was nowhere to resolve it: the path was walked again from the event, landed on the method, and was told the property did not exist. The server was left holding a reference to something the page could never reach. An object now keeps what each path called on it answered, for as long as the event itself is kept and up to a small number of the most recent calls. Walking a path which leads through a method steps through that answer rather than the function, and the method is not run again to get it - it may have side effects, and by then it may not even be there.
Three things the live event proxy promised were no longer the whole story: what the timeout budget counts (unanswered messages, not properties), what the depth option bounds (arguments on the way in as well as data on the way out), and what happens when a client which went quiet comes back.
The count bound popped whatever had been dispatched first, so a handler which kept one event object open across an await could come back and find the page had dropped it while unrelated keystrokes piled up behind it. A read now moves an entry to the live end of the queue, so what goes when the registry is full is the object which has gone longest without being asked about. Which bound fired is worth saying out loud. An object which went idle unread answers "not-found" as before, while one dropped to make room answers "evicted" and raises EventProxyEvicted here - still an EventProxyNotFound, so a handler which does not care keeps working. Age takes only the objects the server never asked about, and the sweep runs after a message has been carried out rather than before it. Carrying one out is the proof an object is wanted, so sweeping first used to delete the very object a request had arrived for, and answer a handler which was still working that the page had gone away on its own. Dispatching an object which is already registered now gives back the id the server holds rather than a second copy of the same thing, and DEFAULT_MAX_DEPTH is gone: serializeValue states its own bound, and a request which does not say how deep it may go gets a reference rather than a depth somebody guessed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Adds a live event proxy: an event handler can read properties from, and call methods on, the browser objects which triggered the event —
event.target.value,event.target.focus(),event.target.getBoundingClientRect()— rather than being limited to whatever was JSON-serializable at the moment the event was dispatched.Nothing about the event object is sent to the server any more. The client keeps the object alive against an id and sends only that id (plus the event's
type); a handler which wants more asks the browser for it, one round trip at a time:The property chain is spelled the same way in Python and in JavaScript. The only difference is the
await, because the value lives in the browser rather than in this process. Reads are cached against their event, and properties which are in flight at the same moment go out as a single message — soawait event.read("target.value", "key")andawait asyncio.gather(event.target.value, event.key)each cost one round trip, while twoawaits written one after the other cannot be batched (the second is not known until the first lands).Breaking changes
defraisesTypeErrorwhen it is wrapped byEventHandler/@event. A callable object whose__call__is a coroutine counts as a handler, and so does apartialwrapping one. Reaching past what the client volunteers means waiting on the browser, and only a coroutine can do that without blocking the loop — a blocking read would have had to run off-loop, which is exactly the thread whereset_state()fails withno running event loop.eventis no longer a dict.reactpy.types.Event(the oldclass Event(dict)with.get()/.items()/ its ownpreventDefault()) is gone, replaced by the awaitableEventProxyandEvent(EventProxy)inreactpy._event_proxy.event["target"]["value"]no longer works;await event.target.valueorawait event.read("target.value")do.reactpy.typesre-exportsEventandEventProxy, and adds theEventObjectalias and theEventHandlerFuncprotocol which handlers are typed against.event-to-objectis deleted. The package, its tests, its build script, its hatch env, and its NPM publish job all go: the client no longer serializes events at dispatch, so nothing imports it any more.What travels over the wire
A dispatched click is now:
{ "type": "layout-event", "target": "key-path:onClick", "data": [{ "__reactpy_event__": { "id": "5b1c..." }, "type": "click" }], "seq": 3 }The
typeis the one value volunteered up front — small, and too useful for telling a handler what kind of event it is holding to withhold. In measurements against the browser fixtures the body of a click went from ~32 KB of serialized snapshot to ~150 B.Resolution adds two message types,
event-proxy-request(requestId/eventId/path/call/args/reply/maxDepth) andevent-proxy-response, both of which travel batched asrequests/responses. A value which cannot be JSON-ified (a DOM node, a function, ...) comes back as{"__reactpy_ref__": {"path": [...]}}and becomes a proxy itself, so arbitrarily deep structures can be walked on demand.Server side
src/reactpy/_event_proxy.pyholds the mechanism:EventProxyBackend(request/reply routing, a per-event cache, in-flight deduplication, batching, timeouts, and disabling),EventProxy(awaitable,__getattr__for property access,__call__for method calls,read()for batched reads),Event(EventProxy), andbuild_event_args()/EventArguments()which turn what a client dispatched into the arguments a handler is handed. Failures areEventProxyErrorand itsUnavailable/Timeout/NotFound/AccessErrorsubclasses.Layout.set_event_proxy_sender()andLayout.handle_event_proxy_response()route requests out to the client and answers back to the layout;core/serve.pywires the sender to the connection's own send path.run_coroutine_threadsafe()when a handler thought of it on another thread. A handler which never reads past what it was handed never sends a request in the first place.core/_event_inspect.py):event.preventDefault(),event.stopPropagation(),event.stopImmediatePropagation()(new), andevent.debounce = <milliseconds>.executors/asgi/middleware.py:ReactPyWebsocketnow keeps hold of theLayoutit is rendering and handsevent-proxy-responsemessages to it, on the loop which owns that layout. Before this, those answers fell into theelse: # nocovbranch and were logged as unknown message types, so every live read hung until it timed out. Unit tests could not see this, because they drivedeliver()directly and bypass the middleware; the browser test suite is what caught it.REACTPY_EVENT_PROXY_TIMEOUT,REACTPY_EVENT_PROXY_MAX_TIMEOUTS, andREACTPY_EVENT_PROXY_MAX_DEPTH. All three bound what an unanswered request may cost; none of them decide whether live access happens. There is deliberately no global switch for it — one setting for a whole server would be deciding for handlers it has never seen, and a handler which does not read past what it was handed costs nothing to leave on.Client side
src/js/packages/@reactpy/client/src/event-proxy.tskeeps a registry of recently dispatched objects and answers requests against the object itself. It is bounded:MAX_LIVE_OBJECTS(512),LIVE_OBJECT_TTL_MS(60s),MAX_PROPERTIES(100 per object),DEFAULT_MAX_DEPTH(10).client.tsanswersevent-proxy-requestfor every client, without waiting on anybody having registered a handler for the message type./^on[a-z]/iplus an explicit blocklist, so handler-like names (including camelCase ones such asonPointerDown) are never reflected back to the server. This deliberately over-blocks a few benign names (only,one,once), which the tests pin down.A read belongs to the moment it is awaited
await event.target.valuereports what the element holds now, just as the same expression would in JavaScript — not what it held when the keystroke fired. A handler still queued behind another event's work therefore sees whatever that element has become by the time it runs. Two controlled-input tests had to change because of this, and one was added to pin the behaviour down. A handler which must act on the value as its own event saw it has to read it before anything else is given the chance to change it. This is the trade made for dropping the snapshot, and it is the semantic callers most need to be told about.Controlled inputs, and the acknowledgement which keeps them honest
An input writes the server's value over what the user has typed only once the server has acknowledged every event sent from it. That acknowledgement used to be one figure per element, recorded before the handler which consumed the event had finished with it, which let a render made while a handler was still running look current and write a snapshot of the previous keystroke back over the field.
_reactpy_ack_seq), since an element bound to more than one event cannot honestly be described by a single number._reactpy_mark_sent(_reactpy_peek_seq/_reactpy_set_seqkeep the two counters on one number line)._reactpy_ack_seqis stripped before props reach Preact, so the protocol field never lands in the DOM.Also fixed: a render latch which could stick
core/_life_cycle_hook.pyraised its "a render is already scheduled" latch after handing off to the scheduler, and never undid it when a scheduled render was cancelled before it ever started. Either way the latch stayed permanently raised, after which every later state change was silently deduplicated away and the component sat on a stale model for the rest of its life. The latch is now raised before the hand-off, and the newunschedule_render()puts it back.Tests
tests/test_core/test_event_proxy.py(new, 47 tests): proxy reads, batched and shared in-flight reads, method calls, the cache, timeout and disabling behaviour, connection loss before a flush, a dead layout which still receives an answer, and the exception paths.src/js/packages/@reactpy/client/tests/event-proxy.test.ts(new, 45 tests): the registry, the blocklist, serialization depth, batching, and method calls against real DOM objects.test_events.py,test_layout.py,test_event_inspect.py,test_serve.py,test_vdom.pyandtest_hooks.py, plus the client, widget, html, utils, web and pyscript suites, all of which had to move to coroutine handlers.fail_under = 100,hatch fmt src/reactpy --checkandhatch run python:type_checkare clean, and the JS and browser suites pass.test-documentation, which is switched off onmainwithif: 0.Known follow-ups
docs/sourceare still plaindefs, and 30 places still read events dict-style (event["target"]["value"]). Nothing exercises them in CI —test-documentationis disabled onmainand is the only job which touchesdocs/— so this PR leavesdocs/alone. They need their own pass.Checklist
Please update this checklist as you complete each item:
event-to-objectremoval, and threeFixedentries for the bugs this branch repairs.)docs/changes: the examples still show sync handlers and dict-style access, and need a separate pass. Nothing in CI runs them.)By submitting this pull request I agree that all contributions comply with this project's open source license(s).