From 945a53fe3be10c6ff17e079a6fe724a2525f5307 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Wed, 2 Sep 2026 11:43:24 +0200 Subject: [PATCH] feat: allow explicit only event handlers --- docs/events/events_overview.md | 11 + news/7033.feature.md | 1 + packages/reflex-base/news/7033.feature.md | 1 + .../reflex-base/src/reflex_base/config.py | 86 +++++-- .../src/reflex_base/event/__init__.py | 5 + reflex/state.py | 12 +- tests/units/test_event.py | 18 ++ tests/units/test_state.py | 212 +++++++++++++++++- 8 files changed, 317 insertions(+), 29 deletions(-) create mode 100644 news/7033.feature.md create mode 100644 packages/reflex-base/news/7033.feature.md diff --git a/docs/events/events_overview.md b/docs/events/events_overview.md index 2b02ef75b54..72618e88bf3 100644 --- a/docs/events/events_overview.md +++ b/docs/events/events_overview.md @@ -46,6 +46,17 @@ Whenever the user hovers over the heading, the `next_word` **event handler** wil Adding the `@rx.event` decorator above the event handler is strongly recommended. This decorator enables proper static type checking, which ensures event handlers receive the correct number and types of arguments. +By default, every public method of a state (one whose name does not start with `_`) is treated as an event handler, whether or not it is decorated. To make `@rx.event` mandatory and keep undecorated public methods as plain Python helpers, enable `state_explicit_event_handlers` in `rxconfig.py`: + +```python +config = rx.Config( + app_name="my_app", + state_explicit_event_handlers=True, +) +``` + +This also applies to states from third-party packages, so they must decorate their event handlers for the option to be usable in your app. + ## What's in this section? In the event section of the documentation, you will explore the different types of events supported by Reflex, along with the different ways to call them. diff --git a/news/7033.feature.md b/news/7033.feature.md new file mode 100644 index 00000000000..8603ca004a6 --- /dev/null +++ b/news/7033.feature.md @@ -0,0 +1 @@ +Add the `state_explicit_event_handlers` config option. When enabled, only methods decorated with `@rx.event` become event handlers; other public state methods stay plain Python methods. diff --git a/packages/reflex-base/news/7033.feature.md b/packages/reflex-base/news/7033.feature.md new file mode 100644 index 00000000000..8603ca004a6 --- /dev/null +++ b/packages/reflex-base/news/7033.feature.md @@ -0,0 +1 @@ +Add the `state_explicit_event_handlers` config option. When enabled, only methods decorated with `@rx.event` become event handlers; other public state methods stay plain Python methods. diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index 2665b6eeabf..f819efc910e 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -9,6 +9,7 @@ import urllib.parse from collections.abc import Iterator, Sequence from contextlib import contextmanager +from contextvars import ContextVar from importlib.util import find_spec from pathlib import Path from types import ModuleType @@ -183,6 +184,7 @@ class BaseConfig: redis_token_expiration: Token expiration time for redis state manager. env_file: Path to file containing key-values pairs to load into the environment; Dotenv format. Multiple files may be separated by os.pathsep. Requires the python-dotenv package. state_auto_setters: Whether to automatically create setters for state base vars. + state_explicit_event_handlers: Whether only methods decorated with `@rx.event` become event handlers. By default every public method of a user-defined state is an event handler. default_color_mode: The default color mode for the app: "system" (follow the OS preference), "light", or "dark". Applies to the built-in color mode switcher and `color_mode_cond` without requiring a radix theme. show_built_with_reflex: Whether to display the sticky "Built with Reflex" badge on all pages. is_reflex_cloud: Whether the app is running in the reflex cloud environment. @@ -260,6 +262,8 @@ class BaseConfig: state_auto_setters: bool = False + state_explicit_event_handlers: bool = False + default_color_mode: LiteralColorMode = "system" show_built_with_reflex: bool | None = None @@ -403,10 +407,10 @@ def _post_init(self, **kwargs): self._non_default_attributes = set(kwargs.keys()) self._replace_defaults(**kwargs) - # Publish for State-class creation so it never re-enters get_config() - # (which AttributeErrors if a State is defined while rxconfig.py is mid-import). - global _state_auto_setters - _state_auto_setters = self.state_auto_setters + # Publish to the in-progress rxconfig.py import, if any, so States defined + # after this Config in rxconfig.py resolve their flags from it. + if (load := _config_load.get()) is not None: + load.config = self if ( self.state_manager_mode == constants.StateManagerMode.REDIS @@ -871,29 +875,66 @@ def _record_imports() -> Iterator[_ImportRecorder]: # Protect sys.path from concurrent modification during config loading. _load_config_lock = threading.RLock() -# Cached state_auto_setters so State-class creation never re-enters get_config(). -_state_auto_setters: bool | None = None +@dataclasses.dataclass +class _ConfigLoad: + """An in-progress rxconfig.py import, holding its Config once constructed.""" -def get_state_auto_setters() -> bool: - """Return whether state auto-setters are enabled, without importing rxconfig. + config: Config | None = None + + +# Set for the duration of _get_config() so States defined inside rxconfig.py see +# the Config being loaded (or env/default before it exists), never a stale one. +_config_load: ContextVar[_ConfigLoad | None] = ContextVar("_config_load", default=None) + + +def _get_state_flag(name: str) -> bool: + """Resolve a boolean State-class creation flag without loading rxconfig. - Reads the value cached when the Config was built. Before any Config exists - (e.g. a State defined inside rxconfig.py during its import), falls back to the - REFLEX_STATE_AUTO_SETTERS env var, then the default (False). This never calls + Uses the Config of the in-progress rxconfig.py import if there is one, else + the Config loaded on the active RegistrationContext. Before either exists + (e.g. a State defined in rxconfig.py ahead of its Config), falls back to the + REFLEX_ env var, then the default (False). This never calls get_config() or imports rxconfig, so it cannot re-enter config loading. + Args: + name: The config field name. + Returns: - Whether state auto-setters are enabled. + The resolved flag value. """ - if _state_auto_setters is not None: - return _state_auto_setters - env_val = os.environ.get(Config._prefixes[0] + "STATE_AUTO_SETTERS") + load = _config_load.get() + config = ( + load.config + if load is not None + else RegistrationContext.ensure_context()._config + ) + if config is not None: + return getattr(config, name) + env_val = os.environ.get(Config._prefixes[0] + name.upper()) if env_val and env_val.strip(): - return interpret_env_var_value(env_val, bool, "state_auto_setters") + return interpret_env_var_value(env_val, bool, name) return False +def get_state_auto_setters() -> bool: + """Return whether state auto-setters are enabled, without importing rxconfig. + + Returns: + Whether state auto-setters are enabled. + """ + return _get_state_flag("state_auto_setters") + + +def get_state_explicit_event_handlers() -> bool: + """Return whether only `@rx.event` methods become event handlers, without importing rxconfig. + + Returns: + Whether explicit event handlers are required. + """ + return _get_state_flag("state_explicit_event_handlers") + + def _get_config(project_root: Path | None = None) -> Config: """Import rxconfig.py fresh from the project root and return its config. @@ -918,6 +959,7 @@ def _get_config(project_root: Path | None = None) -> Config: # which removal by value could confuse with caller-owned ones. cwd = str(project_root) sys.path.insert(0, cwd) + load_token = _config_load.set(_ConfigLoad()) try: # Never cache rxconfig or its project-local dependencies — each load # goes to disk so different RegistrationContexts hold independent @@ -950,6 +992,7 @@ def _get_config(project_root: Path | None = None) -> Config: _config_module_deps.add(name) return rxconfig.config finally: + _config_load.reset(load_token) for i, entry in enumerate(sys.path): if entry is cwd: del sys.path[i] @@ -1014,13 +1057,16 @@ def get_config(reload: bool = False) -> Config: def reload_config() -> Config: """Force a fresh load of the config into the current RegistrationContext. - Clears any cached config on the current context and reloads rxconfig.py - from disk. + Reloads rxconfig.py from disk and replaces any cached config on the current + context. If the load fails, the context keeps its previous config. Returns: The freshly loaded app config. """ ctx = RegistrationContext.ensure_context() - config = _get_config() - ctx._set_config(config) + # Load and publish under one lock so concurrent reloads of a shared context + # cannot publish an older load over a newer one. + with _load_config_lock: + config = _get_config() + ctx._set_config(config) return config diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index b191bd93349..5e907ff2bd5 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -295,6 +295,7 @@ def _scan_detach(value: Any, memo: dict[int, Any], active: set[int]) -> Any: BACKGROUND_TASK_MARKER = "_reflex_background_task" SUPERSEDES_MARKER = "_reflex_supersedes" EVENT_ACTIONS_MARKER = "_rx_event_actions" +EVENT_MARKER = "_rx_event" UPLOAD_FILES_CLIENT_HANDLER = "uploadFiles" # Payload key listing the names of the extra bound handler args in an upload @@ -2932,6 +2933,7 @@ class EventNamespace: BACKGROUND_TASK_MARKER = BACKGROUND_TASK_MARKER SUPERSEDES_MARKER = SUPERSEDES_MARKER EVENT_ACTIONS_MARKER = EVENT_ACTIONS_MARKER + EVENT_MARKER = EVENT_MARKER _EVENT_FIELDS = _EVENT_FIELDS FORM_DATA = FORM_DATA FORM_SUBMIT_MAPPING = FORM_SUBMIT_MAPPING @@ -3057,6 +3059,9 @@ def wrapper( if getattr(func, "__name__", "").startswith("_"): msg = "Event handlers cannot be private." raise ValueError(msg) + # Lets State tell decorated methods apart when + # state_explicit_event_handlers is enabled. + setattr(func, EVENT_MARKER, True) qualname: str | None = getattr(func, "__qualname__", None) diff --git a/reflex/state.py b/reflex/state.py index 15722061c65..8d552317f76 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -29,10 +29,12 @@ ) from reflex_base import constants +from reflex_base.config import get_state_auto_setters, get_state_explicit_event_handlers from reflex_base.constants.state import FIELD_MARKER from reflex_base.environment import PerformanceMode, environment from reflex_base.event import ( EVENT_ACTIONS_MARKER, + EVENT_MARKER, Event, EventHandler, EventSpec, @@ -679,10 +681,11 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs): cls._init_var(name, prop) # Set up the event handlers. + explicit = cls.is_user_defined() and get_state_explicit_event_handlers() events = { name: fn for name, fn in cls.__dict__.items() - if cls._item_is_event_handler(name, fn) + if cls._item_is_event_handler(name, fn, explicit) } for mixin_cls in cls._mixins(): @@ -699,7 +702,7 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs): continue if events.get(name) is not None: continue - if not cls._item_is_event_handler(name, value): + if not cls._item_is_event_handler(name, value, explicit): continue if parent_state is not None and parent_state.event_handlers.get(name): continue @@ -762,12 +765,13 @@ def _copy_fn(fn: Callable) -> Callable: return newfn @staticmethod - def _item_is_event_handler(name: str, value: Any) -> bool: + def _item_is_event_handler(name: str, value: Any, explicit: bool = False) -> bool: """Check if the item is an event handler. Args: name: The name of the item. value: The value of the item. + explicit: Only accept functions decorated with `@rx.event`. Returns: Whether the item is an event handler. @@ -778,6 +782,7 @@ def _item_is_event_handler(name: str, value: Any) -> bool: and not isinstance(value, EventHandler) and not getattr(value, "__override_base_method__", False) and hasattr(value, "__code__") + and (not explicit or getattr(value, EVENT_MARKER, False)) ) @classmethod @@ -1154,7 +1159,6 @@ def _init_var(cls, name: str, prop: Var): Raises: VarTypeError: if the variable has an incorrect type """ - from reflex_base.config import get_state_auto_setters from reflex_base.utils.exceptions import VarTypeError if not types.is_valid_var_type(prop._var_type): diff --git a/tests/units/test_event.py b/tests/units/test_event.py index bd5d9484c0a..5cb307559cc 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -7,6 +7,7 @@ from reflex_base.constants.compiler import Hooks, Imports from reflex_base.event import ( BACKGROUND_TASK_MARKER, + EVENT_MARKER, Event, EventChain, EventChainVar, @@ -916,6 +917,23 @@ async def handle_old_background(self): assert hasattr(bg_handler.fn, BACKGROUND_TASK_MARKER) +def test_event_decorator_marks_function(): + """The decorator marks every function it wraps, with or without options.""" + + def plain(self): + pass + + def with_actions(self): + pass + + async def background(self): + pass + + assert getattr(event(plain), EVENT_MARKER, False) is True + assert getattr(event(stop_propagation=True)(with_actions), EVENT_MARKER, False) + assert getattr(event(background=True)(background), EVENT_MARKER, False) is True + + def test_event_var_in_rx_cond(): """Test that EventVar and EventChainVar cannot be used in rx.cond().""" from reflex_components_core.core.cond import cond as rx_cond diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 346bfc4e038..cddabe81fc7 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -13,6 +13,7 @@ import threading from collections.abc import AsyncGenerator, Callable, Mapping from textwrap import dedent +from types import FunctionType from typing import Any, ClassVar, Literal, TypeVar from unittest.mock import AsyncMock, Mock @@ -27,6 +28,7 @@ from reflex_base.event import Event, EventHandler from reflex_base.event.context import EventContext from reflex_base.event.processor import BaseStateEventProcessor +from reflex_base.registry import RegistrationContext from reflex_base.utils import format, types from reflex_base.utils.exceptions import ( InvalidLockWarningThresholdError, @@ -3998,6 +4000,209 @@ class TestState(State): assert "setvar" in TestState.event_handlers +def test_explicit_event_handlers(tmp_path, forked_registration_context): + """With state_explicit_event_handlers, undecorated methods stay plain methods.""" + proj_root = tmp_path / "project1" + proj_root.mkdir() + + config_string = """ +import reflex as rx +config = rx.Config( + app_name="project1", + state_explicit_event_handlers=True, +) + """ + + (proj_root / "rxconfig.py").write_text(dedent(config_string)) + + with chdir(proj_root): + reflex_base.config.reload_config() + from reflex.state import State + + class ExplicitMixin(State, mixin=True): + @rx.event + def mixin_handler(self): + pass + + def mixin_helper(self) -> int: + return 1 + + class ExplicitState(ExplicitMixin, State): + num: int = 0 + + @rx.event + def handler(self): + self.num += 1 + + @rx.event(background=True) + async def bg_handler(self): + pass + + def helper(self) -> int: + return self.num + self.mixin_helper() + + assert sorted(ExplicitState.event_handlers) == [ + "bg_handler", + "handler", + "mixin_handler", + "setvar", + ] + assert isinstance(ExplicitState.handler, EventHandler) + assert isinstance(ExplicitState.bg_handler, EventHandler) + assert isinstance(ExplicitState.mixin_handler, EventHandler) + assert isinstance(ExplicitState.helper, FunctionType) + assert isinstance(ExplicitState.mixin_helper, FunctionType) + assert ExplicitState(_reflex_internal_init=True).helper() == 1 # pyright: ignore [reportCallIssue] + + # Built-in states are unaffected. + assert "on_load_internal" in OnLoadInternalState.event_handlers + + +def test_reload_config_resets_state_flags(tmp_path, forked_registration_context): + """Reloading a project does not leak the previous project's State-class flags. + + A State defined in rxconfig.py ahead of its Config must see the default + (implicit) handler mode even if the previously loaded Config enabled + state_explicit_event_handlers. + """ + explicit_root = tmp_path / "explicit" + explicit_root.mkdir() + (explicit_root / "rxconfig.py").write_text( + dedent( + """ + import reflex as rx + config = rx.Config(app_name="explicit", state_explicit_event_handlers=True) + """ + ) + ) + implicit_root = tmp_path / "implicit" + implicit_root.mkdir() + (implicit_root / "rxconfig.py").write_text( + dedent( + """ + import reflex as rx + + + class RxconfigImplicitState(rx.State): + def implicit_handler(self): + pass + + + config = rx.Config(app_name="implicit") + """ + ) + ) + + with chdir(explicit_root): + reflex_base.config.reload_config() + assert reflex_base.config.get_state_explicit_event_handlers() is True + + with chdir(implicit_root): + reflex_base.config.reload_config() + state_cls = sys.modules[constants.Config.MODULE].RxconfigImplicitState + assert "implicit_handler" in state_cls.event_handlers + del sys.modules[constants.Config.MODULE] + + +def test_state_in_rxconfig_after_config_honors_flags( + tmp_path, forked_registration_context +): + """A State defined in rxconfig.py after its Config uses that Config's flags.""" + proj_root = tmp_path / "project1" + proj_root.mkdir() + (proj_root / "rxconfig.py").write_text( + dedent( + """ + import reflex as rx + + config = rx.Config(app_name="project1", state_explicit_event_handlers=True) + + + class RxconfigPostConfigState(rx.State): + def helper(self): + pass + """ + ) + ) + + with chdir(proj_root): + reflex_base.config.reload_config() + state_cls = sys.modules[constants.Config.MODULE].RxconfigPostConfigState + assert "helper" not in state_cls.event_handlers + del sys.modules[constants.Config.MODULE] + + +def test_reload_config_failure_keeps_previous_config( + tmp_path, forked_registration_context +): + """A failing rxconfig.py reload leaves the context's previous config in place.""" + proj_root = tmp_path / "project1" + proj_root.mkdir() + rxconfig_path = proj_root / "rxconfig.py" + rxconfig_path.write_text( + dedent( + """ + import reflex as rx + config = rx.Config(app_name="project1", state_explicit_event_handlers=True) + """ + ) + ) + + with chdir(proj_root): + good_config = reflex_base.config.reload_config() + rxconfig_path.write_text("raise RuntimeError('broken rxconfig')\n") + with pytest.raises(RuntimeError, match="broken rxconfig"): + reflex_base.config.reload_config() + assert reflex_base.config.get_config() is good_config + assert reflex_base.config.get_state_explicit_event_handlers() is True + + +def test_state_flags_are_per_registration_context( + tmp_path, forked_registration_context +): + """Each RegistrationContext resolves State-class flags from its own Config.""" + explicit_root = tmp_path / "explicit" + explicit_root.mkdir() + (explicit_root / "rxconfig.py").write_text( + dedent( + """ + import reflex as rx + config = rx.Config(app_name="explicit", state_explicit_event_handlers=True) + """ + ) + ) + implicit_root = tmp_path / "implicit" + implicit_root.mkdir() + (implicit_root / "rxconfig.py").write_text( + dedent( + """ + import reflex as rx + config = rx.Config(app_name="implicit") + """ + ) + ) + + with chdir(explicit_root): + reflex_base.config.reload_config() + + with chdir(implicit_root), RegistrationContext(): + reflex_base.config.reload_config() + + class ImplicitContextState(State): + def handler(self): + pass + + assert "handler" in ImplicitContextState.event_handlers + + # Back on the explicit context: its Config is untouched by the other context. + class ExplicitContextState(State): + def helper(self): + pass + + assert "helper" not in ExplicitContextState.event_handlers + del sys.modules[constants.Config.MODULE] + + def test_state_defined_in_rxconfig_does_not_crash(tmp_path): """A State subclass defined in rxconfig.py must not crash config loading. @@ -4033,11 +4238,9 @@ class RxconfigDefinedState(rx.State): def test_state_in_rxconfig_honors_env_auto_setters(tmp_path, monkeypatch): """A State defined in rxconfig.py (pre-config) honors REFLEX_STATE_AUTO_SETTERS. - During rxconfig import the Config does not exist yet, so the cached value is - unset and get_state_auto_setters falls back to the env var. + During rxconfig import no Config is loaded on the context yet, so + get_state_auto_setters falls back to the env var. """ - # Simulate a fresh process where no Config has been built yet. - monkeypatch.setattr(reflex_base.config, "_state_auto_setters", None) monkeypatch.setenv("REFLEX_STATE_AUTO_SETTERS", "true") proj_root = tmp_path / "project1" @@ -4063,7 +4266,6 @@ class RxconfigEnvSetterState(rx.State): def test_state_in_rxconfig_defaults_to_no_auto_setters(tmp_path, monkeypatch): """A State defined in rxconfig.py gets no auto-setters by default (pre-config).""" - monkeypatch.setattr(reflex_base.config, "_state_auto_setters", None) monkeypatch.delenv("REFLEX_STATE_AUTO_SETTERS", raising=False) proj_root = tmp_path / "project1"