Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
adfd79b
add hybrid_property
benedikt-bartscher Aug 17, 2024
acdd88e
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher Aug 19, 2024
f4f44f8
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher Aug 22, 2024
2ff47e1
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher Aug 23, 2024
58399e5
Merge remote-tracking branch 'origin/main' into hybrid-properties
benedikt-bartscher Aug 29, 2024
dbec527
fix merge conflicts
benedikt-bartscher Sep 5, 2024
0f38cd6
remove reference to computed var
adhami3310 Sep 10, 2024
f4a45fd
fix conflicts
benedikt-bartscher Sep 10, 2024
fc58c38
Merge remote-tracking branch 'upstream/fix-reference-to-old-computed-…
benedikt-bartscher Sep 10, 2024
7d3071e
actually fix conflicts
benedikt-bartscher Sep 10, 2024
30ded81
use other callable
benedikt-bartscher Sep 10, 2024
a541107
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher Sep 11, 2024
fdd30a6
better Self typing for old python versions
benedikt-bartscher Sep 11, 2024
c852767
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher Nov 1, 2024
1b3c82d
move hybrid_property to experimental namespace
benedikt-bartscher Nov 20, 2024
e64783b
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher Nov 20, 2024
0594a8a
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher Aug 10, 2025
8290c86
oups
benedikt-bartscher Aug 10, 2025
5fc942e
fix: adjust test for hybrid properties to upstream changes
benedikt-bartscher Aug 10, 2025
69d57af
Update reflex/experimental/hybrid_property.py
benedikt-bartscher Aug 10, 2025
34e67ed
ruffing
benedikt-bartscher Aug 10, 2025
0ac6e3e
fix darglint, document exception for hybrid_property
benedikt-bartscher Aug 14, 2025
21fa0bc
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher Aug 14, 2025
1e76998
Merge remote-tracking branch 'upstream' into hybrid-properties
benedikt-bartscher Jan 27, 2026
cf67b87
fix pyright
benedikt-bartscher Jan 27, 2026
0a53ae5
Merge remote-tracking branch 'origin/main' into hybrid-properties
masenf Mar 6, 2026
a48b0f3
Merge remote-tracking branch 'upstream' into hybrid-properties
benedikt-bartscher Mar 26, 2026
a3bc40a
Merge remote-tracking branch 'origin/main' into hybrid-properties
benedikt-bartscher Apr 25, 2026
9e4ce1b
adjust tests
benedikt-bartscher Apr 25, 2026
fc44d54
dont use setvar
benedikt-bartscher Apr 25, 2026
7c6def0
fix: add support for tracking dependencies through hybrid properties
benedikt-bartscher Apr 25, 2026
ea48aa4
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher May 6, 2026
6b1032b
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher May 23, 2026
d45750b
Merge branch 'main' into hybrid-properties
masenf May 29, 2026
7c9da05
Fix mismerge of old rx._x.memo
masenf May 29, 2026
8b98a85
add news fragment
masenf May 29, 2026
e69fb3d
add news fragment for reflex-base
masenf May 29, 2026
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/3806.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added `rx._x.hybrid_property`, a property decorator usable on State classes that works like a normal Python property for backend access while also rendering on the frontend at class level. Use the same method for both, or register a separate frontend implementation with `@<name>.var`.
1 change: 1 addition & 0 deletions packages/reflex-base/news/3806.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Dependency tracking now follows through hybrid properties, so computed vars that read a `hybrid_property` correctly recompute when the underlying state vars change.
18 changes: 13 additions & 5 deletions packages/reflex-base/src/reflex_base/vars/dep_tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,15 +185,23 @@ def load_attr_or_method(self, instruction: dis.Instruction) -> None:
except VarValueError:
# If the target state is not a BaseState, we cannot track dependencies on it.
return
# Look up the raw descriptor first via inspect.getattr_static so we can detect
# property-like descriptors (e.g. HybridProperty) that override __get__ to return
# something other than themselves when accessed via the class.
try:
ref_obj = getattr(target_state, instruction.argval)
static_obj = inspect.getattr_static(target_state, instruction.argval)
except AttributeError:
# Not found on this state class, maybe it is a dynamic attribute that will be picked up later.
ref_obj = None
static_obj = None

if isinstance(ref_obj, property) and not isinstance(ref_obj, ComputedVar):
if isinstance(static_obj, property) and not isinstance(static_obj, ComputedVar):
# recurse into property fget functions
ref_obj = ref_obj.fget
ref_obj = static_obj.fget
else:
try:
ref_obj = getattr(target_state, instruction.argval)
except AttributeError:
# Not found on this state class, maybe it is a dynamic attribute that will be picked up later.
ref_obj = None
if callable(ref_obj):
# recurse into callable attributes
self._merge_deps(
Expand Down
2 changes: 2 additions & 0 deletions reflex/experimental/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from . import hooks as hooks
from .client_state import ClientStateVar as ClientStateVar
from .hybrid_property import hybrid_property as hybrid_property


class ExperimentalNamespace(SimpleNamespace):
Expand Down Expand Up @@ -71,4 +72,5 @@ def register_component_warning(component_name: str):
client_state=ClientStateVar.create,
hooks=hooks,
code_block=code_block,
hybrid_property=hybrid_property,
)
54 changes: 54 additions & 0 deletions reflex/experimental/hybrid_property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""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
231 changes: 231 additions & 0 deletions tests/integration/test_hybrid_properties.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
"""Test hybrid properties."""

from __future__ import annotations

from collections.abc import Generator

import pytest
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys

from reflex.testing import DEFAULT_TIMEOUT, AppHarness, WebDriver


def HybridProperties():
"""Test app for hybrid properties."""
import reflex as rx
from reflex.experimental import hybrid_property
from reflex.vars import Var

