Make event handlers awaitable and register them through @rx.event

FA
2h ago
+2052-124

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 lockTargetBehaviour
yes (foreground handler, async with self, app.modify_state)state handlerruns 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 handlerenqueued, its EventFuture awaited
eitherstateless handlerruns inline
yesbackground handlerEventHandlerTypeError (would wait for the lock); yield it instead
anyfrontend-only (rx.redirect, …) or upload specEventHandlerTypeError
no EventContextanyEventHandlerTypeError

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 tests and pre-commit run --all-files are clean.
  • New Selenium scenarios were added to tests/integration/test_event_chain.py (await inline, return value, callee's yield chained after the caller) and tests/integration/test_background_task.py (await through the queue, then inline inside async 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.py passes; repeated A/B runs of the per-event cases against main are within noise (for example process_event[counter] 0.60/0.58 ms base vs 0.58/0.66 ms head).

Review notes

  1. EventSpec.__hash__ is identity-based while the dataclass __eq__ stays field-wise: asyncio.gather hashes awaitables, and the bound Var args make field-wise hashing and equality unusable.
  2. locked_root is shared by reference with tasks a handler spawns, so a gathered sibling runs inline instead of enqueuing behind its own parent. A task outliving the handler sees None and enqueues.
  3. An awaited child that supersession cancels raises CancelledError into the caller.
  4. Stateless handlers run concurrently with each other and with the same client's foreground events.
  5. reflex-enterprise should be checked for inspect.isawaitable on handler results and for code expecting rx.event(...) to return a plain function (the function is at .fn, markers still on it).
  6. await State.h() is typed Any; a return TypeVar would mean rewriting the EventCallback.__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 EventSpec to skip the LiteralVar encode/decode round trip on inline awaits.

closes #6223

https://claude.ai/code/session_01LwJUCVfbdzZayfSZMiP8GJ


Generated by Claude Code