Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/events/events_overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ 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`:
Comment thread
benedikt-bartscher marked this conversation as resolved.

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

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.
1 change: 1 addition & 0 deletions news/7033.feature.md
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
benedikt-bartscher marked this conversation as resolved.
1 change: 1 addition & 0 deletions packages/reflex-base/news/7033.feature.md
Original file line number Diff line number Diff line change
@@ -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.
98 changes: 77 additions & 21 deletions packages/reflex-base/src/reflex_base/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import urllib.parse
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from contextvars import ContextVar
from importlib.util import find_spec
from pathlib import Path, PureWindowsPath
from types import ModuleType
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -419,10 +423,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:
Comment thread
benedikt-bartscher marked this conversation as resolved.
load.config = self

if (
self.state_manager_mode == constants.StateManagerMode.REDIS
Expand Down Expand Up @@ -833,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})
Comment thread
benedikt-bartscher marked this conversation as resolved.


# Project-local modules first imported while loading rxconfig.py; evicted
# before the next load so projects don't reuse each other's dependencies.
Expand Down Expand Up @@ -902,29 +914,68 @@ 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
# Config fields resolved at State-class creation time via _get_state_flag.
_STATE_FLAGS = ("state_auto_setters", "state_explicit_event_handlers")


def get_state_auto_setters() -> bool:
"""Return whether state auto-setters are enabled, without importing rxconfig.
@dataclasses.dataclass
class _ConfigLoad:
"""An in-progress rxconfig.py import, holding its Config once constructed."""

config: Config | None = None


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
get_config() or imports rxconfig, so it cannot re-enter config loading.
# 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.

Uses the Config of the in-progress rxconfig.py import, else the Config cached
on the active RegistrationContext, else the REFLEX_<NAME> 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.

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.

Expand All @@ -949,6 +1000,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
Expand Down Expand Up @@ -981,6 +1033,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]
Expand Down Expand Up @@ -1045,13 +1098,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
5 changes: 5 additions & 0 deletions packages/reflex-base/src/reflex_base/event/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
12 changes: 8 additions & 4 deletions reflex/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -738,10 +740,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():
Expand All @@ -758,7 +761,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
Expand Down Expand Up @@ -853,12 +856,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.
Expand All @@ -869,6 +873,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
Expand Down Expand Up @@ -1274,7 +1279,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):
Expand Down
3 changes: 3 additions & 0 deletions reflex/utils/exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,8 @@ def run_backend(
if not frontend_present:
notify_backend(host)

get_config()._persist_state_flags()
Comment thread
benedikt-bartscher marked this conversation as resolved.

# Run the backend in development mode.
if should_use_granian():
# We import reflex app because this lets granian cache the module
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions tests/units/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
18 changes: 18 additions & 0 deletions tests/units/test_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading