From 0472a69496a489740c141d212cc3e689f2c3cc1a Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 2 Sep 2026 22:28:54 +0500 Subject: [PATCH 1/4] Add dirty-tracking benchmarks and regression tests for write cutoff Benchmarks isolate the per-write cascade cost: many writes with and without dependents, unchanged writes, fan-out and chain shapes, and the clean get_delta floor. Three new unit tests fail on purpose and define the target behavior: an unchanged scalar write must not dirty the var or its dependents, and a computed var that recomputes to an equal value must not cascade. Two more pin the semantics that must survive the change: reads between writes see fresh values, and a getter runs once per read. Claude-Session: https://claude.ai/code/session_01U3KUBonGWkaQm2D7sWKQ5T --- tests/benchmarks/test_state_dirty.py | 142 +++++++++++++++++++++++++++ tests/units/test_state.py | 112 +++++++++++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 tests/benchmarks/test_state_dirty.py diff --git a/tests/benchmarks/test_state_dirty.py b/tests/benchmarks/test_state_dirty.py new file mode 100644 index 00000000000..d372c58d613 --- /dev/null +++ b/tests/benchmarks/test_state_dirty.py @@ -0,0 +1,142 @@ +"""Benchmarks for state dirty tracking. + +Each ``__setattr__`` on a base var records the name in ``dirty_vars`` and +cascades through ``_var_dependencies`` to invalidate dependent computed vars. +These benchmarks isolate the cost of that cascade for the common shapes: +many writes in one handler, a var fanning out to many computed vars, a chain +of computed vars, writes that do not change the value, and the final +``get_delta`` that recomputes and serializes. +""" + +import pytest +from pytest_codspeed import BenchmarkFixture + +import reflex as rx + +N_WRITES = 200 +N_FANOUT = 50 +N_CHAIN = 20 + + +def _make_fan(i: int): + def fget(self) -> int: + return self.source + i + + fget.__name__ = f"dep_{i}" + return rx.var(fget) + + +def _make_link(i: int): + prev = "head" if i == 0 else f"link_{i - 1}" + + def fget(self) -> int: + return getattr(self, prev) + 1 + + fget.__name__ = f"link_{i}" + return rx.var(fget, deps=[prev]) + + +# One base var feeding N_FANOUT computed vars, plus a var nothing depends on. +FanOutState = type( + "FanOutState", + (rx.State,), + { + "__module__": __name__, + "__annotations__": {"source": int, "unrelated": int}, + "source": 0, + "unrelated": 0, + **{f"dep_{i}": _make_fan(i) for i in range(N_FANOUT)}, + }, +) + +# A base var feeding a chain of N_CHAIN computed vars, each depending on the previous. +ChainState = type( + "ChainState", + (rx.State,), + { + "__module__": __name__, + "__annotations__": {"head": int}, + "head": 0, + **{f"link_{i}": _make_link(i) for i in range(N_CHAIN)}, + }, +) + + +def _fresh(state_cls: type[rx.State]) -> rx.State: + state = state_cls() # pyright: ignore [reportCallIssue] + state.dict() # prime computed var caches + state._clean() + return state + + +def test_many_writes_no_dependents(benchmark: BenchmarkFixture): + """N assignments to a var with no dependent computed vars.""" + state = _fresh(FanOutState) + + def run(): + for i in range(N_WRITES): + state.unrelated = i + state._clean() + + benchmark(run) + + +def test_many_writes_with_fanout(benchmark: BenchmarkFixture): + """N assignments to a var that N_FANOUT computed vars depend on.""" + state = _fresh(FanOutState) + + def run(): + for i in range(N_WRITES): + state.source = i + state._clean() + + benchmark(run) + + +def test_many_unchanged_writes(benchmark: BenchmarkFixture): + """N assignments of the value already stored.""" + state = _fresh(FanOutState) + state.source = 7 + state._clean() + + def run(): + for _ in range(N_WRITES): + state.source = 7 + state._clean() + + benchmark(run) + + +def test_chain_write_and_delta(benchmark: BenchmarkFixture): + """One write at the head of a computed var chain, then get_delta.""" + state = _fresh(ChainState) + value = [0] + + def run(): + value[0] += 1 + state.head = value[0] + state.get_delta() + state._clean() + + benchmark(run) + + +def test_fanout_write_and_delta(benchmark: BenchmarkFixture): + """One write that fans out to N_FANOUT computed vars, then get_delta.""" + state = _fresh(FanOutState) + value = [0] + + def run(): + value[0] += 1 + state.source = value[0] + state.get_delta() + state._clean() + + benchmark(run) + + +@pytest.mark.parametrize("state_cls", [FanOutState, ChainState]) +def test_get_delta_clean(state_cls, benchmark: BenchmarkFixture): + """get_delta on a state with nothing dirty (the per-event floor).""" + state = _fresh(state_cls) + benchmark(state.get_delta) diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 346bfc4e038..acf26a13463 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -1280,6 +1280,118 @@ def test_dirty_computed_var_from_backend_var( } +class ParityState(BaseState): + """A chain where the middle computed var often keeps its value.""" + + n: int = 0 + + @rx.var + def parity(self) -> int: + """Depends on n, but only changes when n crosses an odd/even boundary. + + Returns: + n modulo 2. + """ + return self.n % 2 + + @rx.var + def parity_label(self) -> str: + """Depends on parity only. + + Returns: + "even" or "odd". + """ + return "even" if self.parity == 0 else "odd" + + +def test_unchanged_write_is_not_dirty( + interdependent_state: InterdependentState, +) -> None: + """Assigning the value a scalar var already holds must not dirty anything. + + Args: + interdependent_state: A state with varying Var dependencies. + """ + interdependent_state.x = interdependent_state.x + assert not interdependent_state.dirty_vars + assert interdependent_state.get_delta() == {} + + +def test_unchanged_write_keeps_computed_var_cache( + interdependent_state: InterdependentState, +) -> None: + """Assigning an unchanged value must not invalidate dependent computed vars. + + Args: + interdependent_state: A state with varying Var dependencies. + """ + v1x2 = InterdependentState.computed_vars["v1x2"] + assert hasattr(interdependent_state, v1x2._cache_attr) + interdependent_state.v1 = interdependent_state.v1 + assert hasattr(interdependent_state, v1x2._cache_attr) + assert interdependent_state.get_delta() == {} + + +def test_computed_var_equal_value_stops_cascade() -> None: + """A computed var that recomputes to an equal value must not dirty its dependents.""" + s = ParityState() + s.dict() + s._clean() + s.n = 2 # parity stays 0, so parity_label must not be recomputed or sent + assert s.get_delta() == {s.get_full_name(): {"n" + FIELD_MARKER: 2}} + s._clean() + s.n = 3 # parity flips, so the whole chain is sent + assert s.get_delta() == { + s.get_full_name(): { + "n" + FIELD_MARKER: 3, + "parity" + FIELD_MARKER: 1, + "parity_label" + FIELD_MARKER: "odd", + } + } + + +def test_computed_var_fresh_after_each_write( + interdependent_state: InterdependentState, +) -> None: + """Reading a computed var between writes in one handler sees each new value. + + Args: + interdependent_state: A state with varying Var dependencies. + """ + interdependent_state.v1 = 1 + assert interdependent_state.v1x2x2 == 4 + interdependent_state.v1 = 2 + assert interdependent_state.v1x2x2 == 8 + assert interdependent_state.get_delta()[interdependent_state.get_full_name()] == { + "v1" + FIELD_MARKER: 2, + "v1x2" + FIELD_MARKER: 4, + "v1x2x2" + FIELD_MARKER: 8, + } + + +def test_computed_var_recomputes_once_per_read() -> None: + """Several writes followed by one read run the getter once, not per write.""" + calls: list[int] = [] + + class CountingState(BaseState): + v: int = 0 + + @rx.var + def double(self) -> int: + calls.append(self.v) + return self.v * 2 + + s = CountingState() + s.dict() + s._clean() + calls.clear() + s.v = 1 + s.v = 2 + s.v = 3 + assert s.double == 6 + assert calls == [3] + + def test_per_state_backend_var(interdependent_state: InterdependentState) -> None: """Set backend var on one instance, expect no affect in other instances. From ef8666bacd09481236bf7643b12f8348fe9905b7 Mon Sep 17 00:00:00 2001 From: Farhan Date: Wed, 2 Sep 2026 22:51:00 +0500 Subject: [PATCH 2/4] Skip unchanged scalar writes and resolve stale computed vars lazily __setattr__ now returns early when a scalar base or backend var is assigned the value it already holds, so nothing is marked dirty and nothing is sent. Objects are never compared, so reassigning a mutated object still marks it dirty. Dependent computed vars are marked stale instead of having their cache deleted. A stale var is resolved on read or in get_delta: it checks its recorded dependencies, resolves stale computed dependencies first, and recomputes only when one of them is in dirty_vars. A recomputed value equal to the cached one keeps the cache and stays out of dirty_vars, which stops the cascade at that point. Marking stops at a var that is already stale, so repeated writes in one handler no longer re-walk the dependency graph. Unresolved stale vars drop their cache in _clean. Async computed vars keep the old delete-and-resend behavior because they cannot be resolved on a sync read. is_hydrated is always sent because the client resets it locally on navigation and waits for it to come back. get_delta uses class-level sets for frontend computed vars and for computed vars with an update_interval instead of scanning every computed var on each call. Benchmarks (tests/benchmarks/test_state_dirty.py, median): 200 unchanged writes, 50 dependents 35.8 ms -> 0.33 ms 200 changed writes, 50 dependents 35.9 ms -> 3.0 ms 200 writes, no dependents 2.56 ms -> 2.08 ms chain write + get_delta 1.05 ms -> 0.66 ms Claude-Session: https://claude.ai/code/session_01U3KUBonGWkaQm2D7sWKQ5T --- news/+dirty-tracking-cutoff.performance.md | 1 + .../+dirty-tracking-cutoff.performance.md | 1 + .../src/reflex_base/utils/types.py | 8 +- .../reflex-base/src/reflex_base/vars/base.py | 80 ++++++++- reflex/state.py | 152 +++++++++++++++--- tests/units/test_state.py | 51 +++--- 6 files changed, 227 insertions(+), 66 deletions(-) create mode 100644 news/+dirty-tracking-cutoff.performance.md create mode 100644 packages/reflex-base/news/+dirty-tracking-cutoff.performance.md diff --git a/news/+dirty-tracking-cutoff.performance.md b/news/+dirty-tracking-cutoff.performance.md new file mode 100644 index 00000000000..fe5ea457813 --- /dev/null +++ b/news/+dirty-tracking-cutoff.performance.md @@ -0,0 +1 @@ +Skip dirty tracking when a scalar state var is assigned the value it already holds, and stop re-sending a cached computed var whose recomputed value is unchanged. Repeated writes in one event handler no longer re-walk the computed var dependency graph. diff --git a/packages/reflex-base/news/+dirty-tracking-cutoff.performance.md b/packages/reflex-base/news/+dirty-tracking-cutoff.performance.md new file mode 100644 index 00000000000..eca69964aff --- /dev/null +++ b/packages/reflex-base/news/+dirty-tracking-cutoff.performance.md @@ -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. diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index 4e59903b82c..1917ec9a071 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -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: diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index 8803960d51c..dc208b8a3b5 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -2261,6 +2261,28 @@ def is_computed_var(obj: Any) -> TypeGuard[ComputedVar]: return isinstance(obj, FakeComputedVarBaseClass) +_MISSING: Any = object() + +# Values compared by equality before a write is treated as a change. +_CUTOFF_TYPES = frozenset({int, float, str, bool, type(None)}) + + +def _is_unchanged(old: Any, new: Any) -> bool: + """Whether assigning ``new`` over ``old`` changes nothing observable. + + Only scalars are compared; objects are always treated as changed so that + reassigning a mutated object still marks it dirty. + + Args: + old: The stored value. + new: The value being assigned. + + 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 + + @dataclasses.dataclass( eq=False, frozen=True, @@ -2594,20 +2616,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) + 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( diff --git a/reflex/state.py b/reflex/state.py index 15722061c65..e1055ce62ac 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -58,11 +58,14 @@ from reflex_base.utils.types import _isinstance from reflex_base.vars import Field, VarData, field from reflex_base.vars.base import ( + _MISSING, + AsyncComputedVar, ComputedVar, DynamicRouteVar, EvenMoreBasicBaseState, ToOperation, Var, + _is_unchanged, computed_var, dispatch, is_computed_var, @@ -369,6 +372,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", @@ -402,6 +408,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() @@ -425,6 +440,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 @@ -830,6 +848,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) @@ -894,10 +913,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: @@ -916,6 +947,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) # ComputedVar with cache=False always need to be recomputed cls._always_dirty_computed_vars = { @@ -1518,6 +1551,8 @@ def __setattr__(self, name: str, value: Any): return if name in self.backend_vars: + if _is_unchanged(self._backend_vars.get(name, _MISSING), value): + return self._backend_vars.__setitem__(name, value) self.dirty_vars.add(name) self._mark_dirty() @@ -1540,6 +1575,8 @@ def __setattr__(self, name: str, value: Any): fields = self.get_fields() if (field := fields.get(name)) is not None and field.is_var: + if _is_unchanged(self.__dict__.get(name, _MISSING), value): + return field_type = field.outer_type_ if not _isinstance(value, field_type, nested=1, treat_var_as_type=False): logger.error( @@ -1802,30 +1839,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() @@ -1835,10 +1912,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( @@ -1869,9 +1947,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) # Return the dirty vars for this instance, any cached/dependent computed vars, # and always dirty computed vars (cache=False) @@ -1958,6 +2039,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). @@ -2302,7 +2388,7 @@ async def hydrate(self) -> None: self._reset_client_storage() # Mark state as not hydrated (until on_loads are complete) - self.is_hydrated = False + self._set_is_hydrated(False) # Get the initial state if needed. ctx = EventContext.get() @@ -2319,7 +2405,21 @@ def set_is_hydrated(self, value: bool) -> None: Args: value: The hydrated state. """ - self.is_hydrated = value + self._set_is_hydrated(value) + + def _set_is_hydrated(self, value: bool) -> None: + """Write ``is_hydrated`` and always include it in the next delta. + + The client resets ``is_hydrated`` locally when navigation starts and + flushes its queued events only when a delta carries it back. + + Args: + value: The hydrated state. + """ + root = self._get_root_state() + root.is_hydrated = value + root.dirty_vars.add(constants.CompileVars.IS_HYDRATED) + root._mark_dirty() T = TypeVar("T", bound=BaseState) @@ -2469,9 +2569,9 @@ def on_load_internal(self) -> list[Event | EventSpec | event.EventCallback] | No self.router.url.path ) if not load_events: - self.is_hydrated = True + self._set_is_hydrated(True) return None # Fast path for navigation with no on_load events defined. - self.is_hydrated = False + self._set_is_hydrated(False) return [ *Event.from_event_type( load_events, diff --git a/tests/units/test_state.py b/tests/units/test_state.py index acf26a13463..3705f9cff80 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -719,17 +719,20 @@ def test_set_dirty_var(test_state): # Initially there should be no dirty vars. assert test_state.dirty_vars == set() - # Setting a var should mark it as dirty. + # Setting a var should mark it dirty and its dependent computed var stale. test_state.num1 = 1 - assert test_state.dirty_vars == {"num1", "sum"} + assert test_state.dirty_vars == {"num1"} + assert test_state._stale_computed_vars == {"sum"} # Setting another var should mark it as dirty. test_state.num2 = 2 - assert test_state.dirty_vars == {"num1", "num2", "sum"} + assert test_state.dirty_vars == {"num1", "num2"} + assert test_state._stale_computed_vars == {"sum"} - # Cleaning the state should remove all dirty vars. + # Cleaning the state should remove all dirty and stale vars. test_state._clean() assert test_state.dirty_vars == set() + assert test_state._stale_computed_vars == set() def test_set_dirty_substate( @@ -797,35 +800,26 @@ def test_reset(test_state: TestState, child_state: ChildState): assert test_state._backend == 0 assert child_state.value == "" + # Scalars that already held their default are not dirty; objects always are. expected_dirty_vars = { "num1", "num2", "obj", - "upper", "complex", "fig", - "key", - "sum", "array", - "map_key", "mapping", "dt", "_backend", - "mixin", - "_mixin_backend", - "asynctest", } # The dirty vars should be reset. assert test_state.dirty_vars == expected_dirty_vars - assert child_state.dirty_vars == {"count", "value"} + assert test_state._stale_computed_vars == {"sum"} + assert child_state.dirty_vars == {"value"} - # The dirty substates should be reset. - assert test_state.dirty_substates == { - ChildState.get_name(), - ChildState2.get_name(), - ChildState3.get_name(), - } + # Only the substate whose value actually changed is dirty. + assert test_state.dirty_substates == {ChildState.get_name()} def test_reset_does_not_reset_inherited_backend_vars( @@ -1568,13 +1562,13 @@ def comp_v(self) -> int: } cs._clean() assert cs.dirty_vars == set() - assert cs.get_delta() == { - cs.get_name(): {"no_cache_v" + FIELD_MARKER: 0, "dep_v" + FIELD_MARKER: 0} - } + # dep_v is recomputed but still equal, so only the uncached var is sent. + assert cs.get_delta() == {cs.get_name(): {"no_cache_v" + FIELD_MARKER: 0}} cs._clean() assert cs.dirty_vars == set() cs.v = 1 - assert cs.dirty_vars == {"v", "comp_v", "dep_v", "no_cache_v"} + assert cs.dirty_vars == {"v", "no_cache_v"} + assert cs._stale_computed_vars == {"comp_v", "dep_v"} assert cs.get_delta() == { cs.get_name(): { "v" + FIELD_MARKER: 1, @@ -1585,14 +1579,9 @@ def comp_v(self) -> int: } cs._clean() assert cs.dirty_vars == set() - assert cs.get_delta() == { - cs.get_name(): {"no_cache_v" + FIELD_MARKER: 1, "dep_v" + FIELD_MARKER: 1} - } - cs._clean() - assert cs.dirty_vars == set() - assert cs.get_delta() == { - cs.get_name(): {"no_cache_v" + FIELD_MARKER: 1, "dep_v" + FIELD_MARKER: 1} - } + # The uncached var is always sent; dep_v is recomputed each time but only + # sent when its value changes. + assert cs.get_delta() == {cs.get_name(): {"no_cache_v" + FIELD_MARKER: 1}} cs._clean() assert cs.dirty_vars == set() @@ -3858,7 +3847,7 @@ def foo(self) -> str: # Reassign router var state.router = state.router assert rx_state.dirty_vars == {"router"} - assert state.dirty_vars == {"foo"} + assert state._stale_computed_vars == {"foo"} assert parent_state.dirty_substates == {RouterVarDepState.get_name()} From fc47df9cad7c78676b9ff50c404cd6a822ef9076 Mon Sep 17 00:00:00 2001 From: Farhan Date: Thu, 3 Sep 2026 00:02:02 +0500 Subject: [PATCH 3/4] Drop the scalar write cutoff Assigning a scalar var the value it already holds is an established way to force a computed var to recompute when it reads something the dependency tracker cannot see. Skipping that write silently broke the idiom, so the write is marked dirty again, and the is_hydrated helper that only existed to bypass the cutoff is gone. The recomputed-value cutoff on computed vars stays: a forced recompute still runs, and a changed result still ships. Claude-Session: https://claude.ai/code/session_01U3KUBonGWkaQm2D7sWKQ5T --- news/+dirty-tracking-cutoff.performance.md | 2 +- .../reflex-base/src/reflex_base/vars/base.py | 11 ++--- reflex/state.py | 28 ++--------- tests/units/test_state.py | 48 ++++++------------- 4 files changed, 24 insertions(+), 65 deletions(-) diff --git a/news/+dirty-tracking-cutoff.performance.md b/news/+dirty-tracking-cutoff.performance.md index fe5ea457813..917090dec50 100644 --- a/news/+dirty-tracking-cutoff.performance.md +++ b/news/+dirty-tracking-cutoff.performance.md @@ -1 +1 @@ -Skip dirty tracking when a scalar state var is assigned the value it already holds, and stop re-sending a cached computed var whose recomputed value is unchanged. Repeated writes in one event handler no longer re-walk the computed var dependency graph. +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. diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index dc208b8a3b5..ce8db45f587 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -2263,19 +2263,18 @@ def is_computed_var(obj: Any) -> TypeGuard[ComputedVar]: _MISSING: Any = object() -# Values compared by equality before a write is treated as a change. +# 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 assigning ``new`` over ``old`` changes nothing observable. + """Whether a recomputed value is observably the same as the cached one. - Only scalars are compared; objects are always treated as changed so that - reassigning a mutated object still marks it dirty. + Only scalars are compared; objects are always treated as changed. Args: - old: The stored value. - new: The value being assigned. + old: The cached value. + new: The recomputed value. Returns: True when both are scalars of the same type and compare equal. diff --git a/reflex/state.py b/reflex/state.py index e1055ce62ac..a13de4fbf0d 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -58,14 +58,12 @@ from reflex_base.utils.types import _isinstance from reflex_base.vars import Field, VarData, field from reflex_base.vars.base import ( - _MISSING, AsyncComputedVar, ComputedVar, DynamicRouteVar, EvenMoreBasicBaseState, ToOperation, Var, - _is_unchanged, computed_var, dispatch, is_computed_var, @@ -1551,8 +1549,6 @@ def __setattr__(self, name: str, value: Any): return if name in self.backend_vars: - if _is_unchanged(self._backend_vars.get(name, _MISSING), value): - return self._backend_vars.__setitem__(name, value) self.dirty_vars.add(name) self._mark_dirty() @@ -1575,8 +1571,6 @@ def __setattr__(self, name: str, value: Any): fields = self.get_fields() if (field := fields.get(name)) is not None and field.is_var: - if _is_unchanged(self.__dict__.get(name, _MISSING), value): - return field_type = field.outer_type_ if not _isinstance(value, field_type, nested=1, treat_var_as_type=False): logger.error( @@ -2388,7 +2382,7 @@ async def hydrate(self) -> None: self._reset_client_storage() # Mark state as not hydrated (until on_loads are complete) - self._set_is_hydrated(False) + self.is_hydrated = False # Get the initial state if needed. ctx = EventContext.get() @@ -2405,21 +2399,7 @@ def set_is_hydrated(self, value: bool) -> None: Args: value: The hydrated state. """ - self._set_is_hydrated(value) - - def _set_is_hydrated(self, value: bool) -> None: - """Write ``is_hydrated`` and always include it in the next delta. - - The client resets ``is_hydrated`` locally when navigation starts and - flushes its queued events only when a delta carries it back. - - Args: - value: The hydrated state. - """ - root = self._get_root_state() - root.is_hydrated = value - root.dirty_vars.add(constants.CompileVars.IS_HYDRATED) - root._mark_dirty() + self.is_hydrated = value T = TypeVar("T", bound=BaseState) @@ -2569,9 +2549,9 @@ def on_load_internal(self) -> list[Event | EventSpec | event.EventCallback] | No self.router.url.path ) if not load_events: - self._set_is_hydrated(True) + self.is_hydrated = True return None # Fast path for navigation with no on_load events defined. - self._set_is_hydrated(False) + self.is_hydrated = False return [ *Event.from_event_type( load_events, diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 3705f9cff80..c00aa97265c 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -800,26 +800,34 @@ def test_reset(test_state: TestState, child_state: ChildState): assert test_state._backend == 0 assert child_state.value == "" - # Scalars that already held their default are not dirty; objects always are. expected_dirty_vars = { "num1", "num2", "obj", "complex", "fig", + "key", "array", + "map_key", "mapping", "dt", "_backend", + "mixin", + "_mixin_backend", + "asynctest", } - # The dirty vars should be reset. + # The dirty vars should be reset; dependent computed vars are stale. assert test_state.dirty_vars == expected_dirty_vars - assert test_state._stale_computed_vars == {"sum"} - assert child_state.dirty_vars == {"value"} + assert test_state._stale_computed_vars == {"sum", "upper"} + assert child_state.dirty_vars == {"count", "value"} - # Only the substate whose value actually changed is dirty. - assert test_state.dirty_substates == {ChildState.get_name()} + # The dirty substates should be reset. + assert test_state.dirty_substates == { + ChildState.get_name(), + ChildState2.get_name(), + ChildState3.get_name(), + } def test_reset_does_not_reset_inherited_backend_vars( @@ -1298,34 +1306,6 @@ def parity_label(self) -> str: return "even" if self.parity == 0 else "odd" -def test_unchanged_write_is_not_dirty( - interdependent_state: InterdependentState, -) -> None: - """Assigning the value a scalar var already holds must not dirty anything. - - Args: - interdependent_state: A state with varying Var dependencies. - """ - interdependent_state.x = interdependent_state.x - assert not interdependent_state.dirty_vars - assert interdependent_state.get_delta() == {} - - -def test_unchanged_write_keeps_computed_var_cache( - interdependent_state: InterdependentState, -) -> None: - """Assigning an unchanged value must not invalidate dependent computed vars. - - Args: - interdependent_state: A state with varying Var dependencies. - """ - v1x2 = InterdependentState.computed_vars["v1x2"] - assert hasattr(interdependent_state, v1x2._cache_attr) - interdependent_state.v1 = interdependent_state.v1 - assert hasattr(interdependent_state, v1x2._cache_attr) - assert interdependent_state.get_delta() == {} - - def test_computed_var_equal_value_stops_cascade() -> None: """A computed var that recomputes to an equal value must not dirty its dependents.""" s = ParityState() From 4ed537a69f40f3b8549a622561c2d15308136869 Mon Sep 17 00:00:00 2001 From: Farhan Date: Thu, 3 Sep 2026 23:35:46 +0500 Subject: [PATCH 4/4] Drop stale computed var marks when persisting state A stale mark is only meaningful together with the marks it cascaded to, and those may live in substates that redis persists separately or not at all. On socket connect the root is written with its dynamic route vars already stale, so the next event's cascade stops early and dependents in freshly loaded substates keep their old cached values. Persist the invalidation instead: drop the stale caches at pickle time so a reload recomputes them, and let the persisted dirty_vars re-mark the dependents. Claude-Session: https://claude.ai/code/session_01F1U5DCxRAQ1Xys5GywyNgd --- reflex/state.py | 10 +++++++++ tests/units/test_state.py | 44 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/reflex/state.py b/reflex/state.py index a13de4fbf0d..4ae9b65ffe0 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -2155,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) diff --git a/tests/units/test_state.py b/tests/units/test_state.py index c00aa97265c..7c9aa2f73c9 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -4549,6 +4549,50 @@ class DillState(BaseState): _ = state3._serialize() +@pytest.mark.asyncio +async def test_stale_marks_do_not_survive_redis_roundtrip( + state_manager_redis: StateManagerRedis, token: str +): + """Dependents of a stale computed var recompute after a partial persist. + + Mirrors socket connect: the root is modified and persisted without a clean, + while the substate holding the dependent computed var is not written back. + + Args: + state_manager_redis: A redis state manager. + token: A token. + """ + + class StaleRoot(BaseState): + num: int = 0 + + @rx.var + def doubled(self) -> int: + return self.num * 2 + + class StaleSub(StaleRoot): + @rx.var + def doubled_str(self) -> str: + return str(self.doubled) + + root_token = BaseStateToken(ident=token, cls=StaleRoot) + # Populate and persist the caches. + async with state_manager_redis.modify_state(root_token) as root: + sub = await root.get_state(StaleSub) + assert sub.doubled_str == "0" + root._clean() + # Change the base var and exit without cleaning; only the root is touched. + async with state_manager_redis.modify_state(root_token) as root: + root.num = 5 + async with state_manager_redis.modify_state(root_token) as root: + sub = await root.get_state(StaleSub) + delta = root.get_delta() + assert delta.get(StaleSub.get_full_name()) == { + "doubled_str" + FIELD_MARKER: "10" + } + assert sub.doubled_str == "10" + + def test_typed_state() -> None: class TypedState(rx.State): field: rx.Field[str] = rx.field("")