diff --git a/docs/events/events_overview.md b/docs/events/events_overview.md index 2b02ef75b54..842e7af7073 100644 --- a/docs/events/events_overview.md +++ b/docs/events/events_overview.md @@ -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`: + +```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. 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 b2e574ef425..70dfd1d8fbb 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, PureWindowsPath 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 @@ -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: + load.config = self if ( self.state_manager_mode == constants.StateManagerMode.REDIS @@ -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}) + # Project-local modules first imported while loading rxconfig.py; evicted # before the next load so projects don't reuse each other's dependencies. @@ -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_ 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. @@ -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 @@ -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] @@ -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 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 1ea080cc863..6d4ef7e2dc8 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, @@ -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(): @@ -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 @@ -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. @@ -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 @@ -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): 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_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 37c0ed2fc4c..3d64f433277 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,226 @@ 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_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(). + """ + monkeypatch.setenv("REFLEX_STATE_EXPLICIT_EVENT_HANDLERS", "true") + + with RegistrationContext(): + + class WorkerState(State): + def helper(self): + pass + + assert "helper" not in WorkerState.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 +4255,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 +4283,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" 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"}