diff --git a/news/+dirty-tracking-cutoff.performance.md b/news/+dirty-tracking-cutoff.performance.md new file mode 100644 index 00000000000..917090dec50 --- /dev/null +++ b/news/+dirty-tracking-cutoff.performance.md @@ -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. 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..ce8db45f587 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -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 + + @dataclasses.dataclass( eq=False, frozen=True, @@ -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) + 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..4ae9b65ffe0 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -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, @@ -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", @@ -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() @@ -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 @@ -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) @@ -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: @@ -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) # ComputedVar with cache=False always need to be recomputed cls._always_dirty_computed_vars = { @@ -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() @@ -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( @@ -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) # Return the dirty vars for this instance, any cached/dependent computed vars, # and always dirty computed vars (cache=False) @@ -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). @@ -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) 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..7c9aa2f73c9 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( @@ -801,11 +804,9 @@ def test_reset(test_state: TestState, child_state: ChildState): "num1", "num2", "obj", - "upper", "complex", "fig", "key", - "sum", "array", "map_key", "mapping", @@ -816,8 +817,9 @@ def test_reset(test_state: TestState, child_state: ChildState): "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", "upper"} assert child_state.dirty_vars == {"count", "value"} # The dirty substates should be reset. @@ -1280,6 +1282,90 @@ 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_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. @@ -1456,13 +1542,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, @@ -1473,14 +1559,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() @@ -3746,7 +3827,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()} @@ -4468,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("")