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/+dirty-tracking-cutoff.performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Stop re-sending a cached computed var whose recomputed value is unchanged, and stop re-walking the computed var dependency graph on every write within one event handler.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Cached computed vars are now resolved lazily: a stale value is recomputed only when one of its dependencies actually changed, and an unchanged result keeps the cache and does not cascade to dependents.
8 changes: 7 additions & 1 deletion packages/reflex-base/src/reflex_base/utils/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,13 @@ def __call__(
dict: Dict, # noqa: UP006
}

RESERVED_BACKEND_VAR_NAMES = {"_abc_impl", "_backend_vars", "_was_touched", "_mixin"}
RESERVED_BACKEND_VAR_NAMES = {
"_abc_impl",
"_backend_vars",
"_was_touched",
"_stale_computed_vars",
"_mixin",
}


class Unset:
Expand Down
79 changes: 71 additions & 8 deletions packages/reflex-base/src/reflex_base/vars/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2261,6 +2261,27 @@ def is_computed_var(obj: Any) -> TypeGuard[ComputedVar]:
return isinstance(obj, FakeComputedVarBaseClass)


_MISSING: Any = object()

# Computed var results compared by equality before dependents are cascaded.
_CUTOFF_TYPES = frozenset({int, float, str, bool, type(None)})


def _is_unchanged(old: Any, new: Any) -> bool:
"""Whether a recomputed value is observably the same as the cached one.

Only scalars are compared; objects are always treated as changed.

Args:
old: The cached value.
new: The recomputed value.

Returns:
True when both are scalars of the same type and compare equal.
"""
return type(new) in _CUTOFF_TYPES and type(old) is type(new) and old == new

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the sign when comparing zero-valued floats

For float state or computed values, 0.0 == -0.0 even though the sign is observable through serialization, math.copysign, and JavaScript numeric operations. Assigning one over the other now returns early, and a computed var changing only between these values keeps its old cache and stops invalidation, so backend and frontend state can remain incorrect. Exclude opposite-signed zeroes from the unchanged cutoff.

AGENTS.md reference: AGENTS.md:L43-L43

Useful? React with 👍 / 👎.



