Skip to content
Draft
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/state/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ A state class is made up of two parts: vars and event handlers.

**Event handlers** are functions that modify these vars in response to events.

State declarations cannot reuse framework method or bookkeeping names, such as
`get_state`, `_get_was_touched`, or `dirty_vars`. Reflex checks these names when
creating a state class and when adding vars, event handlers, or route arguments
dynamically. Rename a conflicting declaration and update its references. Ordinary
backend names such as `_count` remain supported.

For existing apps, setting `REFLEX_STATE_ALLOW_RESERVED_NAMES=1` before starting
Reflex temporarily restores legacy handling of conflicting vars and emits a
deprecation warning. This compatibility option will be removed in Reflex 1.0.
It preserves the old behavior, including any crashes caused by a collision; rename
the conflicting members to resolve those crashes. Existing restrictions on
overriding framework methods still apply.

These are the main concepts to understand how state works in Reflex:

```python eval
Expand Down
1 change: 1 addition & 0 deletions news/+reserved-state-names.breaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
State vars, event handlers, and dynamic route arguments now reject names reserved by framework methods and bookkeeping before registration. Rename conflicting members; `REFLEX_STATE_ALLOW_RESERVED_NAMES=1` temporarily preserves legacy behavior with a deprecation warning until Reflex 1.0.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add the temporary `REFLEX_STATE_ALLOW_RESERVED_NAMES` compatibility option for apps migrating away from reserved state names. It defaults to false and will be removed in Reflex 1.0.
3 changes: 3 additions & 0 deletions packages/reflex-base/src/reflex_base/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,9 @@ class EnvironmentVariables:
# The maximum size of the reflex state in kilobytes.
REFLEX_STATE_SIZE_LIMIT: EnvVar[int] = env_var(1000)

# Temporary compatibility for state declarations that shadow framework members.
REFLEX_STATE_ALLOW_RESERVED_NAMES: EnvVar[bool] = env_var(False)

# Additional paths to include in the hot reload. Separated by a colon.
REFLEX_HOT_RELOAD_INCLUDE_PATHS: EnvVar[list[Path]] = env_var([])

Expand Down
125 changes: 125 additions & 0 deletions reflex/istate/validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""Validate the framework namespace before constructing or extending a state."""

from functools import cache
from types import FunctionType
from typing import Any

from reflex_base.environment import environment
from reflex_base.utils import console
from reflex_base.utils.compat import annotations_from_namespace
from reflex_base.utils.exceptions import (
EventHandlerShadowsBuiltInStateMethodError,
StateValueError,
)
from reflex_base.vars.base import (
BaseStateMeta,
EvenMoreBasicBaseState,
_linearize_bases,
)

_FIELD_MAP_NAMES = frozenset({"__fields__", "__own_fields__", "__inherited_fields__"})


@cache
def _reserved_state_members() -> dict[str, Any]:
"""Return framework members, excluding state vars and Python protocols.

Returns:
Reserved names and their original descriptors, without invoking them.
"""
# BaseState must exist before its namespace can be inspected.
from reflex.state import BaseState

members = {}
for base in reversed(BaseState.__mro__[:-1]):
namespace = vars(base)
members.update(
(name, namespace.get(name))
for name in namespace.keys() | annotations_from_namespace(namespace).keys()
if not name.startswith("__") or name in _FIELD_MAP_NAMES
)
for name, field in BaseState.__fields__.items():
if field.is_var:
members.pop(name, None)
Comment on lines +41 to +43

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.

P1 Router Remains Shadowable

_reserved_state_members() removes every field marked is_var, but BaseState.router is a framework-owned RouterData field with that flag. A subclass can therefore declare an ordinary router field without being rejected. Instances then initialize router with the user value, causing framework accesses such as self.router._page, self.router.session, or self.router.url to fail with an attribute or type error.

return members


def _validate_state_name(name: str, value: Any = None) -> None:
"""Reject declarations that replace framework methods or bookkeeping.

Args:
name: The declared or dynamically registered name.
value: The raw class declaration, when available.

Raises:
StateValueError: If a declaration uses a reserved name.
EventHandlerShadowsBuiltInStateMethodError: If a method overrides a builtin.
"""
members = _reserved_state_members()
if name not in members:
return
method = value.__func__ if isinstance(value, (classmethod, staticmethod)) else value
if isinstance(method, FunctionType):
if value is members[name] or getattr(method, "__override_base_method__", False):
return
msg = f"The event handler name `{name}` shadows a builtin State method; use a different name instead"
raise EventHandlerShadowsBuiltInStateMethodError(msg)
reason = (
f"State name `{name}` is reserved by BaseState; use a different name instead."
)
if environment.REFLEX_STATE_ALLOW_RESERVED_NAMES.get():
console.deprecate(
feature_name="REFLEX_STATE_ALLOW_RESERVED_NAMES",
reason=reason,
deprecation_version="0.9.12",
removal_version="1.0",
)
return
msg = f"{reason} Set REFLEX_STATE_ALLOW_RESERVED_NAMES=1 temporarily to retain legacy behavior."
raise StateValueError(msg)


class _StateMeta(BaseStateMeta):
"""Check state declarations before field collection and subclass initialization."""

def __new__(
cls,
name: str,
bases: tuple[type, ...],
namespace: dict[str, Any],
mixin: bool = False,
) -> type:
"""Construct a state after checking its declarations and Python mixins.

Args:
name: The class name.
bases: The parent classes.
namespace: The unmodified class namespace.
mixin: Whether the class is a state mixin.

Returns:
The validated state class.
"""
if any(isinstance(base, _StateMeta) for base in bases):
seen = namespace.keys() | annotations_from_namespace(namespace).keys()
for member in seen:
_validate_state_name(member, namespace.get(member))
for base in _linearize_bases(bases):
if not isinstance(base, _StateMeta) and base not in (
EvenMoreBasicBaseState,
object,
):
if isinstance(base, BaseStateMeta):
# Model fields are inherited even when an earlier base
# masks their class attributes in the MRO.
for member in base.__own_fields__:
_validate_state_name(member)
seen.update(base.__own_fields__)
for member, value in vars(base).items():
if member not in seen and not (
isinstance(base, BaseStateMeta)
and (member in _FIELD_MAP_NAMES or member == "_mixin")
):
_validate_state_name(member, value)
seen.update(vars(base))
return super().__new__(cls, name, bases, namespace, mixin=mixin)
59 changes: 18 additions & 41 deletions reflex/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@
ComputedVarShadowsStateVarError,
DynamicComponentInvalidSignatureError,
DynamicRouteArgShadowsStateVarError,
EventHandlerShadowsBuiltInStateMethodError,
ReflexRuntimeError,
SetUndefinedStateVarError,
StateMismatchError,
Expand Down Expand Up @@ -77,6 +76,7 @@
from reflex.istate.proxy import ImmutableMutableProxy as ImmutableMutableProxy
from reflex.istate.proxy import MutableProxy, is_mutable_type
from reflex.istate.storage import ClientStorageBase
from reflex.istate.validation import _StateMeta, _validate_state_name
from reflex.utils import console, format, types
from reflex.utils.exec import is_testing_env

Expand Down Expand Up @@ -426,7 +426,7 @@ def _is_user_descriptor(value: Any) -> bool:
})


class BaseState(EvenMoreBasicBaseState):
class BaseState(EvenMoreBasicBaseState, metaclass=_StateMeta):
"""The state of the app."""

# A map from the var name to the var.
Expand Down Expand Up @@ -616,9 +616,6 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs):
# Validate the module name.
cls._validate_module_name()

# Event handlers should not shadow builtin state methods.
cls._check_overridden_methods()

# Computed vars should not shadow builtin state props.
cls._check_overridden_basevars()

Expand Down Expand Up @@ -825,6 +822,7 @@ def _add_event_handler(
name: The name of the event handler.
fn: The function to call when the event is triggered.
"""
_validate_state_name(name)
handler = cls._create_event_handler(fn)
cls.event_handlers[name] = handler
setattr(cls, name, handler)
Expand Down Expand Up @@ -1059,29 +1057,6 @@ def _iter_functions(cls) -> Iterator[tuple[str, FunctionType]]:
if isinstance(value, FunctionType):
yield name, value

@classmethod
def _check_overridden_methods(cls):
"""Check for shadow methods and raise error if any.

Raises:
EventHandlerShadowsBuiltInStateMethodError: When an event handler shadows an inbuilt state method.
"""
overridden_methods = set()
state_base_functions = cls._get_base_functions()
for name, method in cls._iter_functions():
# Check if the method is overridden and not a dunder method
if (
not name.startswith("__")
and method.__name__ in state_base_functions
and state_base_functions[method.__name__] != method
and not getattr(method, "__override_base_method__", False)
):
overridden_methods.add(method.__name__)

for method_name in overridden_methods:
msg = f"The event handler name `{method_name}` shadows a builtin State method; use a different name instead"
raise EventHandlerShadowsBuiltInStateMethodError(msg)

@classmethod
def _check_overridden_basevars(cls):
"""Check for shadow base vars and raise error if any.
Expand Down Expand Up @@ -1290,6 +1265,19 @@ def _init_var(cls, name: str, prop: Var):
cls._create_setter(name, prop)
cls._set_default_value(name, prop)

@classmethod
@_override_base_method
def add_field(cls, name: str, var: Var, default_value: Any):
"""Validate a dynamically added field before updating the field map.

Args:
name: The name of the field to add.
var: The variable to add a field for.
default_value: The default value of the field.
"""
_validate_state_name(name)
super().add_field(name, var, default_value)

@classmethod
def add_var(cls, name: str, type_: Any, default_value: Any = None):
"""Add dynamically a variable to the State.
Expand Down Expand Up @@ -1442,19 +1430,6 @@ def _get_var_default(cls, name: str, annotation_value: Any) -> Any:
except TypeError:
return None

@staticmethod
def _get_base_functions() -> builtins.dict[str, FunctionType]:
"""Get all functions of the state class excluding dunder methods.

Returns:
The functions of rx.State class as a dict.
"""
return {
func[0]: func[1]
for func in inspect.getmembers(BaseState, predicate=inspect.isfunction)
if not func[0].startswith("__")
}

@classmethod
def _update_substate_inherited_vars(cls, vars_to_add: builtins.dict[str, Var]):
"""Update the inherited vars of substates recursively when new vars are added.
Expand Down Expand Up @@ -1507,6 +1482,8 @@ def setup_dynamic_args(cls, args: builtins.dict[str, str]):
if not args:
return

for name in args:
_validate_state_name(name)
cls._check_overwritten_dynamic_args(list(args.keys()))

def argsingle_factory(param: str):
Expand Down
19 changes: 16 additions & 3 deletions tests/units/istate/test_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -1003,17 +1003,22 @@ def timed(self) -> int:
assert IntervalState._interval_computed_var_names == frozenset({"timed"})


def test_fast_path_skips_names_a_subclass_defines():
def test_fast_path_skips_names_a_subclass_defines(monkeypatch: pytest.MonkeyPatch):
"""A subclass defining a fast-pathed framework name keeps the full lookup for it.

The fast path bypasses var resolution, so it must not apply to a name the
state itself defines (here a marked override of a BaseState method, and a
backend var named like a framework method). The class is a detached root
(not a substate of ``State``) so the shadowed method never reaches the
framework paths that other tests exercise on the shared state tree.

Args:
monkeypatch: Enable legacy reserved names for this lookup regression.
"""
from reflex.state import BaseState

monkeypatch.setenv("REFLEX_STATE_ALLOW_RESERVED_NAMES", "1")

def get_value(self, key: str):
return f"shadow:{key}"

Expand All @@ -1039,12 +1044,20 @@ def get_value(self, key: str):
assert state._get_was_touched == 7


def test_fast_path_prunes_names_registered_after_class_creation():
"""Vars and handlers added after class creation also leave the fast path."""
def test_fast_path_prunes_names_registered_after_class_creation(
monkeypatch: pytest.MonkeyPatch,
):
"""Vars and handlers added after class creation also leave the fast path.

Args:
monkeypatch: Enable legacy reserved names for this lookup regression.
"""
from reflex_base.constants import RouteArgType

from reflex.state import BaseState

monkeypatch.setenv("REFLEX_STATE_ALLOW_RESERVED_NAMES", "1")

DynamicState = type(
"DynamicState",
(BaseState,),
Expand Down
Loading
Loading