Skip to content

Make event handlers awaitable and register them through @rx.event - #7673

Open
FarhanAliRaza wants to merge 14 commits into
mainfrom
farhan/nice-maxwell-bmch7a
Open

FarhanAliRaza wants to merge 14 commits into
mainfrom
farhan/nice-maxwell-bmch7a

Conversation

@FarhanAliRaza

@FarhanAliRaza FarhanAliRaza commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

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 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

View guided diff Turn on auto-fix

@FarhanAliRaza
FarhanAliRaza requested review from a team and Alek99 as code owners October 27, 2026 16:49
@linear-code

linear-code Bot commented Oct 27, 2026

Copy link
Copy Markdown

ENG-9162

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 22 files

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread docs/events/background_events.md Outdated
Comment thread news/+awaitable-event-handlers.feature.md Outdated
Comment thread news/+awaitable-event-handlers.feature.md Outdated
Comment thread docs/events/chaining_events.md Outdated
Comment thread packages/reflex-base/src/reflex_base/event/processor/event_processor.py Outdated
@greptile-apps

greptile-apps Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[High impact] The PR appears safe to merge; the previously reported quote example is fixed.

Summary

Makes event handlers awaitable and registers them through @rx.event.

  • The @rx.event decorator registers handlers directly on state classes and as stateless functions.
  • Awaited handlers run inline under a held state lock or through the event queue when no lock is held.
  • Awaited handlers return ordinary values while event chains continue to run.
  • Literal values wrapped in state proxies keep their own type-based encoding.

Reviews (7) · Last reviewed commit: "Fetch the quote outside the state lock i..." · Reviewed by Greptile

Comment thread packages/reflex-base/src/reflex_base/event/__init__.py Outdated
Comment thread packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py Outdated
Comment thread docs/events/chaining_events.md Outdated
@codspeed

codspeed Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 4.18%

2 warnings

⚠️ 15 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚠️ Base and head ran on different runner types

Some benchmarks with significant performance changes were compared across different runner types, so the changes might not come from the code. Run base and head on the same runner type, or use CodSpeed Macro Runners for more stable results.

Learn more about Macro Runners

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 147 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ test_proxy_list_mutation 9.6 ms 10 ms -4.18%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing farhan/nice-maxwell-bmch7a (2dff931) with main (17370c2)

Open in CodSpeed

…andlers at class creation

A handler's non-event return value now resolves its EventFuture instead of
raising TypeError; returned events still chain. @rx.event returns the
EventHandler itself, bound to its state class by __set_name__, and a function
without a state parameter registers as a stateless handler that runs without
the state lock and outside the per-token FIFO.

Claude-Session: https://claude.ai/code/session_01LwJUCVfbdzZayfSZMiP8GJ
Awaiting a handler (or a spec with bound args) runs it in the active
EventContext: inline on the locked state tree while the current task holds the
token's state lock, which the context now records, or enqueued and awaited
when no lock is held. Its yields emit deltas and chain as usual, and the
handler's return value is the result of the await.

Claude-Session: https://claude.ai/code/session_01LwJUCVfbdzZayfSZMiP8GJ
Adds the docs, news fragments and integration scenarios for awaiting event
handlers from foreground and background handlers.

Claude-Session: https://claude.ai/code/session_01LwJUCVfbdzZayfSZMiP8GJ
Raise the reflex-base floor to 0.10.2.dev0 for the new EventContext lock
methods; keep the state lock held until inline handler runs started from the
handler finish; accept generic annotations on stateless handlers; keep
awaiting futures returned by bare handlers; tighten the docs and news
fragments and add a breaking fragment for the decorator ordering change.

Claude-Session: https://claude.ai/code/session_01LwJUCVfbdzZayfSZMiP8GJ
@FarhanAliRaza
FarhanAliRaza force-pushed the farhan/nice-maxwell-bmch7a branch from 309c05c to daeb277 Compare October 27, 2026 17:35
Comment thread reflex/istate/proxy.py Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 16 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/reflex-base/src/reflex_base/event/context.py Outdated
Comment thread reflex/istate/proxy.py Outdated
Comment thread packages/reflex-base/src/reflex_base/event/context.py Outdated
A background handler leaving `async with self` waits for detached inline runs;
cancelling it during that wait must still exit the state manager's lock, and
the pending inline runs are cancelled rather than left behind.

Claude-Session: https://claude.ai/code/session_01LwJUCVfbdzZayfSZMiP8GJ
Comment thread packages/reflex-base/src/reflex_base/event/context.py Outdated
A nested inline run on an already registered task no longer drops the outer
run's registration, the release keeps draining runs started while it waits,
and exceptions of gathered runs are logged instead of being retrieved silently.

Claude-Session: https://claude.ai/code/session_01LwJUCVfbdzZayfSZMiP8GJ
… hot path

A task cancelled inside the locked block is not interrupted again while it
waits for detached inline runs, so those are cancelled up front. The per-event
lock marking uses a plain try/finally and a fast path when nothing ran inline.

Claude-Session: https://claude.ai/code/session_01LwJUCVfbdzZayfSZMiP8GJ
The CI pyright does not carry the `added` narrowing into `set.add`; keep the
task only when it was registered here.

Claude-Session: https://claude.ai/code/session_01LwJUCVfbdzZayfSZMiP8GJ
Drops the unused holding_lock context manager: both callers now set and
release the lock marker directly, which keeps the per-call overhead down on
the shared-state fan-out path.

Claude-Session: https://claude.ai/code/session_01LwJUCVfbdzZayfSZMiP8GJ

Copy link
Copy Markdown
Contributor Author

On the CodSpeed check (not in the required set; performance-tests-gate is): each run on this branch flags exactly one benchmark at roughly -5%, and the benchmark changes from head to head:

head flagged benchmark base → head
daeb277 test_process_event_burst_independent_tokens[1], test_modify_state[private] 2.9 → 3.1 ms, 7.5 → 7.9 ms
80e1d44, ad34033, ff91379, f87f474 test_modify_state[private] 7.5 → 7.9 ms
42c7239 (current) test_route_argument_extraction 117.5 → 123.4 µs

The current flag is route-argument parsing, which this PR does not touch. For the earlier modify_state figure: a local wall-time A/B against main (two alternating rounds each) gives 0.762/0.729 ms base vs 0.712/0.753 ms head, within noise, and a deterministic call-count profile shows the head adds exactly four Python calls per modify_state (set_locked_root twice, release_lock, _get_root_state), about 1.3% of calls. Every report also carries CodSpeed's own warning that base and head ran on different runner types. I'm not acknowledging the regression on CodSpeed myself; leaving that to a maintainer.


Generated by Claude Code

@masenf masenf added on deck PRs lined up to review / merge next breaking Public API facing change considered for minor release bump pre-v1.0 documentation Improvements or additions to documentation labels Oct 27, 2026
Both sides appended tests to tests/units/test_app.py and
tests/units/test_state.py; keep both.

Claude-Session: https://claude.ai/code/session_01LwJUCVfbdzZayfSZMiP8GJ
_literal_var_for caches the literal class per exact value type, but a
MutableProxy has the proxy's type whatever it wraps while isinstance sees
the wrapped value. The first proxied value pinned every later proxy to its
literal class, so LiteralVar.create(proxy_of_list) raised after
LiteralVar.create(proxy_of_dict). Skip the cache when the value reports a
__class__ other than its type.

Claude-Session: https://claude.ai/code/session_01LwJUCVfbdzZayfSZMiP8GJ

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread tests/units/reflex_base/vars/test_base.py Outdated
Comment thread docs/events/decentralized_event_handlers.md Outdated

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Public API facing change considered for minor release bump pre-v1.0 documentation Improvements or additions to documentation on deck PRs lined up to review / merge next

Projects

None yet

Development

Successfully merging this pull request may close these issues.

EventHandler redesign: awaitable semantics

2 participants