Skip to content
Merged
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
1 change: 1 addition & 0 deletions news/6621.feature.md
Original file line number Diff line number Diff line change
@@ -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 `@<name>.var`. ([#6621](https://github.com/reflex-dev/reflex/issues/6621))
1 change: 1 addition & 0 deletions packages/reflex-base/news/6621.feature.md
Original file line number Diff line number Diff line change
@@ -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))
4 changes: 4 additions & 0 deletions packages/reflex-base/src/reflex_base/utils/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
66 changes: 62 additions & 4 deletions packages/reflex-base/src/reflex_base/vars/hybrid_property.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -57,27 +102,40 @@ def __get__(self, instance: Any, owner: type | None = None, /) -> Any:

Returns:
The property value, a frontend Var, or the descriptor itself.

Raises:
HybridPropertyError: If the frontend logic reads a backend-only state var.
"""
if instance is not None:
return super().__get__(instance, owner)
if isinstance(owner, type):
from reflex.state import BaseState

if issubclass(owner, BaseState):
return self._get_var(owner)
if not owner.backend_vars:
return self._get_var(owner)
property_name = (
self.fget.__name__ if self.fget is not None else "hybrid_property"
)
return self._get_var(_StateBackendVarGuard(owner, property_name))
return self

def var(self, func: Callable[[Any], Var]) -> Self:
"""Set the (optional) var function for the property.

Returns a new HybridProperty with the same getter/setter/deleter so
that each class gets its own descriptor — matching how property.setter
behaves and preventing shared-mixin mutation across subclasses.

Args:
func: The var function to set.

Returns:
The property instance with the var function set.
A new property instance with the var function set.
"""
self._var = func
return self
new = type(self)(self.fget, self.fset, self.fdel, self.__doc__)
new._var = func
return new


hybrid_property = HybridProperty
111 changes: 111 additions & 0 deletions tests/units/vars/test_hybrid_property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""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_var_returns_new_descriptor():
"""var() must return a new descriptor, not mutate self, so mixin inheritance is safe."""

class Mixin:
@hybrid_property
def full(self) -> str:
return ""

original = Mixin.__dict__["full"]

class StateA(Mixin, rx.State):
first: str = "a"
last: str = "b"

@Mixin.full.var
def full(cls) -> Var:
return cls.first # pyright: ignore[reportReturnType]

class StateB(Mixin, rx.State):
first: str = "x"
last: str = "y"

# var() must have produced a new object
assert StateA.__dict__["full"] is not original
# The mixin's descriptor must be unmodified
assert original._var is None
# StateB inherits the unmodified descriptor — no _var leak
assert StateB.__dict__.get("full") is None or StateB.__dict__["full"]._var is None


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)
Loading