Stream event handler yield and return values to the frontend caller
closes #7608 (ENG-13447)
Type of change
- New feature (non-breaking change which adds functionality)
- Breaking change: a single-event trigger now returns a result stream that settles when the handler finishes, not a promise that resolved once the event was sent
- This change requires a documentation update (included)
What it does
Frontend code can dispatch a backend event and get back every value the handler yields or returns:
for await (const hit of addEvents([searchEvent])) render(hit);
const rows = await addEvents([loadRowsEvent]); // the last value; rejects if the handler raised
A trigger made of one event handler returns the stream too, so it can be passed straight to a React prop that expects an async callback (loadOptions, getRows, ...).
Wire protocol
- Frontend to backend: one new field on each sent event,
id(a random per-page-load prefix plus a counter). - Backend to frontend: a
resultsfield on the existing"event"StateUpdate:{id, value},{id, done},{id, error},{id, done, cancelled}. Results therefore keep the message's ordering, lost-and-found routing and disconnect handling.
Backend (reflex-base, reflex)
on_eventreadsidfrom the wire fields, liketraceparent, and checks that it is a string of at most 64 characters. It is not added toEvent: events are serialized as dataclasses when sent to the browser, so every event would otherwise carry"id": null.EventContextgainsrequest_id, whichfork()does not inherit, so chained events report nothing. It also gains anemit_resultchannel wired inconfigure.- Yields and returns share one classifier,
_is_result:Noneflushes the delta, and events chain as before;- any other value is a result;
- events mixed with values, unawaited coroutines, and callables (
yield self.handler, the classic mistake) still raiseTypeError. - ENG-13665 can build on the same function.
- Only the processor's top-level run gets
request_id. A handler run withawaitinside another shares its caller's context, so reading the id from the context would have leaked its values into the caller's stream. - Each request completes exactly once (
EventContext.result_sent):- success and error are sent inside the handler's task, so they reach the browser before the next event for the same tab emits its delta;
- cancellation, whether queued or running, is reported from the future's done callback, which also covers events cancelled before their task started.
- In prod mode the error text is generic, matching
default_backend_exception_handler. The backend exception handler and its toast still run.
Separate vs merged completion, measured. The issue asked to merge done into the final delta where possible. I built both and ran them on the production Socket.IO load app (200 events per client, average of two rounds):
| clients | baseline (no id) | separate done message | done merged into the final delta |
|---|---|---|---|
| 1 | 242–253 ev/s | 219 (−14%), p50 2.0 ms | 243 (same), p50 1.5 ms |
| 10 | 584–600 | 421 (−30%), p50 20.1 ms | 541 (−7%), p50 14.7 ms |
| 50 | 762–791 | 495 (−35%), p50 92 ms | 720 (−9%), p50 60 ms |
Merged it is. A handler that returns a value or nothing, and doesn't end by chaining events, still costs one message per event. A yielded value also rides with the delta flushed just before it.
Frontend (state.js, new utils/event_results.js)
ResultStreamis an async iterator and a thenable rather than a Promise, so a failing dispatch nobody awaits never reports an unhandled rejection. Batches merge in arrival order.- The module has no npm imports, so vitest covers it (
tests/js/event_results.test.js). - The stream is attached to the event when it is queued and registered under its id only when the event is sent. Events queued while offline keep waiting, and frontend-only events and uploads complete immediately.
- A disconnect fails every request that was sent and not completed.
- Debounce: every call in one window gets the same stream, bound to the dispatch that fires. Throttle: a dropped call shares the last open dispatch, or gets an already-completed stream.
Compatibility
- An old frontend sends no
id, so it gets no results and no overhead (_execute_eventruns directly), and handlers that yield values just run. - A plain
yield []is now a value rather than an empty event list. This matches #7673's rule for return values.
Tests
test_base_state_processor.py(backend processor):- ordering of deltas and values;
- bare done;
- no-id drop;
- dev/prod error text;
- an inline awaited handler stays with its caller;
- queued and running cancellation reported once;
- background handler;
- stateless handler;
yield self.handlerstill raises.
test_context.py,test_app.py(on_eventforwards and validates theid),test_state.py(rewrotetest_state_with_invalid_yield;resultsserialization).state.jsruntime harness (client_state_updates.mjs): records routed by id after the delta, frontend-only events complete at once, disconnect rejects. The harnesses now load the realevent_results.js.- vitest for the stream module.
- Playwright (
tests_playwright/test_event_results.py, new app; no existing one fit):for awaitandawait, rejection after partial values, two concurrent requests keep their own results, two calls in one debounce window share one dispatch. - Registered the
ignore_console_errormarker (already used, never registered).
Known limits
- A trigger with several events, or with event actions applied to the whole trigger, compiles to a
{a; b;}block and returns nothing. This is documented. - Values from events the handler chains are not included (no
wait_all, as scoped).
Checks
- Unit tests pass, coverage 81%. Unrelated: the
reflex_clitests fail in this environment on a clean checkout too, and 17test_app.pyfailures appear only when it runs together with certain other test files, with or without this change. -
ruff check,ruff format,pyright reflex testsand pre-commit pass - JS tests and Playwright tests pass
- Docs (
yield_events.md,browser_javascript.md,wrapping-react/props.md) and news fragments forreflexandreflex-base. The unreleased awaitable-handlers fragment no longer says return values aren't sent to the frontend.
https://claude.ai/code/session_01LwJUCSPGJ9cvdFM2KLFHoH
Generated by Claude Code