@dataclasses.dataclass(
eq=False,
frozen=True,
Expand Down Expand Up @@ -2594,20 +2615,62 @@ def __get__(self, instance: BaseState | None, owner: type):
if not self._cache:
value = self.fget(instance)
else:
# handle caching
if not hasattr(instance, self._cache_attr) or self.needs_update(instance):
# Set cache attr on state instance.
setattr(instance, self._cache_attr, self.fget(instance))
# Ensure the computed var gets serialized to redis.
instance._was_touched = True
# Set the last updated timestamp on the state instance.
setattr(instance, self._last_updated_attr, datetime.datetime.now())
if self._name in instance._stale_computed_vars:
self._resolve_stale(instance)
elif not hasattr(instance, self._cache_attr) or self.needs_update(instance):
self._recompute(instance)
value = getattr(instance, self._cache_attr)

self._check_deprecated_return_type(instance, value)

return value

def _recompute(self, instance: BaseState) -> None:
"""Run the getter and store its value in the instance cache.

Args:
instance: The state instance to compute the value for.
"""
setattr(instance, self._cache_attr, self.fget(instance))
# Ensure the computed var gets serialized to redis.
instance._was_touched = True
# Set the last updated timestamp on the state instance.
setattr(instance, self._last_updated_attr, datetime.datetime.now())

def _resolve_stale(self, instance: BaseState) -> None:
"""Settle a stale cached value: recompute it only if a dependency changed.

A dependency counts as changed when it is in its state's ``dirty_vars``.
Stale computed var dependencies are resolved first, so a chain settles
from the changed base var outward. When the getter returns a value equal
to the cached one, the cache is kept and the var stays out of
``dirty_vars``, which stops the cascade at this point.

Args:
instance: The state instance holding the stale cache.
"""
name = self._name
instance._stale_computed_vars.discard(name)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Failed recomputation loses stale state

When a stale computed getter raises, _resolve_stale has already removed its stale marker while leaving the old cache intact, so subsequent reads treat the outdated cached value as current and later deltas can silently omit the dependency change.

Knowledge Base Used:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: If a stale computed getter raises, this method has already removed its stale marker, so the cached old value is returned on the next read without retrying. Preserve the stale marker until dependency resolution and recomputation succeed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/reflex-base/src/reflex_base/vars/base.py, line 2654:

<comment>If a stale computed getter raises, this method has already removed its stale marker, so the cached old value is returned on the next read without retrying. Preserve the stale marker until dependency resolution and recomputation succeed.</comment>

<file context>
@@ -2594,20 +2616,62 @@ def __get__(self, instance: BaseState | None, owner: type):
+            instance: The state instance holding the stale cache.
+        """
+        name = self._name
+        instance._stale_computed_vars.discard(name)
+        cache_attr = self._cache_attr
+        changed = not hasattr(instance, cache_attr) or self.needs_update(instance)
</file context>

cache_attr = self._cache_attr
changed = not hasattr(instance, cache_attr) or self.needs_update(instance)
if not changed:
state_cls = type(instance)
for state_name, dep in state_cls._computed_var_deps.get(name, ()):
dep_state = instance._get_state_by_full_name(state_name)
if dep_state is None:
continue
if dep in dep_state._stale_computed_vars:
dep_state.computed_vars[dep]._resolve_stale(dep_state)
if dep in dep_state.dirty_vars:
changed = True
break
if not changed:
return
old = getattr(instance, cache_attr, _MISSING)
self._recompute(instance)
if not _is_unchanged(old, getattr(instance, cache_attr)):
instance.dirty_vars.add(name)

def _check_deprecated_return_type(self, instance: BaseState, value: Any) -> None:
if not _isinstance(value, self._var_type, nested=1, treat_var_as_type=False):
logger.error(
Expand Down
134 changes: 112 additions & 22 deletions reflex/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
from reflex_base.utils.types import _isinstance
from reflex_base.vars import Field, VarData, field
from reflex_base.vars.base import (
AsyncComputedVar,
ComputedVar,
DynamicRouteVar,
EvenMoreBasicBaseState,
Expand Down Expand Up @@ -369,6 +370,9 @@ def _is_user_descriptor(value: Any) -> bool:
"inherited_backend_vars",
"event_handlers",
"_var_dependencies",
"_computed_var_deps",
"_frontend_computed_vars",
"_interval_computed_vars",
"_always_dirty_computed_vars",
"_always_dirty_substates",
"_potentially_dirty_states",
Expand Down Expand Up @@ -402,6 +406,15 @@ class BaseState(EvenMoreBasicBaseState):
# Mapping of var name to set of (state_full_name, var_name) that depend on it.
_var_dependencies: ClassVar[builtins.dict[str, set[tuple[str, str]]]] = {}

# Mapping of computed var name to the (state_full_name, var_name) pairs it reads.
_computed_var_deps: ClassVar[builtins.dict[str, tuple[tuple[str, str], ...]]] = {}

# Cached computed vars that are sent to the frontend.
_frontend_computed_vars: ClassVar[set[str]] = set()

# Computed vars with an update_interval.
_interval_computed_vars: ClassVar[set[str]] = set()

# Set of vars which always need to be recomputed
_always_dirty_computed_vars: ClassVar[set[str]] = set()

Expand All @@ -425,6 +438,9 @@ class BaseState(EvenMoreBasicBaseState):
# The set of dirty substates.
dirty_substates: set[str] = field(default_factory=set, is_var=False)

# Cached computed vars whose dependencies changed and that have not been re-read yet.
_stale_computed_vars: set[str] = field(default_factory=set, is_var=False)

# The routing path that triggered the state
router_data: builtins.dict[str, Any] = field(
default_factory=builtins.dict, is_var=False
Expand Down Expand Up @@ -830,6 +846,7 @@ def computed_var_func(state: Self):
cls.vars[unique_var_name] = computed_var_func_arg
cls._update_substate_inherited_vars({unique_var_name: computed_var_func_arg})
cls._always_dirty_computed_vars.add(unique_var_name)
cls._frontend_computed_vars.add(unique_var_name)

return getattr(cls, unique_var_name)

Expand Down Expand Up @@ -894,10 +911,22 @@ def _init_var_dependency_dicts(cls):
Additional updates tracking dicts for vars and substates that always
need to be recomputed.
"""
cls._computed_var_deps = {}
cls._frontend_computed_vars = {
cvar_name
for cvar_name, cvar in cls.computed_vars.items()
if not cvar._backend
}
cls._interval_computed_vars = {
cvar_name
for cvar_name, cvar in cls.computed_vars.items()
if cvar._update_interval is not None
}
for cvar_name, cvar in cls.computed_vars.items():
if not cvar._cache:
# Do not perform dep calculation when cache=False (these are always dirty).
continue
forward_deps: list[tuple[str, str]] = []
for state_name, dvar_set in cvar._deps(objclass=cls).items():
state_cls = cls.get_root_state().get_class_substate(state_name)
for dvar in dvar_set:
Expand All @@ -916,6 +945,8 @@ def _init_var_dependency_dicts(cls):
defining_state_cls._potentially_dirty_states.add(
cls.get_full_name()
)
forward_deps.append((defining_state_cls.get_full_name(), dvar))
cls._computed_var_deps[cvar_name] = tuple(forward_deps)
Comment on lines +948 to +949

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep dynamically added dependencies in the forward map

When a cached synchronous computed var calls add_dependency() after class initialization, that method updates _var_dependencies but not this new _computed_var_deps snapshot. A write to the added dependency therefore marks the computed var stale, but _resolve_stale() sees no forward dependencies and returns without recomputing it, leaving the client with the old computed value. Update the forward map when adding a dependency or rebuild it before relying on it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a cached synchronous ComputedVar receives a dependency through add_dependency() after class initialization, this reverse map never includes it. The stale resolver retains the old cache and omits the computed value from the delta; update _computed_var_deps during dynamic dependency registration.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At reflex/state.py, line 951:

<comment>When a cached synchronous `ComputedVar` receives a dependency through `add_dependency()` after class initialization, this reverse map never includes it. The stale resolver retains the old cache and omits the computed value from the delta; update `_computed_var_deps` during dynamic dependency registration.</comment>

<file context>
@@ -916,6 +947,8 @@ def _init_var_dependency_dicts(cls):
                         cls.get_full_name()
                     )
+                    forward_deps.append((defining_state_cls.get_full_name(), dvar))
+            cls._computed_var_deps[cvar_name] = tuple(forward_deps)
 
         # ComputedVar with cache=False always need to be recomputed
</file context>


# ComputedVar with cache=False always need to be recomputed
cls._always_dirty_computed_vars = {
Expand Down Expand Up @@ -1802,30 +1833,70 @@ async def get_var_value(self, var: Var[VAR_TYPE]) -> VAR_TYPE:
return await value
return value

def _mark_dirty_computed_vars(self) -> None:
"""Mark ComputedVars that need to be recalculated based on dirty_vars."""
# Append expired computed vars to dirty_vars to trigger recalculation
self.dirty_vars.update(self._expired_computed_vars())
# Append always dirty computed vars to dirty_vars to trigger recalculation
self.dirty_vars.update(self._always_dirty_computed_vars)
def _get_state_by_full_name(self, full_name: str) -> BaseState | None:
"""Get a loaded state instance from this tree by its full name.

Args:
full_name: The full dotted name of the state.

Returns:
The state instance, or None when it is not loaded in this tree.
"""
if full_name == self.get_full_name():
return self
try:
return self._get_root_state().get_substate(tuple(full_name.split(".")))
except ValueError:
return None

def _mark_dirty_computed_vars(self, from_vars: set[str] | None = None) -> None:
"""Mark ComputedVars that depend on changed vars as stale.

A stale computed var is re-read lazily: on access, or in ``get_delta``.
Marking stops at a var that is already stale, so repeated writes in one
event do not re-walk the dependency graph.

Args:
from_vars: The changed vars to cascade from. Defaults to ``dirty_vars``
plus expired and always-dirty computed vars.
"""
dirty_vars = self.dirty_vars
while dirty_vars:
calc_vars, dirty_vars = dirty_vars, set()
if from_vars is None:
if self._interval_computed_vars:
dirty_vars.update(self._expired_computed_vars())
dirty_vars.update(self._always_dirty_computed_vars)
from_vars = dirty_vars

stale = self._stale_computed_vars
computed_vars = self.computed_vars
full_name = self.get_full_name()
while from_vars:
calc_vars, from_vars = from_vars, set()
for state_name, cvar in self._dirty_computed_vars(from_vars=calc_vars):
if state_name == self.get_full_name():
defining_state = self
if state_name == full_name:
if cvar in stale:
continue
actual_var = computed_vars[cvar]
if isinstance(actual_var, AsyncComputedVar):
# Async getters cannot be resolved on a sync read.
actual_var.mark_dirty(instance=self)
dirty_vars.add(cvar)
else:
stale.add(cvar)
from_vars.add(cvar)
else:
defining_state = self._get_root_state().get_substate(
tuple(state_name.split("."))
)
defining_state.dirty_vars.add(cvar)
actual_var = defining_state.computed_vars.get(cvar)
if actual_var is not None:
actual_var.mark_dirty(instance=defining_state)
if defining_state is self:
dirty_vars.add(cvar)
else:
if cvar in defining_state._stale_computed_vars:
continue
defining_state._mark_dirty_computed_vars(from_vars={cvar})
actual_var = defining_state.computed_vars[cvar]
if isinstance(actual_var, AsyncComputedVar):
actual_var.mark_dirty(instance=defining_state)
defining_state.dirty_vars.add(cvar)
else:
defining_state._stale_computed_vars.add(cvar)
# mark dirty where this var is defined
defining_state._mark_dirty()

Expand All @@ -1835,10 +1906,11 @@ def _expired_computed_vars(self) -> set[str]:
Returns:
Set of computed vars to include in the delta.
"""
computed_vars = self.computed_vars
return {
cvar
for cvar, cvar_obj in self.computed_vars.items()
if cvar_obj.needs_update(instance=self)
for cvar in self._interval_computed_vars
if computed_vars[cvar].needs_update(instance=self)
}

def _dirty_computed_vars(
Expand Down Expand Up @@ -1869,9 +1941,12 @@ def get_delta(self) -> Delta:
delta = {}

self._mark_dirty_computed_vars()
frontend_computed_vars: set[str] = {
name for name, cv in self.computed_vars.items() if not cv._backend
}
frontend_computed_vars = self._frontend_computed_vars
if stale := self._stale_computed_vars:
# Re-read stale frontend computed vars; the ones that changed join dirty_vars.
computed_vars = self.computed_vars
for name in [cvar for cvar in stale if cvar in frontend_computed_vars]:
computed_vars[name]._resolve_stale(self)
Comment on lines +1948 to +1949

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip dependencies already resolved from the stale snapshot

When a dependent computed var appears before its dependency in this set snapshot, resolving the dependent recursively resolves and removes the dependency, but the stale snapshot still invokes that dependency again later. Because its base dependency remains dirty, the getter is recomputed twice; this defeats the optimization and can leave non-idempotent or side-effecting computed chains inconsistent, with the dependent based on the first result and the dependency cache holding the second. Check that each snapshot entry is still stale before resolving it.

AGENTS.md reference: AGENTS.md:L43-L47

Useful? React with 👍 / 👎.


# Return the dirty vars for this instance, any cached/dependent computed vars,
# and always dirty computed vars (cache=False)
Expand Down Expand Up @@ -1958,6 +2033,11 @@ def _clean(self):
# Clean this state.
self.dirty_vars = set()
self.dirty_substates = set()
if stale := self._stale_computed_vars:
computed_vars = self.computed_vars
for name in stale:
computed_vars[name].mark_dirty(instance=self)
stale.clear()

def get_value(self, key: str) -> Any:
"""Get the value of a field (without proxying).
Expand Down Expand Up @@ -2075,6 +2155,16 @@ def __getstate__(self):
state.pop("parent_state", None)
state.pop("substates", None)
state.pop("_was_touched", None)
if stale := self._stale_computed_vars:
# A stale mark is only meaningful together with the marks it
# cascaded to, and those may sit in substates that are persisted
# separately or not at all. Persist the invalidation instead: drop
# the stale caches so a reload recomputes them, and the persisted
# dirty_vars re-mark the dependents.
computed_vars = self.computed_vars
for name in stale:
state.pop(computed_vars[name]._cache_attr, None)
state["_stale_computed_vars"] = set()
# Remove all inherited vars.
for inherited_var_name in self.inherited_vars:
state.pop(inherited_var_name, None)
Expand Down
Loading