diff --git a/news/6621.feature.md b/news/6621.feature.md new file mode 100644 index 00000000000..b4c161ff31a --- /dev/null +++ b/news/6621.feature.md @@ -0,0 +1 @@ +`rx._x.hybrid_property` now raises a clear error when its frontend logic reads a backend (underscore-prefixed) state var, instead of silently baking the var's server-side default into the frontend. Reference a regular var, or provide a separate frontend implementation with `@.var`. ([#6621](https://github.com/reflex-dev/reflex/issues/6621)) diff --git a/packages/reflex-base/news/6621.feature.md b/packages/reflex-base/news/6621.feature.md new file mode 100644 index 00000000000..9473f82a856 --- /dev/null +++ b/packages/reflex-base/news/6621.feature.md @@ -0,0 +1 @@ +Added `HybridPropertyError`, raised when a hybrid property's frontend logic accesses a backend (underscore-prefixed) var on a state while building its frontend var. ([#6621](https://github.com/reflex-dev/reflex/issues/6621)) diff --git a/packages/reflex-base/src/reflex_base/utils/exceptions.py b/packages/reflex-base/src/reflex_base/utils/exceptions.py index b8892e0e513..fce7b4d1225 100644 --- a/packages/reflex-base/src/reflex_base/utils/exceptions.py +++ b/packages/reflex-base/src/reflex_base/utils/exceptions.py @@ -284,3 +284,7 @@ class InvalidLockWarningThresholdError(ReflexError): class UnretrievableVarValueError(ReflexError): """Raised when the value of a var is not retrievable.""" + + +class HybridPropertyError(ReflexError): + """Raised when a hybrid property is misused while building its frontend var.""" diff --git a/packages/reflex-base/src/reflex_base/vars/hybrid_property.py b/packages/reflex-base/src/reflex_base/vars/hybrid_property.py index b34350d51b3..00d469526a1 100644 --- a/packages/reflex-base/src/reflex_base/vars/hybrid_property.py +++ b/packages/reflex-base/src/reflex_base/vars/hybrid_property.py @@ -3,11 +3,56 @@ from collections.abc import Callable from typing import Any +from reflex_base.utils.exceptions import HybridPropertyError from reflex_base.utils.types import Self, override from .base import Var +class _StateBackendVarGuard: + """Proxy around a state class used while building a hybrid property's frontend var. + + Attribute access is forwarded to the wrapped state class, except for backend + (underscore-prefixed) vars, which raise :class:`HybridPropertyError`: backend vars + are server-only and cannot be referenced from a hybrid property's frontend logic. + """ + + def __init__(self, state_cls: Any, property_name: str) -> None: + """Initialize the guard. + + Args: + state_cls: The state class the hybrid property is defined on. + property_name: The name of the hybrid property (for error messages). + """ + self.__state_cls = state_cls + self.__property_name = property_name + + def __getattr__(self, name: str) -> Any: + """Forward attribute access to the state class, blocking backend vars. + + Args: + name: The attribute accessed on the state inside the hybrid property. + + Returns: + The class-level value (e.g. a frontend var) from the state. + + Raises: + HybridPropertyError: If a backend (underscore-prefixed) var is accessed. + """ + state_cls = self.__state_cls + if name in state_cls.backend_vars: + msg = ( + f"Hybrid property '{self.__property_name}' of state " + f"'{state_cls.__name__}' accessed backend-only var '{name}' while " + f"building its frontend value. Backend vars (prefixed with '_') exist " + f"only on the server and cannot be referenced from a hybrid property's " + f"frontend logic. Use a regular var, or provide a separate frontend " + f"implementation with '@{self.__property_name}.var'." + ) + raise HybridPropertyError(msg) + return getattr(state_cls, name) + + class HybridProperty(property): """A hybrid property that can also be used in frontend/as var.""" @@ -30,7 +75,9 @@ def _get_var(self, owner: Any) -> Var: Raises: AttributeError: If the property has no getter function and no var function is set. + HybridPropertyError: If the frontend logic accesses a backend-only state var. """ + owner = self._guarded_owner(owner) if self._var is not None: # Call custom var function if set return self._var(owner) @@ -40,6 +87,29 @@ def _get_var(self, owner: Any) -> Var: raise AttributeError(msg) return self.fget(owner) + def _guarded_owner(self, owner: Any) -> Any: + """Wrap a state ``owner`` so backend-var access raises while building the frontend var. + + Non-class owners (e.g. an object var for nested attribute access) have no backend + vars and are returned unchanged. + + Args: + owner: The class or var the property is accessed on. + + Returns: + The owner, wrapped in a backend-var guard when it is a state class. + """ + if not isinstance(owner, type): + return owner + from reflex.state import BaseState + + if issubclass(owner, BaseState): + property_name = ( + self.fget.__name__ if self.fget is not None else "hybrid_property" + ) + return _StateBackendVarGuard(owner, property_name) + return owner + @override def __get__(self, instance: Any, owner: type | None = None, /) -> Any: """Get the value of the property. diff --git a/tests/units/vars/test_hybrid_property.py b/tests/units/vars/test_hybrid_property.py new file mode 100644 index 00000000000..f46ac1ac2be --- /dev/null +++ b/tests/units/vars/test_hybrid_property.py @@ -0,0 +1,81 @@ +"""Unit tests for reflex_base.vars.hybrid_property.""" + +import pytest +from reflex_base.utils.exceptions import HybridPropertyError + +import reflex as rx +from reflex.experimental import hybrid_property +from reflex.vars import Var + + +def test_hybrid_property_getter_backend_var_access_raises(): + """A hybrid property getter that reads a backend var raises when its frontend var is built.""" + + class GetterBackendState(rx.State): + name: str = "pub" + _secret: str = "hidden" + + @hybrid_property + def leaky(self) -> str: + return f"{self.name}-{self._secret}" + + with pytest.raises(HybridPropertyError, match="_secret"): + _ = GetterBackendState.leaky + + +def test_hybrid_property_var_fn_backend_var_access_raises(): + """A hybrid property whose custom .var function reads a backend var raises.""" + + class VarFnBackendState(rx.State): + name: str = "pub" + _secret: str = "hidden" + + @hybrid_property + def value(self) -> str: + return self.name + + @value.var + def value(cls) -> Var[str]: + return cls._secret # pyright: ignore[reportReturnType] + + with pytest.raises(HybridPropertyError, match="_secret"): + _ = VarFnBackendState.value + + +def test_hybrid_property_frontend_var_access_ok(): + """A hybrid property reading only frontend vars builds the expected frontend var.""" + + class FrontendOnlyState(rx.State): + first: str = "a" + last: str = "b" + + @hybrid_property + def full(self) -> str: + return f"{self.first} {self.last}" + + assert str(Var.create(FrontendOnlyState.full)) == str( + Var.create(f"{FrontendOnlyState.first} {FrontendOnlyState.last}") + ) + + +def test_hybrid_property_on_object_var_not_guarded(): + """The guard is State-only; underscore fields on an object var are not affected. + + Underscore-field serialization on dataclasses/models is a separate concern, so a + hybrid property accessed through an object var must not raise here. + """ + from dataclasses import dataclass + + @dataclass + class Info: + a: str + _internal: str = "x" + + @hybrid_property + def combined(self) -> str: + return f"{self.a}-{self._internal}" + + class ObjVarState(rx.State): + info: Info = Info(a="a") + + assert isinstance(Var.create(ObjVarState.info.combined), Var)