From 945a53fe3be10c6ff17e079a6fe724a2525f5307 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Wed, 2 Sep 2026 11:43:24 +0200 Subject: [PATCH 1/8] 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" From acc796dd6e601d99f40d205b9ca094a46624eaa8 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Thu, 10 Sep 2026 14:08:50 +0200 Subject: [PATCH 2/8] fix: load config on demand when resolving State-class flags so backend workers honor state_explicit_event_handlers --- .../reflex-base/src/reflex_base/config.py | 22 +++++++-------- tests/units/test_state.py | 27 +++++++++++++++++++ 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index 90638639671..c0765607e38 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -920,13 +920,12 @@ class _ConfigLoad: def _get_state_flag(name: str) -> bool: - """Resolve a boolean State-class creation flag without loading rxconfig. + """Resolve a boolean State-class creation flag. - 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. + Outside an rxconfig.py import, reads the context's Config, loading it on demand + (backend workers define user States before anything calls get_config()). During + the import, uses the Config built so far, else the REFLEX_ env var, then + False, so config loading is never re-entered. Args: name: The config field name. @@ -935,13 +934,10 @@ def _get_state_flag(name: str) -> bool: The resolved flag value. """ 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) + if load is None: + return getattr(get_config(), name) + if load.config is not None: + return getattr(load.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, name) diff --git a/tests/units/test_state.py b/tests/units/test_state.py index bed9e74340b..f1e0b80078d 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -4058,6 +4058,33 @@ def helper(self) -> int: assert "on_load_internal" in OnLoadInternalState.event_handlers +def test_state_flags_load_config_on_demand(tmp_path): + """A State defined before any config load resolves its flags from rxconfig.py. + + Backend workers import the app module in a fresh process, so user States are + created before anything calls get_config(). + """ + 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) + """ + ) + ) + + with chdir(proj_root), RegistrationContext(): + + class LazyConfigState(State): + def helper(self): + pass + + assert "helper" not in LazyConfigState.event_handlers + del sys.modules[constants.Config.MODULE] + + 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. From bfa63ac96462e670c5f2f6925e352d4df1435ba1 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Thu, 10 Sep 2026 16:29:21 +0200 Subject: [PATCH 3/8] test: guard rxconfig module cleanup in on-demand config flag test --- tests/units/test_state.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/units/test_state.py b/tests/units/test_state.py index f1e0b80078d..5a0bf2ffeb3 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -4075,14 +4075,16 @@ def test_state_flags_load_config_on_demand(tmp_path): ) ) - with chdir(proj_root), RegistrationContext(): + try: + with chdir(proj_root), RegistrationContext(): - class LazyConfigState(State): - def helper(self): - pass + class LazyConfigState(State): + def helper(self): + pass - assert "helper" not in LazyConfigState.event_handlers - del sys.modules[constants.Config.MODULE] + assert "helper" not in LazyConfigState.event_handlers + finally: + sys.modules.pop(constants.Config.MODULE, None) def test_reload_config_resets_state_flags(tmp_path, forked_registration_context): From 77fe96569817e669d3bd638fcfdf90879510e550 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Thu, 10 Sep 2026 17:38:01 +0200 Subject: [PATCH 4/8] fix: export State-class flags to backend workers via env instead of loading config during State creation --- docs/events/events_overview.md | 2 + .../reflex-base/src/reflex_base/config.py | 32 ++++++++++---- reflex/utils/exec.py | 3 ++ tests/units/test_config.py | 14 ++++++ tests/units/test_state.py | 28 ++++-------- tests/units/utils/test_exec.py | 43 +++++++++++++++++++ 6 files changed, 93 insertions(+), 29 deletions(-) diff --git a/docs/events/events_overview.md b/docs/events/events_overview.md index 72618e88bf3..842e7af7073 100644 --- a/docs/events/events_overview.md +++ b/docs/events/events_overview.md @@ -57,6 +57,8 @@ config = rx.Config( 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. +The option is applied when a state class is created. `reflex run` forwards it to its backend workers; other entry points that import your state modules before the config is loaded, such as a test suite, can set the `REFLEX_STATE_EXPLICIT_EVENT_HANDLERS` environment variable instead. + ## 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/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index c0765607e38..70dfd1d8fbb 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -837,6 +837,14 @@ def _set_persistent(self, **kwargs): self._non_default_attributes.update(kwargs) self._replace_defaults(**kwargs) + def _persist_state_flags(self): + """Export the State-class creation flags to the environment for subprocesses. + + Backend workers import the app module before any config is loaded, so + their user States resolve these flags from the environment. + """ + self._set_persistent(**{name: getattr(self, name) for name in _STATE_FLAGS}) + # Project-local modules first imported while loading rxconfig.py; evicted # before the next load so projects don't reuse each other's dependencies. @@ -906,6 +914,9 @@ def _record_imports() -> Iterator[_ImportRecorder]: # Protect sys.path from concurrent modification during config loading. _load_config_lock = threading.RLock() +# Config fields resolved at State-class creation time via _get_state_flag. +_STATE_FLAGS = ("state_auto_setters", "state_explicit_event_handlers") + @dataclasses.dataclass class _ConfigLoad: @@ -920,12 +931,12 @@ class _ConfigLoad: def _get_state_flag(name: str) -> bool: - """Resolve a boolean State-class creation flag. + """Resolve a boolean State-class creation flag without loading rxconfig. - Outside an rxconfig.py import, reads the context's Config, loading it on demand - (backend workers define user States before anything calls get_config()). During - the import, uses the Config built so far, else the REFLEX_ env var, then - False, so config loading is never re-entered. + Uses the Config of the in-progress rxconfig.py import, else the Config cached + on the active RegistrationContext, else the REFLEX_ env var (which + `reflex run` sets for its backend workers), then False. Never loads rxconfig, + since an rxconfig.py may import the very module defining the State. Args: name: The config field name. @@ -934,10 +945,13 @@ def _get_state_flag(name: str) -> bool: The resolved flag value. """ load = _config_load.get() - if load is None: - return getattr(get_config(), name) - if load.config is not None: - return getattr(load.config, name) + 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, name) diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index c93b11949c3..86440a279b9 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -496,6 +496,8 @@ def run_backend( if not frontend_present: notify_backend(host) + get_config()._persist_state_flags() + # Run the backend in development mode. if should_use_granian(): # We import reflex app because this lets granian cache the module @@ -710,6 +712,7 @@ def run_backend_prod( mount_frontend_compiled_app: Whether to mount the compiled frontend app with the backend. """ environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.set(mount_frontend_compiled_app) + get_config()._persist_state_flags() if should_use_granian(): run_granian_backend_prod(host, port, loglevel) diff --git a/tests/units/test_config.py b/tests/units/test_config.py index bf82d5415b8..07cf412486a 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -1361,3 +1361,17 @@ def test_get_config_reload_deprecated(mocker: MockerFixture): # The freshly loaded config stays cached on the context afterwards. assert reflex_base.config.get_config() is second deprecate.assert_called_once() + + +def test_persist_state_flags(monkeypatch): + """_persist_state_flags exports both State-class flags to the environment.""" + mock_os_env = os.environ.copy() + mock_os_env.pop("REFLEX_STATE_EXPLICIT_EVENT_HANDLERS", None) + mock_os_env.pop("REFLEX_STATE_AUTO_SETTERS", None) + monkeypatch.setattr(os, "environ", mock_os_env) + config = rx.Config( + app_name="app", state_explicit_event_handlers=True, _skip_plugins_checks=True + ) + config._persist_state_flags() + assert os.environ["REFLEX_STATE_EXPLICIT_EVENT_HANDLERS"] == "True" + assert os.environ["REFLEX_STATE_AUTO_SETTERS"] == "False" diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 5a0bf2ffeb3..3d64f433277 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -4058,33 +4058,21 @@ def helper(self) -> int: assert "on_load_internal" in OnLoadInternalState.event_handlers -def test_state_flags_load_config_on_demand(tmp_path): - """A State defined before any config load resolves its flags from rxconfig.py. +def test_state_flags_from_env_without_config(monkeypatch): + """A State defined before any config load honors the env var `reflex run` exports. Backend workers import the app module in a fresh process, so user States are created before anything calls get_config(). """ - 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) - """ - ) - ) + monkeypatch.setenv("REFLEX_STATE_EXPLICIT_EVENT_HANDLERS", "true") - try: - with chdir(proj_root), RegistrationContext(): + with RegistrationContext(): - class LazyConfigState(State): - def helper(self): - pass + class WorkerState(State): + def helper(self): + pass - assert "helper" not in LazyConfigState.event_handlers - finally: - sys.modules.pop(constants.Config.MODULE, None) + assert "helper" not in WorkerState.event_handlers def test_reload_config_resets_state_flags(tmp_path, forked_registration_context): diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index 5dfc677c094..1c4aca5d166 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_exec.py @@ -5,7 +5,9 @@ import pytest from pytest_mock import MockerFixture +from reflex_base.config import Config from reflex_base.environment import environment +from reflex_base.registry import RegistrationContext from reflex.utils import exec as exec_utils @@ -116,3 +118,44 @@ def test_arbitrate_ssr_env_var_wins(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv(environment.REFLEX_SSR.name, "False") assert exec_utils.arbitrate_ssr(True) is False + + +@pytest.mark.parametrize( + ("launcher", "runner"), + [ + ("run_backend", "run_uvicorn_backend"), + ("run_backend_prod", "run_uvicorn_backend_prod"), + ], +) +def test_backend_launchers_persist_state_flags( + launcher: str, + runner: str, + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + forked_registration_context: RegistrationContext, +): + """Backend launchers export the State-class flags before workers import the app.""" + mock_os_env = os.environ.copy() + mock_os_env.pop("REFLEX_STATE_EXPLICIT_EVENT_HANDLERS", None) + mock_os_env.pop("REFLEX_STATE_AUTO_SETTERS", None) + monkeypatch.setattr(os, "environ", mock_os_env) + forked_registration_context._set_config( + Config( + app_name="app", + state_explicit_event_handlers=True, + _skip_plugins_checks=True, + ) + ) + mocker.patch.object(exec_utils, "should_use_granian", return_value=False) + mocker.patch.object(exec_utils, "get_web_dir", return_value=Path("/nonexistent")) + seen: dict[str, str | None] = {} + + def fake_run(*_args, **_kwargs): + seen["explicit"] = os.environ.get("REFLEX_STATE_EXPLICIT_EVENT_HANDLERS") + seen["setters"] = os.environ.get("REFLEX_STATE_AUTO_SETTERS") + + mocker.patch.object(exec_utils, runner, side_effect=fake_run) + + getattr(exec_utils, launcher)("0.0.0.0", 8000, exec_utils.LogLevel.INFO, True) + + assert seen == {"explicit": "True", "setters": "False"} From 1e31e0ce97bb9865683caa2cbae3f20f8611818f Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 27 Sep 2026 11:59:39 +0200 Subject: [PATCH 5/8] Raise a targeted error when an undecorated state method is wired to a trigger --- .../src/reflex_base/event/__init__.py | 30 ++++++++++++++++++- reflex/state.py | 24 +++++++++------ tests/units/test_state.py | 15 ++++++++++ 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 3fa7d0d132c..3dda28e872f 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -296,6 +296,7 @@ def _scan_detach(value: Any, memo: dict[int, Any], active: set[int]) -> Any: SUPERSEDES_MARKER = "_reflex_supersedes" EVENT_ACTIONS_MARKER = "_rx_event_actions" EVENT_MARKER = "_rx_event" +UNDECORATED_STATE_METHOD_MARKER = "_rx_undecorated_state_method" UPLOAD_FILES_CLIENT_HANDLER = "uploadFiles" # Payload key listing the names of the extra bound handler args in an upload @@ -2295,6 +2296,28 @@ def check_fn_match_arg_spec( raise EventFnArgMismatchError(msg) +def _check_not_undecorated_state_method(value: Any, key: str | None) -> None: + """Reject a state method that explicit event handler mode left undecorated. + + Args: + value: The value wired to the event trigger. + key: The name of the event trigger. + + Raises: + EventHandlerValueError: If value is an undecorated state method. + """ + if getattr(value, UNDECORATED_STATE_METHOD_MARKER, False): + from reflex_base.utils.exceptions import EventHandlerValueError + + msg = ( + f"{value.__qualname__} is wired to {key or 'an event trigger'} but is not an " + "event handler. Decorate it with `@rx.event`: with " + "`state_explicit_event_handlers` enabled, undecorated methods stay plain " + "methods." + ) + raise EventHandlerValueError(msg) + + def call_event_fn( fn: Callable, arg_spec: ArgsSpec | Sequence[ArgsSpec], @@ -2314,12 +2337,15 @@ def call_event_fn( The event-like values from calling the function. Raises: - EventHandlerValueError: If the lambda returns an unusable value. + EventHandlerValueError: If fn is an undecorated state method or returns + an unusable value. """ # Import here to avoid circular imports. from reflex_base.event import EventHandler, EventSpec from reflex_base.utils.exceptions import EventHandlerValueError + _check_not_undecorated_state_method(fn, key) + parsed_args, _ = parse_args_spec(arg_spec) parameters = inspect.signature(fn).parameters @@ -2410,6 +2436,7 @@ def _dispatch_mixed_event_var(event_like_var: Var) -> FunctionVar: # Make sure the event spec is valid. if not isinstance(e, (EventSpec, FunctionVar, EventVar)): + _check_not_undecorated_state_method(e, key) hint = "" if isinstance(e, VarOperationCall): hint = " Hint: use `fn.partial(...)` instead of calling the FunctionVar directly." @@ -2961,6 +2988,7 @@ class EventNamespace: SUPERSEDES_MARKER = SUPERSEDES_MARKER EVENT_ACTIONS_MARKER = EVENT_ACTIONS_MARKER EVENT_MARKER = EVENT_MARKER + UNDECORATED_STATE_METHOD_MARKER = UNDECORATED_STATE_METHOD_MARKER _EVENT_FIELDS = _EVENT_FIELDS FORM_DATA = FORM_DATA FORM_SUBMIT_MAPPING = FORM_SUBMIT_MAPPING diff --git a/reflex/state.py b/reflex/state.py index 5bcbe8d9941..d837bd69063 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -39,6 +39,7 @@ from reflex_base.event import ( EVENT_ACTIONS_MARKER, EVENT_MARKER, + UNDECORATED_STATE_METHOD_MARKER, Event, EventHandler, EventSpec, @@ -1163,19 +1164,24 @@ def _item_is_event_handler(name: str, value: Any, explicit: bool = False) -> boo Args: name: The name of the item. value: The value of the item. - explicit: Only accept functions decorated with `@rx.event`. + explicit: Only accept functions decorated with `@rx.event`; an + undecorated one is marked so wiring it to a trigger fails clearly. Returns: Whether the item is an event handler. """ - return ( - not name.startswith("_") - and isinstance(value, Callable) - 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)) - ) + if ( + name.startswith("_") + or not isinstance(value, Callable) + or isinstance(value, EventHandler) + or getattr(value, "__override_base_method__", False) + or not hasattr(value, "__code__") + ): + return False + if explicit and not getattr(value, EVENT_MARKER, False): + setattr(value, UNDECORATED_STATE_METHOD_MARKER, True) + return False + return True @classmethod def _evaluate(cls, f: Callable[[Self], Any], of_type: type | None = None) -> Var: diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 7ec4d05cd59..003cd38fab8 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -33,6 +33,7 @@ from reflex_base.utils import format, types from reflex_base.utils.exceptions import ( BaseVarShadowsInheritedVarError, + EventHandlerValueError, InvalidLockWarningThresholdError, LockExpiredError, ReflexRuntimeError, @@ -4935,6 +4936,20 @@ def helper(self) -> int: assert isinstance(ExplicitState.mixin_helper, FunctionType) assert ExplicitState(_reflex_internal_init=True).helper() == 1 # pyright: ignore [reportCallIssue] + # Wiring an undecorated method to a trigger names the missing decorator + # instead of calling it with the component as `self`. + for trigger in ( + ExplicitState.helper, + ExplicitState.mixin_helper, + lambda: ExplicitState.helper, + lambda: ExplicitState.mixin_helper, + ): + with pytest.raises( + EventHandlerValueError, + match=r"@rx\.event.*state_explicit_event_handlers", + ): + rx.button(on_click=trigger) + # Built-in states are unaffected. assert "on_load_internal" in OnLoadInternalState.event_handlers From a80e5a1558637cd20a1b45d749325dc0f94a1eee Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 27 Sep 2026 12:11:49 +0200 Subject: [PATCH 6/8] Only mark undecorated methods defined in the state class body --- reflex/state.py | 8 ++++++-- tests/units/test_state.py | 23 ++++++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/reflex/state.py b/reflex/state.py index 7977faf28af..17231b7657a 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -833,7 +833,8 @@ def _item_is_event_handler(name: str, value: Any, explicit: bool = False) -> boo name: The name of the item. value: The value of the item. explicit: Only accept functions decorated with `@rx.event`; an - undecorated one is marked so wiring it to a trigger fails clearly. + undecorated method of the class body is marked so wiring it to a + trigger fails clearly. Returns: Whether the item is an event handler. @@ -847,7 +848,10 @@ def _item_is_event_handler(name: str, value: Any, explicit: bool = False) -> boo ): return False if explicit and not getattr(value, EVENT_MARKER, False): - setattr(value, UNDECORATED_STATE_METHOD_MARKER, True) + # A callback defined elsewhere may be shared, so leave it unmarked. + owner, _, fn_name = value.__qualname__.rpartition(".") + if fn_name == name and owner and not owner.endswith(""): + setattr(value, UNDECORATED_STATE_METHOD_MARKER, True) return False return True diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 08bf241f4d3..e7a6b857575 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -27,7 +27,7 @@ from reflex_base import constants from reflex_base.constants import CompileVars, RouteVar from reflex_base.constants.state import FIELD_MARKER -from reflex_base.event import Event, EventHandler +from reflex_base.event import Event, EventHandler, EventSpec from reflex_base.event.context import EventContext from reflex_base.event.processor import BaseStateEventProcessor from reflex_base.registry import RegistrationContext @@ -4868,6 +4868,15 @@ class TestState(State): assert "setvar" in TestState.event_handlers +def shared_explicit_callback() -> EventSpec: + """A module-level callback that a state also exposes as an attribute. + + Returns: + The event to run. + """ + return rx.console_log("shared") + + 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" @@ -4895,8 +4904,16 @@ def mixin_handler(self): def mixin_helper(self) -> int: return 1 + # Named like the attribute it is exposed as, like a class-body method. + def made_callback() -> EventSpec: + return rx.console_log("made") + + made = made_callback + class ExplicitState(ExplicitMixin, State): num: int = 0 + shared_explicit_callback = shared_explicit_callback + made_callback = made @rx.event def handler(self): @@ -4936,6 +4953,10 @@ def helper(self) -> int: ): rx.button(on_click=trigger) + # A callback the state merely exposes stays usable as a trigger elsewhere. + rx.button(on_click=shared_explicit_callback) + rx.button(on_click=made_callback) + # Built-in states are unaffected. assert "on_load_internal" in OnLoadInternalState.event_handlers From 5fa41a12474a77ca93f4e5d06178da1e14204ed8 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 27 Sep 2026 12:27:34 +0200 Subject: [PATCH 7/8] Mark only undecorated methods defined in the owning class body --- reflex/state.py | 25 ++++++++++++++++--------- tests/units/test_state.py | 8 ++++++++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/reflex/state.py b/reflex/state.py index 17231b7657a..a5bd8f307e0 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -717,7 +717,7 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs): # Set up the event handlers. for name, fn in list(cls.__dict__.items()): - if cls._item_is_event_handler(name, fn, explicit): + if cls._item_is_event_handler(name, fn, cls if explicit else None): handler = cls._create_event_handler(fn) cls.event_handlers[name] = handler setattr(cls, name, handler) @@ -781,7 +781,9 @@ def _bind_mixin_members(cls, explicit: bool) -> None: _var_data=VarData.from_state(cls), ), ) - elif cls._item_is_event_handler(name, value, explicit): + elif cls._item_is_event_handler( + name, value, mixin_cls if explicit else None + ): fn = cls._copy_fn(value) fn.__qualname__ = f"{cls.__name__}.{name}" setattr(cls, name, fn) @@ -826,15 +828,17 @@ def _copy_fn(fn: Callable) -> Callable: return newfn @staticmethod - def _item_is_event_handler(name: str, value: Any, explicit: bool = False) -> bool: + def _item_is_event_handler( + name: str, value: Any, explicit_owner: type | None = None + ) -> 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`; an - undecorated method of the class body is marked so wiring it to a - trigger fails clearly. + explicit_owner: The class holding the item when only functions + decorated with `@rx.event` count; an undecorated method defined + in its body is marked so wiring it to a trigger fails clearly. Returns: Whether the item is an event handler. @@ -847,10 +851,12 @@ def _item_is_event_handler(name: str, value: Any, explicit: bool = False) -> boo or not hasattr(value, "__code__") ): return False - if explicit and not getattr(value, EVENT_MARKER, False): + if explicit_owner is not None and not getattr(value, EVENT_MARKER, False): # A callback defined elsewhere may be shared, so leave it unmarked. - owner, _, fn_name = value.__qualname__.rpartition(".") - if fn_name == name and owner and not owner.endswith(""): + owner_qualname = explicit_owner.__dict__.get( + "__original_qualname__", explicit_owner.__qualname__ + ) + if value.__qualname__ == f"{owner_qualname}.{name}": setattr(value, UNDECORATED_STATE_METHOD_MARKER, True) return False return True @@ -936,6 +942,7 @@ def _handle_local_def(cls): proposed_name = f"{cls.__name__}_{ix}" setattr(reflex.istate.dynamic, proposed_name, cls) cls.__original_name__ = cls.__name__ + cls.__original_qualname__ = cls.__qualname__ cls.__original_module__ = cls.__module__ cls.__name__ = cls.__qualname__ = proposed_name cls.__module__ = reflex.istate.dynamic.__name__ diff --git a/tests/units/test_state.py b/tests/units/test_state.py index e7a6b857575..c940b503155 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -4910,10 +4910,16 @@ def made_callback() -> EventSpec: made = made_callback + class ExplicitCallbacks: + @staticmethod + def refresh() -> EventSpec: + return rx.console_log("refresh") + class ExplicitState(ExplicitMixin, State): num: int = 0 shared_explicit_callback = shared_explicit_callback made_callback = made + refresh = ExplicitCallbacks.refresh @rx.event def handler(self): @@ -4956,6 +4962,8 @@ def helper(self) -> int: # A callback the state merely exposes stays usable as a trigger elsewhere. rx.button(on_click=shared_explicit_callback) rx.button(on_click=made_callback) + rx.button(on_click=ExplicitCallbacks.refresh) + rx.button(on_click=ExplicitState.refresh) # Built-in states are unaffected. assert "on_load_internal" in OnLoadInternalState.event_handlers From b9137c6a367a1a7a17f06ec3b90136d837f2afe6 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Tue, 29 Sep 2026 13:30:49 +0200 Subject: [PATCH 8/8] Keep the event handler predicate free of side effects Move marking undecorated state methods out of _item_is_event_handler into _mark_undecorated_methods, which __init_subclass__ runs once in explicit mode for the state and its mixins. --- reflex/state.py | 59 +++++++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/reflex/state.py b/reflex/state.py index a5bd8f307e0..bdc0afedee5 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -684,6 +684,8 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs): explicit = cls.is_user_defined() and get_state_explicit_event_handlers() cls._bind_fields() cls._bind_mixin_members(explicit) + if explicit: + cls._mark_undecorated_methods() # Set the base and computed vars. cls.base_vars = { @@ -717,7 +719,7 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs): # Set up the event handlers. for name, fn in list(cls.__dict__.items()): - if cls._item_is_event_handler(name, fn, cls if explicit else None): + if cls._item_is_event_handler(name, fn, explicit): handler = cls._create_event_handler(fn) cls.event_handlers[name] = handler setattr(cls, name, handler) @@ -781,9 +783,7 @@ def _bind_mixin_members(cls, explicit: bool) -> None: _var_data=VarData.from_state(cls), ), ) - elif cls._item_is_event_handler( - name, value, mixin_cls if explicit else None - ): + elif cls._item_is_event_handler(name, value, explicit): fn = cls._copy_fn(value) fn.__qualname__ = f"{cls.__name__}.{name}" setattr(cls, name, fn) @@ -828,38 +828,43 @@ def _copy_fn(fn: Callable) -> Callable: return newfn @staticmethod - def _item_is_event_handler( - name: str, value: Any, explicit_owner: type | None = None - ) -> 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_owner: The class holding the item when only functions - decorated with `@rx.event` count; an undecorated method defined - in its body is marked so wiring it to a trigger fails clearly. + explicit: Only count functions decorated with `@rx.event`. Returns: Whether the item is an event handler. """ - if ( - name.startswith("_") - or not isinstance(value, Callable) - or isinstance(value, EventHandler) - or getattr(value, "__override_base_method__", False) - or not hasattr(value, "__code__") - ): - return False - if explicit_owner is not None and not getattr(value, EVENT_MARKER, False): - # A callback defined elsewhere may be shared, so leave it unmarked. - owner_qualname = explicit_owner.__dict__.get( - "__original_qualname__", explicit_owner.__qualname__ - ) - if value.__qualname__ == f"{owner_qualname}.{name}": - setattr(value, UNDECORATED_STATE_METHOD_MARKER, True) - return False - return True + return ( + not name.startswith("_") + and isinstance(value, Callable) + 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 + def _mark_undecorated_methods(cls) -> None: + """Mark the undecorated methods of this class and its mixins. + + Wiring a marked method to an event trigger raises a targeted error. Only + methods defined in the class body are marked: a callback defined + elsewhere may be shared. + """ + for owner in (cls, *cls._mixins()): + prefix = owner.__dict__.get("__original_qualname__", owner.__qualname__) + for name, value in owner.__dict__.items(): + if ( + cls._item_is_event_handler(name, value) + and not getattr(value, EVENT_MARKER, False) + and value.__qualname__ == f"{prefix}.{name}" + ): + setattr(value, UNDECORATED_STATE_METHOD_MARKER, True) @classmethod def _evaluate(cls, f: Callable[[Self], Any], of_type: type | None = None) -> Var: