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/+initial-value-computed-var-dependency.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A computed var that reads another computed var declared with `initial_value` now sees that initial value while the initial state is compiled, instead of running the other var's getter. `reflex export` and `reflex run` no longer fail when such a getter needs a database or a request. At runtime the getter still runs as before.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Reading a computed var declared with `initial_value` from another computed var's getter now yields the initial value while the initial state dict is built, instead of running its getter.
39 changes: 39 additions & 0 deletions packages/reflex-base/src/reflex_base/vars/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import builtins
import contextlib
import contextvars
import copy
import dataclasses
import datetime
Expand Down Expand Up @@ -2619,6 +2620,15 @@ def is_computed_var(obj: Any) -> TypeGuard[ComputedVar]:
return isinstance(obj, FakeComputedVarBaseClass)


# Set while the initial state dict is built, where a computed var read by another
# one yields its initial value instead of running its getter. Holds the values
# computed from initial values, keyed by state instance id and cache attribute,
# which are kept out of the instance caches.
_initial_dict_cache: contextvars.ContextVar[dict[tuple[int, str], Any] | None] = (
contextvars.ContextVar("_initial_dict_cache", default=None)
)


@dataclasses.dataclass(
eq=False,
frozen=True,
Expand Down Expand Up @@ -3020,6 +3030,10 @@ def __get__(self, instance: Any, owner: type):
if owner_cls is not None and type(instance) is not owner_cls:
instance = _owner_state(instance, owner_cls)
if not self._cache:
if _initial_dict_cache.get() is not None and not isinstance(
initial_value := self._initial_value, types.Unset
):
return initial_value
value = self.fget(instance)
self._check_deprecated_return_type(instance, value)
return value
Expand All @@ -3031,6 +3045,9 @@ def __get__(self, instance: Any, owner: type):
if value is _NOT_CACHED or (
self._update_interval is not None and self.needs_update(instance)
):
if (initial_cache := _initial_dict_cache.get()) is not None:
# A value derived from initial values must not reach the cache.
return self._initial_dict_value(instance, initial_cache)
# Set cache attr on state instance.
value = cache[cache_attr] = self.fget(instance)
# Ensure the computed var gets serialized to redis.
Expand All @@ -3041,6 +3058,28 @@ def __get__(self, instance: Any, owner: type):
self._check_deprecated_return_type(instance, value)
return value

def _initial_dict_value(
self, instance: BaseState, initial_cache: dict[tuple[int, str], Any]
) -> RETURN_TYPE:
"""Get the value of a cached computed var while the initial state dict is built.

Args:
instance: The instance of the owning state.
initial_cache: The values already computed for the initial dict.

Returns:
The initial value if one was given, otherwise the value computed
once for the initial dict.
"""
if not isinstance(initial_value := self._initial_value, types.Unset):
return initial_value
key = (id(instance), self._cache_attr)
value = initial_cache.get(key, _NOT_CACHED)
if value is _NOT_CACHED:
value = initial_cache[key] = self.fget(instance)
self._check_deprecated_return_type(instance, value)
return value

def __set_name__(self, owner: type[BaseState], name: str) -> None:
"""Bind the computed var to the state class it is assigned to.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ dependencies = [
"starlette >=1.3.1",
"typing_extensions >=4.13.0",
"wrapt >=1.17.0,<2.6",
"reflex-base >= 0.10.2.dev0",
"reflex-base >= 0.11.1.dev0",
"reflex-components-code >= 0.11.0",
"reflex-components-core >= 0.11.0",
"reflex-components-dataeditor >= 0.10.1",
Expand Down
28 changes: 17 additions & 11 deletions reflex/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
ToOperation,
Var,
_inherited_value,
_initial_dict_cache,
_is_descriptor,
_private_prefixes,
_slot_names,
Expand Down Expand Up @@ -2124,17 +2125,22 @@ def dict(
prop_name: self.get_value(prop_name) for prop_name in self.base_vars
}
if initial and include_computed:
computed_vars = {
# Include initial computed vars.
prop_name: (
cv._initial_value
if is_computed_var(cv)
and not isinstance(cv._initial_value, types.Unset)
else self.get_value(prop_name)
)
for prop_name, cv in self.computed_vars.items()
if not cv._backend
}
# Computed vars read by the getters below yield their initial value too.
token = _initial_dict_cache.set({})
try:
computed_vars = {
# Include initial computed vars.
prop_name: (
cv._initial_value
if is_computed_var(cv)
and not isinstance(cv._initial_value, types.Unset)
else self.get_value(prop_name)
)
for prop_name, cv in self.computed_vars.items()
if not cv._backend
}
finally:
_initial_dict_cache.reset(token)
elif include_computed:
computed_vars = {
# Include the computed vars.
Expand Down
113 changes: 113 additions & 0 deletions tests/units/test_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -1346,6 +1346,119 @@ def test_interdependent_state_initial_dict() -> None:
}


class InitialValueDependencyState(BaseState):
"""A state whose computed vars read computed vars that have an initial value."""

getter_calls: ClassVar[list[str]] = []

@rx.var(initial_value=10)
def cached_source(self) -> int:
"""A cached computed var with an initial value.

Returns:
The runtime value.
"""
self.getter_calls.append("cached_source")
return 1

@rx.var(initial_value=20, cache=False)
def uncached_source(self) -> int:
"""An uncached computed var with an initial value.

Returns:
The runtime value.
"""
self.getter_calls.append("uncached_source")
return 2

@rx.var
def no_initial_source(self) -> int:
"""A computed var without an initial value, reading one that has it.

Returns:
The cached source plus 100.
"""
self.getter_calls.append("no_initial_source")
return self.cached_source + 100

@rx.var
def total(self) -> int:
"""Depends on every source.

Returns:
The sum of the sources.
"""
return self.cached_source + self.uncached_source + self.no_initial_source


class InitialValueDependencyChildState(InitialValueDependencyState):
"""A substate reading its parent's computed vars that have an initial value."""

@rx.var
def child_total(self) -> int:
"""Depends on the inherited sources.

Returns:
The sum of the inherited sources.
"""
return self.cached_source + self.uncached_source


def test_initial_dict_uses_initial_value_of_computed_var_dependencies() -> None:
"""A computed var read by another one yields its initial value in the initial dict.

The getter of a computed var with an initial value does not run while the
initial dict is built, and a cached one without an initial value runs once.
Nothing computed from initial values is cached on the state: a regular read
afterwards runs the getters.
"""
InitialValueDependencyState.getter_calls.clear()
s = InitialValueDependencyState()
child = s.substates[InitialValueDependencyChildState.get_name()]
assert isinstance(child, InitialValueDependencyChildState)
d = s.dict(initial=True)
parent_vars = d[InitialValueDependencyState.get_full_name()]
for router_var in constants.ROUTER_VARS:
parent_vars.pop(router_var + FIELD_MARKER)
assert d == {
InitialValueDependencyState.get_full_name(): {
"cached_source" + FIELD_MARKER: 10,
"uncached_source" + FIELD_MARKER: 20,
"no_initial_source" + FIELD_MARKER: 110,
"total" + FIELD_MARKER: 140,
},
InitialValueDependencyChildState.get_full_name(): {
"child_total" + FIELD_MARKER: 30,
},
}
assert InitialValueDependencyState.getter_calls == ["no_initial_source"]
InitialValueDependencyState.getter_calls.clear()

assert s.total == 104
assert child.child_total == 3
assert InitialValueDependencyState.getter_calls == [
"cached_source",
"uncached_source",
"no_initial_source",
"uncached_source",
]
d = s.dict()
parent_vars = d[InitialValueDependencyState.get_full_name()]
for router_var in constants.ROUTER_VARS:
parent_vars.pop(router_var + FIELD_MARKER)
assert d == {
InitialValueDependencyState.get_full_name(): {
"cached_source" + FIELD_MARKER: 1,
"uncached_source" + FIELD_MARKER: 2,
"no_initial_source" + FIELD_MARKER: 101,
"total" + FIELD_MARKER: 104,
},
InitialValueDependencyChildState.get_full_name(): {
"child_total" + FIELD_MARKER: 3,
},
}


def test_not_dirty_computed_var_from_var(
interdependent_state: InterdependentState,
) -> None:
Expand Down
Loading