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
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."""
70 changes: 70 additions & 0 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 All @@ -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)
Expand All @@ -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.
Expand Down
81 changes: 81 additions & 0 deletions tests/units/vars/test_hybrid_property.py
Original file line number Diff line number Diff line change
@@ -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)
Loading