Skip to content

Add live event proxy for accessing browser event data - #1352

Merged
Archmonger merged 36 commits into
mainfrom
live-event-proxy
Oct 16, 2026
Merged

Archmonger merged 36 commits into
mainfrom
live-event-proxy

Conversation

@Archmonger

@Archmonger Archmonger commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

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:

async def on_input(event):
    value = await event.target.value           # one round trip
    await event.target.focus()                 # methods are callable too
    height, key = await event.read("target.offsetHeight", "key")  # both in one round trip

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 — so await event.read("target.value", "key") and await asyncio.gather(event.target.value, event.key) each cost one round trip, while two awaits written one after the other cannot be batched (the second is not known until the first lands).

Breaking changes

  • Event handlers must be coroutines. A plain def raises TypeError when it is wrapped by EventHandler / @event. A callable object whose __call__ is a coroutine counts as a handler, and so does a partial wrapping 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 where set_state() fails with no running event loop.
  • event is no longer a dict. reactpy.types.Event (the old class Event(dict) with .get() / .items() / its own preventDefault()) is gone, replaced by the awaitable EventProxy and Event(EventProxy) in reactpy._event_proxy. event["target"]["value"] no longer works; await event.target.value or await event.read("target.value") do. reactpy.types re-exports Event and EventProxy, and adds the EventObject alias and the EventHandlerFunc protocol which handlers are typed against.
  • event-to-object is 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 type is 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) and event-proxy-response, both of which travel batched as requests / 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.py holds 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), and build_event_args() / EventArguments() which turn what a client dispatched into the arguments a handler is handed. Failures are EventProxyError and its Unavailable / Timeout / NotFound / AccessError subclasses.
  • Layout.set_event_proxy_sender() and Layout.handle_event_proxy_response() route requests out to the client and answers back to the layout; core/serve.py wires the sender to the connection's own send path.
  • Fully async, nothing blocks. A request goes out on the loop which owns the connection — scheduled there directly when the caller is already on it, handed over with 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.
  • The JavaScript-flavoured part of the event API survives and is still detected statically from the handler's bytecode (core/_event_inspect.py): event.preventDefault(), event.stopPropagation(), event.stopImmediatePropagation() (new), and event.debounce = <milliseconds>.
  • executors/asgi/middleware.py: ReactPyWebsocket now keeps hold of the Layout it is rendering and hands event-proxy-response messages to it, on the loop which owns that layout. Before this, those answers fell into the else: # nocov branch and were logged as unknown message types, so every live read hung until it timed out. Unit tests could not see this, because they drive deliver() directly and bypass the middleware; the browser test suite is what caught it.
  • New config: REACTPY_EVENT_PROXY_TIMEOUT, REACTPY_EVENT_PROXY_MAX_TIMEOUTS, and REACTPY_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.ts keeps 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.ts answers event-proxy-request for every client, without waiting on anybody having registered a handler for the message type.
  • Guardrail: property access is refused for anything matching /^on[a-z]/i plus an explicit blocklist, so handler-like names (including camelCase ones such as onPointerDown) are never reflected back to the server. This deliberately over-blocks a few benign names (only, one, once), which the tests pin down.
  • Guardrail: whole-object views are data-only — methods are omitted from them, while an explicit method path still returns something callable. Blocked names are dropped during serialization rather than turned into references.

A read belongs to the moment it is awaited

await event.target.value reports 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.

  • The figure is per event name now (_reactpy_ack_seq), since an element bound to more than one event cannot honestly be described by a single number.
  • It is recorded once the handler is out of the way, so a render cannot claim a keystroke has been handled while the value it carries predates it.
  • It carries the sequence number which actually reached the wire, not the number of times a handler fired: a debounced handler allocates a sequence per keystroke and dispatches only the last, which is why the send path reports back through _reactpy_mark_sent (_reactpy_peek_seq / _reactpy_set_seq keep the two counters on one number line).
  • An acknowledgement with no render to travel on prompts one — unless the client already holds it, a queued render will carry it, or the element is not one which waits on an acknowledgement at all.
  • Guardrail: a render asked for while the layout is closing is refused rather than left to run against a layout which has already let go of what it renders with.
  • Guardrail: _reactpy_ack_seq is 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.py raised 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 new unschedule_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.
  • Extended test_events.py, test_layout.py, test_event_inspect.py, test_serve.py, test_vdom.py and test_hooks.py, plus the client, widget, html, utils, web and pyscript suites, all of which had to move to coroutine handlers.
  • Locally: the full Python suite passes at 100% coverage under the project's fail_under = 100, hatch fmt src/reactpy --check and hatch run python:type_check are clean, and the JS and browser suites pass.
  • On CI: every check on this PR is green. The only skip is test-documentation, which is switched off on main with if: 0.

Known follow-ups

  • The docs still teach the old API: 41 example handlers across docs/source are still plain defs, and 30 places still read events dict-style (event["target"]["value"]). Nothing exercises them in CI — test-documentation is disabled on main and is the only job which touches docs/ — so this PR leaves docs/ alone. They need their own pass.

Checklist

Please update this checklist as you complete each item:

  • Tests have been developed for bug fixes or new functionality.
  • The changelog has been updated, if necessary. (The live event proxy entry, the event-to-object removal, and three Fixed entries for the bugs this branch repairs.)
  • Documentation has been updated, if necessary. (No docs/ changes: the examples still show sync handlers and dict-style access, and need a separate pass. Nothing in CI runs them.)
  • GitHub Issues closed by this PR have been linked.

By submitting this pull request I agree that all contributions comply with this project's open source license(s).

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
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.
@Archmonger
Archmonger merged commit 4b765d4 into main Oct 16, 2026
20 checks passed
@Archmonger
Archmonger deleted the live-event-proxy branch October 16, 2026 08:29
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.

1 participant