From 958399a10e743d706ee129a49e6b7252e13aad72 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 20:25:50 +0000 Subject: [PATCH 1/4] feat: support hybrid_property on object vars (dataclass/model) `rx._x.hybrid_property` previously only resolved as a frontend var when accessed directly on a `State` class. Accessing it through an object var (e.g. `State.info.a_b` where `info` is a dataclass, pydantic model or SQLAlchemy model) raised `VarAttributeError`. `ObjectVar.__getattr__` now detects a `HybridProperty` defined on the underlying type and evaluates its frontend logic with the object var substituted as `self`, so it renders with the same Var-access semantics as accessing the hybrid property directly on the state. This works uniformly across bare classes, pydantic models, SQLAlchemy models and dataclasses, since they are all treated as object vars. `HybridProperty` moved to `reflex_base.vars.hybrid_property` (so the var system can reference it without an inverted dependency) and is still re-exported from `reflex.experimental.hybrid_property`. Fixes #6617 https://claude.ai/code/session_01DKFiYGnWRQG8wMNKFW7obm --- news/6617.feature.md | 1 + packages/reflex-base/news/6617.feature.md | 1 + .../src/reflex_base/vars/hybrid_property.py | 71 ++++++++++++++ .../src/reflex_base/vars/object.py | 11 +++ reflex/experimental/hybrid_property.py | 54 +---------- tests/integration/test_hybrid_properties.py | 47 +++++++++ tests/units/vars/test_object.py | 96 ++++++++++++++++++- 7 files changed, 225 insertions(+), 56 deletions(-) create mode 100644 news/6617.feature.md create mode 100644 packages/reflex-base/news/6617.feature.md create mode 100644 packages/reflex-base/src/reflex_base/vars/hybrid_property.py diff --git a/news/6617.feature.md b/news/6617.feature.md new file mode 100644 index 00000000000..c6f9f7f276b --- /dev/null +++ b/news/6617.feature.md @@ -0,0 +1 @@ +`rx._x.hybrid_property` now works on dataclasses, pydantic models and SQLAlchemy models, not just `State` classes. Accessing the property through an object var on the frontend (e.g. `State.info.a_b`) renders it as a var, using the same code you already use on the backend. ([#6617](https://github.com/reflex-dev/reflex/issues/6617)) diff --git a/packages/reflex-base/news/6617.feature.md b/packages/reflex-base/news/6617.feature.md new file mode 100644 index 00000000000..765fc10326e --- /dev/null +++ b/packages/reflex-base/news/6617.feature.md @@ -0,0 +1 @@ +`ObjectVar` attribute access now resolves `HybridProperty` descriptors defined on the underlying type, evaluating the property's frontend logic with the object var substituted as `self`. `HybridProperty` moved to `reflex_base.vars.hybrid_property` (still re-exported from `reflex.experimental.hybrid_property`). ([#6617](https://github.com/reflex-dev/reflex/issues/6617)) diff --git a/packages/reflex-base/src/reflex_base/vars/hybrid_property.py b/packages/reflex-base/src/reflex_base/vars/hybrid_property.py new file mode 100644 index 00000000000..58b6f374487 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/vars/hybrid_property.py @@ -0,0 +1,71 @@ +"""hybrid_property decorator which functions like a normal python property but additionally allows (class-level) access from the frontend. You can use the same code for frontend and backend, or implement 2 different methods.""" + +from collections.abc import Callable +from typing import Any + +from reflex_base.utils.types import Self, override + +from .base import Var + + +class HybridProperty(property): + """A hybrid property that can also be used in frontend/as var.""" + + # The optional var function for the property. + _var: Callable[[Any], Var] | None = None + + def _get_var(self, owner: Any) -> Var: + """Get the frontend Var for the property. + + The ``owner`` is the object the property is accessed on at the var level: + either the class (for class-level access, e.g. ``State.full_name``) or an + ``ObjectVar`` (for attribute access on an object var, e.g. ``State.info.a_b``). + Attribute access on ``owner`` inside the getter/var function resolves to Vars. + + Args: + owner: The class or var the property is accessed on. + + Returns: + The frontend Var for the property. + + Raises: + AttributeError: If the property has no getter function and no var function is set. + """ + if self._var is not None: + # Call custom var function if set + return self._var(owner) + # Call the property getter function if no custom var function is set + if self.fget is None: + msg = "HybridProperty has no getter function" + raise AttributeError(msg) + return self.fget(owner) + + @override + def __get__(self, instance: Any, owner: type | None = None, /) -> Any: + """Get the value of the property. If the property is not bound to an instance return a frontend Var. + + Args: + instance: The instance of the class accessing this property. + owner: The class that this descriptor is attached to. + + Returns: + The value of the property or a frontend Var. + """ + if instance is not None: + return super().__get__(instance, owner) + return self._get_var(owner) + + def var(self, func: Callable[[Any], Var]) -> Self: + """Set the (optional) var function for the property. + + Args: + func: The var function to set. + + Returns: + The property instance with the var function set. + """ + self._var = func + return self + + +hybrid_property = HybridProperty diff --git a/packages/reflex-base/src/reflex_base/vars/object.py b/packages/reflex-base/src/reflex_base/vars/object.py index 521771272de..2e0cdbc9bee 100644 --- a/packages/reflex-base/src/reflex_base/vars/object.py +++ b/packages/reflex-base/src/reflex_base/vars/object.py @@ -4,6 +4,7 @@ import collections.abc import dataclasses +import inspect import typing from collections.abc import Mapping from importlib.util import find_spec @@ -331,6 +332,16 @@ def __getattr__(self, name: str) -> Var: fixed_type = get_origin(var_type) or var_type + if isinstance(fixed_type, type): + from .hybrid_property import HybridProperty + + # A HybridProperty on the underlying type resolves to a frontend Var with + # this object var substituted as `self`, so e.g. `State.info.a_b` uses the + # same Var-access semantics as accessing the hybrid property directly. + descriptor = inspect.getattr_static(fixed_type, name, None) + if isinstance(descriptor, HybridProperty): + return descriptor._get_var(self) + if ( is_typeddict(fixed_type) or ( diff --git a/reflex/experimental/hybrid_property.py b/reflex/experimental/hybrid_property.py index dfcda68e2ca..ad9dd009924 100644 --- a/reflex/experimental/hybrid_property.py +++ b/reflex/experimental/hybrid_property.py @@ -1,54 +1,4 @@ """hybrid_property decorator which functions like a normal python property but additionally allows (class-level) access from the frontend. You can use the same code for frontend and backend, or implement 2 different methods.""" -from collections.abc import Callable -from typing import Any - -from reflex.utils.types import Self, override -from reflex.vars.base import Var - - -class HybridProperty(property): - """A hybrid property that can also be used in frontend/as var.""" - - # The optional var function for the property. - _var: Callable[[Any], Var] | None = None - - @override - def __get__(self, instance: Any, owner: type | None = None, /) -> Any: - """Get the value of the property. If the property is not bound to an instance return a frontend Var. - - Args: - instance: The instance of the class accessing this property. - owner: The class that this descriptor is attached to. - - Returns: - The value of the property or a frontend Var. - - Raises: - AttributeError: If the property has no getter function and no var function is set. - """ - if instance is not None: - return super().__get__(instance, owner) - if self._var is not None: - # Call custom var function if set - return self._var(owner) - # Call the property getter function if no custom var function is set - if self.fget is None: - msg = "HybridProperty has no getter function" - raise AttributeError(msg) - return self.fget(owner) - - def var(self, func: Callable[[Any], Var]) -> Self: - """Set the (optional) var function for the property. - - Args: - func: The var function to set. - - Returns: - The property instance with the var function set. - """ - self._var = func - return self - - -hybrid_property = HybridProperty +from reflex_base.vars.hybrid_property import HybridProperty as HybridProperty +from reflex_base.vars.hybrid_property import hybrid_property as hybrid_property diff --git a/tests/integration/test_hybrid_properties.py b/tests/integration/test_hybrid_properties.py index 7cb40588522..ae130115ee3 100644 --- a/tests/integration/test_hybrid_properties.py +++ b/tests/integration/test_hybrid_properties.py @@ -13,13 +13,32 @@ def HybridProperties(): """Test app for hybrid properties.""" + from dataclasses import dataclass + import reflex as rx from reflex.experimental import hybrid_property from reflex.vars import Var + @dataclass + class Info: + """A nested dataclass exposing a hybrid property.""" + + a: str + b: str + + @hybrid_property + def a_b(self) -> str: + """Combine the two fields, usable on both frontend and backend. + + Returns: + str: The two fields joined with a dash. + """ + return f"{self.a} - {self.b}" + class State(rx.State): first_name: str = "John" last_name: str = "Doe" + info: Info = Info(a="a", b="b") @property def python_full_name(self) -> str: @@ -84,6 +103,15 @@ def update_last_name(self, value: str): """ self.last_name = value + @rx.event + def update_info_a(self, value: str): + """Update the `a` field of the nested info dataclass. + + Args: + value: The new value for `info.a`. + """ + self.info = Info(a=value, b=self.info.b) + def index() -> rx.Component: return rx.center( rx.vstack( @@ -110,6 +138,12 @@ def index() -> rx.Component: on_change=State.update_last_name, id="set_last_name", ), + rx.text(f"info_a_b: {State.info.a_b}", id="info_a_b"), + rx.el.input( + value=State.info.a, + on_change=State.update_info_a, + id="set_info_a", + ), ), ) @@ -192,6 +226,19 @@ def test_hybrid_properties( assert hybrid_properties.app_instance is not None assert token + info_a_b = driver.find_element(By.ID, "info_a_b") + assert info_a_b.text == "info_a_b: a - b" + + # Updating the nested dataclass re-renders the hybrid property accessed via the object var. + set_info_a = driver.find_element(By.ID, "set_info_a") + set_info_a.send_keys(Keys.CONTROL + "a") + set_info_a.send_keys(Keys.DELETE) + set_info_a.send_keys("z") + assert ( + hybrid_properties.poll_for_content(info_a_b, exp_not_equal="info_a_b: a - b") + == "info_a_b: z - b" + ) + full_name = driver.find_element(By.ID, "full_name") assert full_name.text == "full_name: John Doe" diff --git a/tests/units/vars/test_object.py b/tests/units/vars/test_object.py index e9ab66c82dc..4648c4af68a 100644 --- a/tests/units/vars/test_object.py +++ b/tests/units/vars/test_object.py @@ -3,9 +3,11 @@ from typing import Any import pytest +from reflex_base.utils.exceptions import VarAttributeError from reflex_base.utils.imports import ImportVar from reflex_base.utils.types import GenericType from reflex_base.vars.base import Var, VarData +from reflex_base.vars.number import NumberVar from reflex_base.vars.object import LiteralObjectVar, ObjectVar, RestProp from reflex_base.vars.sequence import ArrayVar from typing_extensions import assert_type @@ -18,8 +20,49 @@ import pydantic from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column +from reflex.experimental import hybrid_property -class Bare: + +class HybridQuantity: + """Mixin defining hybrid properties on top of a ``quantity`` attribute. + + Used to verify that ``hybrid_property`` resolves uniformly across every kind of + object var (bare class, pydantic model, sqlalchemy model and dataclass). + """ + + # Provided by each subclass; typed loosely so the various concrete declarations + # (e.g. sqlalchemy's `Mapped[int]`) don't conflict with the mixin. + quantity: Any + + @hybrid_property + def doubled(self) -> int: + """A simple hybrid property reusing the same code on frontend and backend. + + Returns: + Twice the quantity. + """ + return self.quantity * 2 + + @hybrid_property + def is_nonzero(self) -> bool: + """A hybrid property whose backend getter differs from the frontend var. + + Returns: + Whether the quantity is non-zero. + """ + return self.quantity != 0 + + @is_nonzero.var + def is_nonzero(cls) -> Var[bool]: + """The frontend var for ``is_nonzero`` (deliberately distinct from the getter). + + Returns: + A Var that is True when the quantity is greater than zero. + """ + return cls.quantity > 0 + + +class Bare(HybridQuantity): """A bare class with a single attribute.""" quantity: int = 0 @@ -38,7 +81,7 @@ def serialize_bare(obj: Bare) -> dict: return {"quantity": obj.quantity} -class Base(pydantic.BaseModel): +class Base(HybridQuantity, pydantic.BaseModel): """A pydantic BaseModel class with a single attribute.""" quantity: int = 0 @@ -48,7 +91,7 @@ class SqlaBase(DeclarativeBase, MappedAsDataclass): """Sqlalchemy declarative mapping base class.""" -class SqlaModel(SqlaBase): +class SqlaModel(HybridQuantity, SqlaBase): """A sqlalchemy model with a single attribute.""" __tablename__: str = "sqla_model" @@ -58,7 +101,7 @@ class SqlaModel(SqlaBase): @dataclasses.dataclass -class Dataclass: +class Dataclass(HybridQuantity): """A dataclass with a single attribute.""" quantity: int = 0 @@ -132,6 +175,51 @@ def test_state_to_operation(type_: GenericType) -> None: assert var._var_type is type_ +@pytest.mark.parametrize("type_", [Base, Bare, SqlaModel, Dataclass]) +def test_hybrid_property_on_object_var(type_: GenericType) -> None: + """A hybrid property on the underlying type resolves with the object var as ``self``. + + Args: + type_: The object type to access the hybrid property on. + """ + obj_var: ObjectVar = getattr(ObjectState, type_.__name__.lower()) + quantity = obj_var.quantity.to(NumberVar) + + # The simple hybrid property reuses its getter, with attribute access resolved + # against the object var (e.g. `obj.doubled` behaves like `obj.quantity * 2`). + assert str(Var.create(obj_var.doubled)) == str(Var.create(quantity * 2)) + + # The custom `.var` function is used for the frontend (not the getter), so the + # result matches `obj.quantity > 0` rather than the getter's `obj.quantity != 0`. + assert str(Var.create(obj_var.is_nonzero)) == str(Var.create(quantity > 0)) + + +def test_hybrid_property_on_object_var_issue_6617() -> None: + """A hybrid property on a nested dataclass renders as if accessed on the state.""" + + @dataclasses.dataclass + class Info: + a: str + b: str + + @hybrid_property + def a_b(self) -> str: + return f"{self.a} - {self.b}" + + class InfoState(rx.State): + info: Info = Info(a="a", b="b") + + assert str(Var.create(InfoState.info.a_b)) == str( + Var.create(f"{InfoState.info.a} - {InfoState.info.b}") + ) + + +def test_hybrid_property_missing_attribute_still_raises() -> None: + """Accessing a non-existent attribute on an object var still raises VarAttributeError.""" + with pytest.raises(VarAttributeError): + _ = ObjectState.dataclass.does_not_exist + + def test_typing() -> None: # Bare var = ObjectState.bare.to(ObjectVar) From a46bb3c142025a3251d8385c2efe2c75765e3532 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 20:49:41 +0000 Subject: [PATCH 2/4] refactor: drop reflex.experimental.hybrid_property shim module Import `hybrid_property` directly from `reflex_base.vars.hybrid_property` in `reflex.experimental.__init__` instead of going through a one-line re-export module. `from reflex.experimental import hybrid_property` and `rx._x.hybrid_property` are unchanged. https://claude.ai/code/session_01DKFiYGnWRQG8wMNKFW7obm --- packages/reflex-base/news/6617.feature.md | 2 +- reflex/experimental/__init__.py | 2 +- reflex/experimental/hybrid_property.py | 4 ---- 3 files changed, 2 insertions(+), 6 deletions(-) delete mode 100644 reflex/experimental/hybrid_property.py diff --git a/packages/reflex-base/news/6617.feature.md b/packages/reflex-base/news/6617.feature.md index 765fc10326e..e457549824b 100644 --- a/packages/reflex-base/news/6617.feature.md +++ b/packages/reflex-base/news/6617.feature.md @@ -1 +1 @@ -`ObjectVar` attribute access now resolves `HybridProperty` descriptors defined on the underlying type, evaluating the property's frontend logic with the object var substituted as `self`. `HybridProperty` moved to `reflex_base.vars.hybrid_property` (still re-exported from `reflex.experimental.hybrid_property`). ([#6617](https://github.com/reflex-dev/reflex/issues/6617)) +`ObjectVar` attribute access now resolves `HybridProperty` descriptors defined on the underlying type, evaluating the property's frontend logic with the object var substituted as `self`. `HybridProperty` moved to `reflex_base.vars.hybrid_property` (still available as `rx._x.hybrid_property`). ([#6617](https://github.com/reflex-dev/reflex/issues/6617)) diff --git a/reflex/experimental/__init__.py b/reflex/experimental/__init__.py index 83531ebe15b..77d6aec18fc 100644 --- a/reflex/experimental/__init__.py +++ b/reflex/experimental/__init__.py @@ -5,13 +5,13 @@ from reflex_base.components.memo import memo as _memo from reflex_base.utils.console import warn +from reflex_base.vars.hybrid_property import hybrid_property as hybrid_property from reflex_components_code.shiki_code_block import code_block as code_block from reflex.utils.misc import run_in_thread from . import hooks as hooks from .client_state import ClientStateVar as ClientStateVar -from .hybrid_property import hybrid_property as hybrid_property class ExperimentalNamespace(SimpleNamespace): diff --git a/reflex/experimental/hybrid_property.py b/reflex/experimental/hybrid_property.py deleted file mode 100644 index ad9dd009924..00000000000 --- a/reflex/experimental/hybrid_property.py +++ /dev/null @@ -1,4 +0,0 @@ -"""hybrid_property decorator which functions like a normal python property but additionally allows (class-level) access from the frontend. You can use the same code for frontend and backend, or implement 2 different methods.""" - -from reflex_base.vars.hybrid_property import HybridProperty as HybridProperty -from reflex_base.vars.hybrid_property import hybrid_property as hybrid_property From 14b59d9a6334aae028f984a7955a4d4f96831dca Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 23:00:49 +0000 Subject: [PATCH 3/4] fix: return the descriptor for class-level hybrid property access on non-states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HybridProperty.__get__ produced a frontend var for any class-level access, which only makes sense on a state (whose class attributes are vars). On a plain class accessed directly — e.g. `Info.a_b` on a dataclass, not through an object var — it ran the getter with the class as `self`, raising AttributeError (no field default) or returning a value built from class defaults. It now returns the descriptor itself, like a normal property. Var access through an object var (`State.info.a_b`) is unaffected: it is resolved by ObjectVar.__getattr__ via _get_var, not __get__. https://claude.ai/code/session_01DKFiYGnWRQG8wMNKFW7obm --- .../src/reflex_base/vars/hybrid_property.py | 18 +++++++-- tests/units/vars/test_object.py | 39 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) 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 58b6f374487..b34350d51b3 100644 --- a/packages/reflex-base/src/reflex_base/vars/hybrid_property.py +++ b/packages/reflex-base/src/reflex_base/vars/hybrid_property.py @@ -42,18 +42,30 @@ def _get_var(self, owner: Any) -> Var: @override def __get__(self, instance: Any, owner: type | None = None, /) -> Any: - """Get the value of the property. If the property is not bound to an instance return a frontend Var. + """Get the value of the property. + + On an instance, return the getter's value. At the class level, return a + frontend Var only when accessed on a state (whose class attributes are + vars); on any other class there is no var context, so return the + descriptor itself, like a normal property. Note that var access on a + nested object (e.g. ``State.info.a_b``) does not go through ``__get__`` — + it is resolved by ``ObjectVar.__getattr__`` via ``_get_var``. Args: instance: The instance of the class accessing this property. owner: The class that this descriptor is attached to. Returns: - The value of the property or a frontend Var. + The property value, a frontend Var, or the descriptor itself. """ if instance is not None: return super().__get__(instance, owner) - return self._get_var(owner) + if isinstance(owner, type): + from reflex.state import BaseState + + if issubclass(owner, BaseState): + return self._get_var(owner) + return self def var(self, func: Callable[[Any], Var]) -> Self: """Set the (optional) var function for the property. diff --git a/tests/units/vars/test_object.py b/tests/units/vars/test_object.py index 4648c4af68a..41e7ae8358a 100644 --- a/tests/units/vars/test_object.py +++ b/tests/units/vars/test_object.py @@ -220,6 +220,45 @@ def test_hybrid_property_missing_attribute_still_raises() -> None: _ = ObjectState.dataclass.does_not_exist +def test_hybrid_property_class_access_on_non_state_returns_descriptor() -> None: + """Class-level access on a non-state (not via an object var) returns the descriptor. + + Only a state exposes a hybrid property as a frontend var at the class level, since + its class attributes are vars. On a plain class accessed directly there is no var + context, so it behaves like a normal property and returns the descriptor itself. + """ + from reflex_base.vars.hybrid_property import HybridProperty + + @dataclasses.dataclass + class Info: + a: str + b: str + + @hybrid_property + def a_b(self) -> str: + return f"{self.a} - {self.b}" + + # Direct class-level access yields the descriptor, not a var or a default-based value. + assert isinstance(Info.a_b, HybridProperty) + # Instance access still returns the computed backend value. + assert Info(a="1", b="2").a_b == "1 - 2" + + +def test_hybrid_property_class_access_on_state_returns_var() -> None: + """Class-level access on a state still resolves to a frontend var (not the descriptor).""" + from reflex_base.vars.hybrid_property import HybridProperty + + class HybridState(rx.State): + value: str = "v" + + @hybrid_property + def shout(self) -> str: + return self.value + + assert not isinstance(HybridState.shout, HybridProperty) + assert isinstance(Var.create(HybridState.shout), Var) + + def test_typing() -> None: # Bare var = ObjectState.bare.to(ObjectVar) From ae2b3b835b1aac8c546745427b86971f71adb539 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 23:51:00 +0000 Subject: [PATCH 4/4] perf: detect hybrid properties via getattr instead of getattr_static MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ObjectVar.__getattr__ ran inspect.getattr_static on every attribute access to detect a HybridProperty on the underlying type — a pure-Python MRO walk on a hot path, ~15x slower than getattr for ordinary field access. Now that HybridProperty.__get__ returns the descriptor itself for non-state class access, a plain getattr surfaces it directly, so the static lookup is no longer needed. https://claude.ai/code/session_01DKFiYGnWRQG8wMNKFW7obm --- packages/reflex-base/src/reflex_base/vars/object.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/vars/object.py b/packages/reflex-base/src/reflex_base/vars/object.py index 2e0cdbc9bee..e228f8507a7 100644 --- a/packages/reflex-base/src/reflex_base/vars/object.py +++ b/packages/reflex-base/src/reflex_base/vars/object.py @@ -4,7 +4,6 @@ import collections.abc import dataclasses -import inspect import typing from collections.abc import Mapping from importlib.util import find_spec @@ -336,9 +335,10 @@ def __getattr__(self, name: str) -> Var: from .hybrid_property import HybridProperty # A HybridProperty on the underlying type resolves to a frontend Var with - # this object var substituted as `self`, so e.g. `State.info.a_b` uses the - # same Var-access semantics as accessing the hybrid property directly. - descriptor = inspect.getattr_static(fixed_type, name, None) + # this object var substituted as `self` (e.g. `State.info.a_b`). Class-level + # access returns the descriptor itself (it only yields a var on a state), so a + # plain `getattr` surfaces it without the cost of `inspect.getattr_static`. + descriptor = getattr(fixed_type, name, None) if isinstance(descriptor, HybridProperty): return descriptor._get_var(self)