class State(rx.State):
first_name: str = "John"
last_name: str = "Doe"

@property
def python_full_name(self) -> str:
"""A normal python property to showcase the current behavior. This renders to smth like `<property object at 0x723b334e5940>`.

Returns:
str: The full name of the person.
"""
return f"{self.first_name} {self.last_name}"

@hybrid_property
def full_name(self) -> str:
"""A simple hybrid property which uses the same code for both frontend and backend.

Returns:
str: The full name of the person.
"""
return f"{self.first_name} {self.last_name}"

@hybrid_property
def has_last_name(self) -> str:
"""A more complex hybrid property which uses different code for frontend and backend.

Returns:
str: "yes" if the person has a last name, "no" otherwise.
"""
return "yes" if self.last_name else "no"

@has_last_name.var
def has_last_name(cls) -> Var[str]:
"""The frontend code for the `has_last_name` hybrid property.

Returns:
Var[str]: The value of the hybrid property.
"""
return rx.cond(cls.last_name, "yes", "no")

@rx.var
def full_name_backend(self) -> str:
"""Expose the backend value of the `full_name` hybrid property.

Returns:
str: The full name as evaluated by the backend property getter.
"""
return self.full_name

@rx.var
def has_last_name_backend(self) -> str:
"""Expose the backend value of the `has_last_name` hybrid property.

Returns:
str: The has_last_name value as evaluated by the backend property getter.
"""
return self.has_last_name

@rx.event
def update_last_name(self, value: str):
"""Update the last_name field.

Args:
value: The new last name value.
"""
self.last_name = value

def index() -> rx.Component:
return rx.center(
rx.vstack(
rx.el.input(
id="token",
value=State.router.session.client_token,
is_read_only=True,
),
rx.text(
f"python_full_name: {State.python_full_name}", id="python_full_name"
),
rx.text(f"full_name: {State.full_name}", id="full_name"),
rx.text(
f"full_name_backend: {State.full_name_backend}",
id="full_name_backend",
),
rx.text(f"has_last_name: {State.has_last_name}", id="has_last_name"),
rx.text(
f"has_last_name_backend: {State.has_last_name_backend}",
id="has_last_name_backend",
),
rx.el.input(
value=State.last_name,
on_change=State.update_last_name,
id="set_last_name",
),
),
)

app = rx.App()
app.add_page(index)


@pytest.fixture(scope="module")
def hybrid_properties(
tmp_path_factory: pytest.TempPathFactory,
) -> Generator[AppHarness, None, None]:
"""Start HybridProperties app at tmp_path via AppHarness.

Args:
tmp_path_factory: pytest tmp_path_factory fixture

Yields:
running AppHarness instance
"""
with AppHarness.create(
root=tmp_path_factory.mktemp("hybrid_properties"),
app_source=HybridProperties,
) as harness:
yield harness


@pytest.fixture
def driver(hybrid_properties: AppHarness) -> Generator[WebDriver, None, None]:
"""Get an instance of the browser open to the hybrid_properties app.

Args:
hybrid_properties: harness for HybridProperties app

Yields:
WebDriver instance.
"""
assert hybrid_properties.app_instance is not None, "app is not running"
driver = hybrid_properties.frontend()
try:
yield driver
finally:
driver.quit()


@pytest.fixture
def token(hybrid_properties: AppHarness, driver: WebDriver) -> str:
"""Get a function that returns the active token.

Args:
hybrid_properties: harness for HybridProperties app.
driver: WebDriver instance.

Returns:
The token for the connected client
"""
assert hybrid_properties.app_instance is not None
token_input = AppHarness.poll_for_or_raise_timeout(
lambda: driver.find_element(By.ID, "token")
)

# wait for the backend connection to send the token
token = hybrid_properties.poll_for_value(token_input, timeout=DEFAULT_TIMEOUT * 2)
assert token is not None

return token


def test_hybrid_properties(
hybrid_properties: AppHarness,
driver: WebDriver,
token: str,
):
"""Test that hybrid properties are working as expected.

Args:
hybrid_properties: harness for HybridProperties app.
driver: WebDriver instance.
token: The token for the connected client (used to wait for backend connection).
"""
assert hybrid_properties.app_instance is not None
assert token

full_name = driver.find_element(By.ID, "full_name")
assert full_name.text == "full_name: John Doe"

full_name_backend = driver.find_element(By.ID, "full_name_backend")
assert full_name_backend.text == "full_name_backend: John Doe"

python_full_name = driver.find_element(By.ID, "python_full_name")
assert "<property object at 0x" in python_full_name.text

has_last_name = driver.find_element(By.ID, "has_last_name")
assert has_last_name.text == "has_last_name: yes"

has_last_name_backend = driver.find_element(By.ID, "has_last_name_backend")
assert has_last_name_backend.text == "has_last_name_backend: yes"

set_last_name = driver.find_element(By.ID, "set_last_name")
# clear the input
set_last_name.send_keys(Keys.CONTROL + "a")
set_last_name.send_keys(Keys.DELETE)

assert (
hybrid_properties.poll_for_content(
has_last_name, exp_not_equal="has_last_name: yes"
)
== "has_last_name: no"
)
assert (
hybrid_properties.poll_for_content(
has_last_name_backend, exp_not_equal="has_last_name_backend: yes"
)
== "has_last_name_backend: no"
)

assert full_name.text == "full_name: John"
Comment thread
masenf marked this conversation as resolved.
# Use textContent to preserve trailing whitespace (Selenium's .text strips it),
# since the backend f-string yields "John " (with trailing space) when last_name is empty.
assert full_name_backend.get_attribute("textContent") == "full_name_backend: John "
Loading
Loading