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
11 changes: 11 additions & 0 deletions docs/events/events_overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Might also become default one day, following the Zen of Python

Explicit is better than implicit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yeah, i wanted to go the same route as i did with state_auto_setters.

  1. introduce a flag to enable explicit mode - let feature stabilize and some projects adopt
  2. change the default to explicit
  3. drop implicit mode


```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.
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.
86 changes: 66 additions & 20 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
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 @@ -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:
Comment thread
benedikt-bartscher marked this conversation as resolved.
load.config = self

if (
self.state_manager_mode == constants.StateManagerMode.REDIS
Expand Down Expand Up @@ -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_<NAME> 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.

Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
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 @@ -720,10 +722,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 @@ -740,7 +743,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 @@ -835,12 +838,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 @@ -851,6 +855,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 @@ -1232,7 +1237,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
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