Merge a repeated @rx.event; restore staticmethod/classmethod above @rx.event

Merged
MA
1d ago
+56-10

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

  • Bug fix (non-breaking change which fixes an issue)

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?

This is a release fix-up for FINDING-001 (HIGH, regression) and FINDING-016 (LOW, regression) from the reflex 0.11.0a1 pre-release testing (artifacts on branch claude/vibrant-pasteur-3921g3, prerelease-testing/runs/2026-11-11-v0.11.0a1/).

Defect

FINDING-001. A second @rx.event on a handler makes class creation fail. reflex-examples overkey (overkey/overkey.py:164, @rx.event directly above @rx.event(background=True)) fails at import, and reflex run exits 1. Since #7673, @rx.event returns an EventHandler, and EventNamespace.__new__.wrapper had no case for an input that is already one. Depending on the outer decorator's options, one of three things happened:

  • it built EventHandler(fn=EventHandler), and registration failed in format.py:448 with 'EventHandler' object has no attribute '__qualname__';
  • the background check failed with Background task must be async function or generator.;
  • setattr on the frozen dataclass raised FrozenInstanceError.

All six stacking patterns failed: event/event, event/event(background=True), event/event(throttle=300), event(background=True)/event, event(throttle=300)/event and event()/event.

FINDING-016. @staticmethod (or @classmethod) stacked above @rx.event on a state returned an EventSpec instead of calling the function. BaseState._item_is_event_handler found the inner handler through staticmethod.__wrapped__ and emitted the generic, misleading "Decorators above @rx.event" warning. A staticmethod has no __code__, so the attribute stayed staticmethod(EventHandler), and calls went to EventHandler.__call__.

Fix

  • reflex_base/event/__init__.py wrapper: when the input is an EventHandler, unwrap it to func.fn and decorate the function again, silently (owner's decision: the deprecation covers non-rx.event decorators). Merge semantics:
    • background / supersedes markers live on the function and take the explicit value of each decorator, so an outer background=False turns off an inner background=True. The outer decorator wins for every option, the same rule as for event actions below.
    • Event actions are {**inner, **outer}: the outer decorator wins where both set an action, because it is applied last, the same way chained .throttle(...) overrides. In 0.10.1 the outer decorator replaced the whole dict whenever it had actions. The merge gives the same result for every pattern above and drops nothing.
    • A repeated decorator on a decentralized (module-level) handler re-registers it under the name it already has, instead of registering a second, doubly-prefixed handler.
    • Only an unbound handler (the inner decorator of a stack) or a decentralized one is decorated again. A method handler already bound to a state, such as rx.event(OtherState.handler) in another state, raises a TypeError, as on main. Unwrapping it would write the new options onto the other state's function: background=True would make OtherState.handler a background task.
  • reflex/state.py: a new _method_above_handler turns staticmethod(EventHandler) / classmethod(EventHandler) back into the plain staticmethod/classmethod of its function. It is used in the handler loop of __init_subclass__ and in _bind_mixin_members, so a state that copies the method from a mixin is fixed too. It warns with the existing @rx.event on a staticmethod or classmethod deprecation (the decorator has no effect; remove it), as the #7673 changelog says for @rx.event below them.

Test plan

Regression tests written first and shown failing on origin/main (2d71015b7):

  • tests/units/test_event.py::test_repeated_rx_event_is_one_handler[*]: the six patterns each register one handler with the right name, background flag and actions, emit no deprecation, and run. Also test_repeated_rx_event_merges_event_actions and test_repeated_rx_event_on_a_decentralized_handler.
  • test_repeated_rx_event_outer_false_overrides_background_and_supersedes and test_rx_event_on_a_handler_of_another_state_raises[*]. Before their fix: assert True is False (outer background=False ignored) and 3× DID NOT RAISE TypeError (OtherState.handle decorated again, and made a background task with background=True).
    Before the fix, all 8 failed: 4× AttributeError: 'EventHandler' object has no attribute '__qualname__' (format.py:448), 1× TypeError: Background task must be async function or generator., 3× FrozenInstanceError: cannot assign to field '_rx_event_actions'.
  • tests/units/test_state.py::test_static_and_class_methods_above_rx_event_are_deprecated_and_ignored: S.helper(3) == 6, S().helper(3) == 6, S.cls_helper(3) runs, nothing is registered as a handler, and the same holds via a mixin. Before the fix it failed: assert {'Decorators above @rx.event'} == {'@rx.event on a staticmethod or classmethod'}, and the call returned an EventSpec.

After the fix:

  • uv run pytest tests/units --cov --no-cov-on-fail --cov-report=: 15643 passed, 66 skipped, coverage 81.43%. tests/units/reflex_bench/drivers/test_browser.py was excluded because the sandbox has no Chromium for it.
  • ruff check, ruff format and uv run pyright reflex tests are clean (0 errors).
  • reflex-examples overkey (copied from @ebe19ff), with a throwaway venv that has this branch installed editable: import overkey.overkey works and OverkeyState.tick.is_background is True. reflex run in dev serves the page, and in Chromium a key press starts the stacked background tick: the timer goes 29 → 26 in 2.5 s, with no console or backend errors.

Notes

  • Not changed: @staticmethod above @rx.event on a class that is not a state still returns an EventSpec, with no warning. Nothing inspects such a class's attributes (staticmethod does not forward __set_name__). The #7673 changelog already says calling such a method through the class is unsupported. A follow-up could warn in EventHandler.__call__ for an unbound method-like handler.
  • pyright still rejects @rx.event(...) stacked above another @rx.event (the EventDecorator signature takes a plain function). The tests carry # pyright: ignore. Typing the repeated form is left out of this fix-up.
  • FINDING-007 (pyright typing of bare @rx.event on Python 3.11–3.13) is handled in a separate PR.

Generated by Claude Code