Make event handlers awaitable and register them through @rx.event
All Submissions:
- Have you followed the guidelines stated in CONTRIBUTING.md file?
- Have you checked to ensure there aren't any other open Pull Requests for the desired changed?
Type of change
- New feature (non-breaking change which adds functionality)
- This change requires a documentation update
New Feature Submission:
- Does your submission pass the tests?
- Have you linted your code locally prior to submission?
Changes To Core Features:
- Have you added an explanation of what your changes do and why you'd like us to include them?
- Have you written new tests for your core changes, as applicable?
- Have you successfully ran tests with your changes locally?
What this does
Implements ENG-9162 (EventHandler redesign: awaitable semantics).
@rx.event is the registration point. The decorator returns the EventHandler itself. EventHandler.__set_name__ binds a method's handler to the state class whose body declares it, before BaseState.__init_subclass__ collects the class's handlers and RegistrationContext.register_base_state registers them under the final class name, so local classes and hot reload behave as before. A mixin's handler is bound to the mixin and copied per concrete state (including rx.ComponentState). Undecorated public methods are still wrapped as today. A decorator stacked above @rx.event now raises TypeError at class creation (it would otherwise chain the handler forever). @rx.event on a function without a state parameter registers a stateless handler (states=()) that runs with no state, no lock, and outside the per-token FIFO of BaseStateEventProcessor.
Handlers are awaitable. EventHandler and EventSpec implement __await__; calling still only binds arguments (returns an EventSpec), so return rx.redirect(...) and yield State.x(1) are unchanged. execute_event_inline decides how an await runs from EventContext.locked_root, which the processor, StateProxy.__aenter__/__aexit__ and App.modify_state set while the token's state lock is held:
| Caller holds the state lock | Target | Behaviour |
|---|---|---|
yes (foreground handler, async with self, app.modify_state) | state handler | runs inline on the locked tree under the caller's context: its yields emit deltas, the events it yields chain under the caller's EventFuture |
no (background task outside async with self, API route) | state handler | enqueued, its EventFuture awaited |
| either | stateless handler | runs inline |
| yes | background handler | EventHandlerTypeError (would wait for the lock); yield it instead |
| any | frontend-only (rx.redirect, …) or upload spec | EventHandlerTypeError |
no EventContext | any | EventHandlerTypeError |
Return values. A handler's non-event return value resolves await and the EventFuture (EventContext.enqueue now returns the futures). Returned events still chain; a returned coroutine still raises. Yielded non-events still raise; streaming values to the frontend is ENG-13447. EventProcessor._execute_event awaits only coroutines now, so a bare handler returning a spec no longer executes it.
self.handler() on a state instance keeps calling the plain method as before; the new form is await type(self).handler(x).
Verification
- Unit suite: 13,396 passed. The only failures are
tests/units/reflex_bench/drivers/test_browser.py, which needs a Playwright browser download the sandbox cannot fetch. ruff check,ruff format,pyright reflex testsandpre-commit run --all-filesare clean.- New Selenium scenarios were added to
tests/integration/test_event_chain.py(await inline, return value, callee's yield chained after the caller) andtests/integration/test_background_task.py(await through the queue, then inline insideasync with self). Selenium could not download chromedriver in the sandbox, so both apps were driven with Playwright against the installed Chromium instead; both pass. CI runs the Selenium versions. tests/benchmarks/test_event_processing.pypasses; repeated A/B runs of the per-event cases againstmainare within noise (for exampleprocess_event[counter]0.60/0.58 ms base vs 0.58/0.66 ms head).
Review notes
EventSpec.__hash__is identity-based while the dataclass__eq__stays field-wise:asyncio.gatherhashes awaitables, and the boundVarargs make field-wise hashing and equality unusable.locked_rootis shared by reference with tasks a handler spawns, so agathered sibling runs inline instead of enqueuing behind its own parent. A task outliving the handler seesNoneand enqueues.- An awaited child that supersession cancels raises
CancelledErrorinto the caller. - Stateless handlers run concurrently with each other and with the same client's foreground events.
- reflex-enterprise should be checked for
inspect.isawaitableon handler results and for code expectingrx.event(...)to return a plain function (the function is at.fn, markers still on it). await State.h()is typedAny; a return TypeVar would mean rewriting theEventCallback.__call__overloads.
Follow-ups
- Wire transport of return values and the frontend promise: ENG-13447.
- Precompiled per-parameter payload coercers (resolved once at registration), gated on the event processing benchmark.
- Carrying raw args on
EventSpecto skip theLiteralVarencode/decode round trip on inline awaits.
closes #6223
https://claude.ai/code/session_01LwJUCVfbdzZayfSZMiP8GJ
Generated by Claude Code