Stream event handler yield and return values to the frontend caller

FA
2d ago
+2203-90

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 results field 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_event reads id from the wire fields, like traceparent, and checks that it is a string of at most 64 characters. It is not added to Event: events are serialized as dataclasses when sent to the browser, so every event would otherwise carry "id": null.
  • EventContext gains request_id, which fork() does not inherit, so chained events report nothing. It also gains an emit_result channel wired in configure.
  • Yields and returns share one classifier, _is_result:
    • None flushes 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 raise TypeError.
    • ENG-13665 can build on the same function.
  • Only the processor's top-level run gets request_id. A handler run with await inside 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):

clientsbaseline (no id)separate done messagedone merged into the final delta
1242–253 ev/s219 (−14%), p50 2.0 ms243 (same), p50 1.5 ms
10584–600421 (−30%), p50 20.1 ms541 (−7%), p50 14.7 ms
50762–791495 (−35%), p50 92 ms720 (−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)

  • ResultStream is 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_event runs 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.handler still raises.
  • test_context.py, test_app.py (on_event forwards and validates the id), test_state.py (rewrote test_state_with_invalid_yield; results serialization).
  • state.js runtime 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 real event_results.js.
  • vitest for the stream module.
  • Playwright (tests_playwright/test_event_results.py, new app; no existing one fit): for await and await, rejection after partial values, two concurrent requests keep their own results, two calls in one debounce window share one dispatch.
  • Registered the ignore_console_error marker (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_cli tests fail in this environment on a clean checkout too, and 17 test_app.py failures appear only when it runs together with certain other test files, with or without this change.
  • ruff check, ruff format, pyright reflex tests and pre-commit pass
  • JS tests and Playwright tests pass
  • Docs (yield_events.md, browser_javascript.md, wrapping-react/props.md) and news fragments for reflex and reflex-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