From d4ed26a5896f7dae27c953d5fe702461c8a60229 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 19:32:59 +0000 Subject: [PATCH 01/16] Skip deep type-validation on state var hot paths Assigning a state var and reading a computed var both ran _isinstance(value, type, nested=1), walking every element of list/dict values only to gate a diagnostic log. The computed var check also ran on every access, including cache hits. - Validate computed var return types only when the value is recomputed (sync and async), not on cache hits. - Validate one container level deep only in dev mode; production now checks just the outer type (the check never gates behavior, it only logs). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X --- .../src/reflex_base/utils/types.py | 17 +++++ .../reflex-base/src/reflex_base/vars/base.py | 40 ++++++---- reflex/state.py | 6 +- tests/units/reflex_base/utils/test_types.py | 26 ++++++- tests/units/reflex_base/vars/test_base.py | 74 +++++++++++++++++++ tests/units/test_state.py | 16 ++++ 6 files changed, 160 insertions(+), 19 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index 85c769523a0..b3a0971215c 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -633,6 +633,23 @@ def does_obj_satisfy_typed_dict( return required_keys.issubset(frozenset(obj)) +@lru_cache +def _validation_depth() -> int: + """Get the container depth for hot-path state var type validation. + + The result of these checks only gates a diagnostic log, so production + mode skips the per-element walk of large containers and only validates + the outer type. + + Returns: + The `nested` depth to pass to `_isinstance`. + """ + from reflex_base import constants + from reflex_base.environment import environment + + return 0 if environment.REFLEX_ENV_MODE.get() == constants.Env.PROD else 1 + + def _isinstance( obj: Any, cls: GenericType, diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index fa461cf5a0b..59074c8995b 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -66,6 +66,7 @@ GenericType, Self, _isinstance, + _validation_depth, get_origin, has_args, safe_issubclass, @@ -2587,23 +2588,28 @@ 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()) - value = getattr(instance, self._cache_attr) + self._check_deprecated_return_type(instance, value) + return value - self._check_deprecated_return_type(instance, value) + # 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()) + value = getattr(instance, self._cache_attr) + # Only validate the return type when the value was just computed. + self._check_deprecated_return_type(instance, value) + return value - return value + return getattr(instance, self._cache_attr) 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): + if not _isinstance( + value, self._var_type, nested=_validation_depth(), treat_var_as_type=False + ): console.error( f"Computed var '{type(instance).__name__}.{self._name}' must return" f" a value of type '{escape(str(self._var_type))}', got '{value!s}' of type {type(value)}." @@ -2858,9 +2864,11 @@ async def _awaitable_result(instance: BaseState = instance) -> RETURN_TYPE: instance._was_touched = True # Set the last updated timestamp on the state instance. setattr(instance, self._last_updated_attr, datetime.datetime.now()) - value = getattr(instance, self._cache_attr) - self._check_deprecated_return_type(instance, value) - return value + value = getattr(instance, self._cache_attr) + # Only validate the return type when the value was just computed. + self._check_deprecated_return_type(instance, value) + return value + return getattr(instance, self._cache_attr) return _awaitable_result() diff --git a/reflex/state.py b/reflex/state.py index bad54ff4e8e..953219306e6 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -54,7 +54,7 @@ ) from reflex_base.utils.exceptions import ImmutableStateError as ImmutableStateError from reflex_base.utils.serializers import serializer -from reflex_base.utils.types import _isinstance +from reflex_base.utils.types import _isinstance, _validation_depth from reflex_base.vars import Field, VarData, field from reflex_base.vars.base import ( ComputedVar, @@ -1538,7 +1538,9 @@ def __setattr__(self, name: str, value: Any): if (field := fields.get(name)) is not None and field.is_var: field_type = field.outer_type_ - if not _isinstance(value, field_type, nested=1, treat_var_as_type=False): + if not _isinstance( + value, field_type, nested=_validation_depth(), treat_var_as_type=False + ): console.error( f"Expected field '{type(self).__name__}.{name}' to receive type '{escape(str(field_type))}'," f" but got '{value}' of type '{type(value)}'." diff --git a/tests/units/reflex_base/utils/test_types.py b/tests/units/reflex_base/utils/test_types.py index e4ab9dece45..b6dfc7afde8 100644 --- a/tests/units/reflex_base/utils/test_types.py +++ b/tests/units/reflex_base/utils/test_types.py @@ -1,6 +1,15 @@ """Tests for reflex_base.utils.types.""" -from reflex_base.utils.types import ASGIApp, Message, Receive, Scope, Send +from reflex_base import constants +from reflex_base.environment import environment +from reflex_base.utils.types import ( + ASGIApp, + Message, + Receive, + Scope, + Send, + _validation_depth, +) from typing_extensions import TypeAliasType @@ -14,3 +23,18 @@ def test_asgi_aliases_keep_their_names(): assert Receive.__name__ == "Receive" assert Send.__name__ == "Send" assert ASGIApp.__name__ == "ASGIApp" + + +def test_validation_depth_by_env_mode(): + """Hot-path validation walks containers in dev but stays shallow in prod.""" + initial = environment.REFLEX_ENV_MODE.getenv() + _validation_depth.cache_clear() + try: + environment.REFLEX_ENV_MODE.set(constants.Env.PROD) + assert _validation_depth() == 0 + _validation_depth.cache_clear() + environment.REFLEX_ENV_MODE.set(constants.Env.DEV) + assert _validation_depth() == 1 + finally: + environment.REFLEX_ENV_MODE.set(initial) + _validation_depth.cache_clear() diff --git a/tests/units/reflex_base/vars/test_base.py b/tests/units/reflex_base/vars/test_base.py index b0ff880aa20..77f8a134e3d 100644 --- a/tests/units/reflex_base/vars/test_base.py +++ b/tests/units/reflex_base/vars/test_base.py @@ -6,6 +6,9 @@ from reflex_base.utils.types import get_field_type from reflex_base.vars.base import EvenMoreBasicBaseState, field +import reflex as rx +from reflex.state import BaseState + _MARKER_ATTR = "_marker" @@ -87,3 +90,74 @@ class MyState(EvenMoreBasicBaseState): rebuilt = MyState.get_fields()["name"] assert rebuilt._check is check # pyright: ignore[reportAttributeAccessIssue] + + +def test_computed_var_return_type_checked_only_on_recompute(mocker): + """The return type of a cached computed var is only validated on recompute. + + Args: + mocker: Pytest mocker object. + """ + + class ReturnTypeCheckState(BaseState): + v: int = 0 + + @rx.var + def wrong_typed(self) -> str: + return self.v # pyright: ignore [reportReturnType] + + state = ReturnTypeCheckState() + mock_error = mocker.patch("reflex_base.utils.console.error") + assert state.wrong_typed == 0 + assert mock_error.call_count == 1 + # Cache hits must not re-run the (potentially deep) type check. + assert state.wrong_typed == 0 + assert mock_error.call_count == 1 + # Invalidation triggers a recompute, which re-checks the return type. + state.v = 1 + assert state.wrong_typed == 1 + assert mock_error.call_count == 2 + + +def test_non_cached_computed_var_return_type_checked_every_access(mocker): + """A cache=False computed var recomputes, and is type-checked, on every access. + + Args: + mocker: Pytest mocker object. + """ + + class NonCachedReturnTypeCheckState(BaseState): + v: int = 0 + + @rx.var(cache=False) + def wrong_typed(self) -> str: + return self.v # pyright: ignore [reportReturnType] + + state = NonCachedReturnTypeCheckState() + mock_error = mocker.patch("reflex_base.utils.console.error") + assert state.wrong_typed == 0 + assert state.wrong_typed == 0 + assert mock_error.call_count == 2 + + +async def test_async_computed_var_return_type_checked_only_on_recompute(mocker): + """The return type of a cached async computed var is only validated on recompute. + + Args: + mocker: Pytest mocker object. + """ + + class AsyncReturnTypeCheckState(BaseState): + v: int = 0 + + @rx.var + async def wrong_typed(self) -> str: + return self.v # pyright: ignore [reportReturnType] + + state = AsyncReturnTypeCheckState() + mock_error = mocker.patch("reflex_base.utils.console.error") + assert await state.wrong_typed == 0 + assert mock_error.call_count == 1 + # Cache hits must not re-run the (potentially deep) type check. + assert await state.wrong_typed == 0 + assert mock_error.call_count == 1 diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 05e06ee9f8c..3325c4db514 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -1436,6 +1436,22 @@ def comp_v(self) -> int: assert comp_v_calls == 2 +def test_setattr_wrong_type_logs_error(mocker): + """Assigning a value of the wrong type to a base var logs an error in dev mode. + + Args: + mocker: Pytest mocker object. + """ + + class WrongTypeState(BaseState): + n: int = 0 + + state = WrongTypeState() + mock_error = mocker.patch("reflex.utils.console.error") + setattr(state, "n", "not an int") # noqa: B010 + assert mock_error.call_count == 1 + + def test_computed_var_cached_depends_on_non_cached(): """Test that a cached var is recalculated if it depends on non-cached ComputedVar.""" From 5e8d83093bde3c75373e3954a2cc304793945906 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 20:05:28 +0000 Subject: [PATCH 02/16] Add changelog fragments Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X --- news/6738.performance.md | 1 + packages/reflex-base/news/6738.performance.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 news/6738.performance.md create mode 100644 packages/reflex-base/news/6738.performance.md diff --git a/news/6738.performance.md b/news/6738.performance.md new file mode 100644 index 00000000000..2f719d22187 --- /dev/null +++ b/news/6738.performance.md @@ -0,0 +1 @@ +Skip deep element-wise type validation on state var hot paths: computed var return types are only checked on recompute (not on cache hits), and production mode no longer walks every element of assigned containers for the log-only type check. diff --git a/packages/reflex-base/news/6738.performance.md b/packages/reflex-base/news/6738.performance.md new file mode 100644 index 00000000000..2f719d22187 --- /dev/null +++ b/packages/reflex-base/news/6738.performance.md @@ -0,0 +1 @@ +Skip deep element-wise type validation on state var hot paths: computed var return types are only checked on recompute (not on cache hits), and production mode no longer walks every element of assigned containers for the log-only type check. From 1f6a4f4902e85d92b8571a74807ab7386d67f77a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 20:18:48 +0000 Subject: [PATCH 03/16] Honor runtime env mode changes in _validation_depth Address review feedback: instead of caching the first observed mode forever, re-read the raw REFLEX_ENV_MODE value on every call and cache the depth per raw value, so in-process mode changes take effect immediately at negligible hot-path cost. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X --- .../src/reflex_base/utils/types.py | 24 +++++++++++++++---- tests/units/reflex_base/utils/test_types.py | 9 +++---- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index b3a0971215c..dcb2dac8af0 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -3,6 +3,7 @@ from __future__ import annotations import dataclasses +import os import sys import types from collections.abc import Callable, Iterable, Mapping, Sequence @@ -634,20 +635,33 @@ def does_obj_satisfy_typed_dict( @lru_cache +def _validation_depth_for_mode(raw_mode: str | None) -> int: + """Get the validation depth for a raw REFLEX_ENV_MODE value. + + Args: + raw_mode: The raw environment variable value (or None if unset). + + Returns: + The `nested` depth to pass to `_isinstance`. + """ + return 0 if raw_mode == constants.Env.PROD.value else 1 + + def _validation_depth() -> int: """Get the container depth for hot-path state var type validation. The result of these checks only gates a diagnostic log, so production mode skips the per-element walk of large containers and only validates - the outer type. + the outer type. The environment is re-read on every call so in-process + mode changes take effect immediately. Returns: The `nested` depth to pass to `_isinstance`. """ - from reflex_base import constants - from reflex_base.environment import environment - - return 0 if environment.REFLEX_ENV_MODE.get() == constants.Env.PROD else 1 + # Read the raw env var directly: interpreting it through + # environment.REFLEX_ENV_MODE.get() on this hot path would re-parse the + # enum on every state var assignment (and the import would be circular). + return _validation_depth_for_mode(os.environ.get("REFLEX_ENV_MODE")) def _isinstance( diff --git a/tests/units/reflex_base/utils/test_types.py b/tests/units/reflex_base/utils/test_types.py index b6dfc7afde8..344b01a3ed0 100644 --- a/tests/units/reflex_base/utils/test_types.py +++ b/tests/units/reflex_base/utils/test_types.py @@ -26,15 +26,16 @@ def test_asgi_aliases_keep_their_names(): def test_validation_depth_by_env_mode(): - """Hot-path validation walks containers in dev but stays shallow in prod.""" + """Hot-path validation walks containers in dev but stays shallow in prod. + + In-process environment mode changes must take effect immediately, without + any cache invalidation by the caller. + """ initial = environment.REFLEX_ENV_MODE.getenv() - _validation_depth.cache_clear() try: environment.REFLEX_ENV_MODE.set(constants.Env.PROD) assert _validation_depth() == 0 - _validation_depth.cache_clear() environment.REFLEX_ENV_MODE.set(constants.Env.DEV) assert _validation_depth() == 1 finally: environment.REFLEX_ENV_MODE.set(initial) - _validation_depth.cache_clear() From 0f081b1fc7090e11fb4f2985086dcd322440ab4f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 19:41:41 +0000 Subject: [PATCH 04/16] Reduce MutableProxy per-element overhead and cache per-field proxies _wrap_recursive walked 5 stack frames (dataclasses-internal check) for every element retrieved through a proxy, even immutable scalars that never get wrapped. Check is_mutable_type first so immutable elements skip the frame walk entirely. Also cache the MutableProxy built for each mutable state var on the instance, keyed by field name, instead of constructing a fresh proxy on every attribute read. The cache is invalidated by identity when the underlying value is reassigned and is excluded from pickling (copy and deepcopy already go through __getstate__). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X --- reflex/istate/proxy.py | 11 +++++--- reflex/state.py | 14 ++++++++++- tests/units/istate/test_proxy.py | 43 ++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/reflex/istate/proxy.py b/reflex/istate/proxy.py index ce2eef96834..1c7f009e385 100644 --- a/reflex/istate/proxy.py +++ b/reflex/istate/proxy.py @@ -664,13 +664,18 @@ def _wrap_mutable(self, value: Any, path: tuple[_AccessSpec, ...]) -> Any: Returns: The wrapped value. """ - # When called from dataclasses internal code, return the unwrapped value - if self._is_called_from_dataclasses_internal(): - return value # If we already have a proxy, unwrap and rewrap to make sure the state # reference is up to date. if isinstance(value, MutableProxy): value = value.__wrapped__ + # Immutable values (the common case when iterating a container of + # scalars) never need wrapping nor the frame inspection below. + if not is_mutable_type(type(value)): + return value + # When called from dataclasses internal code, return the unwrapped value + if self._is_called_from_dataclasses_internal(): + return value + # Recursively wrap mutable types. return globals()[self.__base_proxy__]( wrapped=value, state=self._self_state, diff --git a/reflex/state.py b/reflex/state.py index 953219306e6..21a54819e52 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -1481,7 +1481,17 @@ def _get_attribute(self, name: str) -> Any: name in super().__getattribute__("base_vars") or name in backend_vars ): # track changes in mutable containers (list, dict, set, etc) - return MutableProxy(wrapped=value, state=self, field_name=name) + cache = super().__getattribute__("__dict__").get("_mutable_proxy_cache") + if cache is None: + cache = {} + object.__setattr__(self, "_mutable_proxy_cache", cache) + proxy = cache.get(name) + # isinstance also rejects entries degraded by deepcopy, which + # copies a MutableProxy as its unwrapped value. + if not isinstance(proxy, MutableProxy) or proxy.__wrapped__ is not value: + proxy = MutableProxy(wrapped=value, state=self, field_name=name) + cache[name] = proxy + return proxy return value @@ -2074,6 +2084,8 @@ def __getstate__(self): state.pop("parent_state", None) state.pop("substates", None) state.pop("_was_touched", None) + # Proxies wrap live state references and are rebuilt on access. + state.pop("_mutable_proxy_cache", None) # Remove all inherited vars. for inherited_var_name in self.inherited_vars: state.pop(inherited_var_name, None) diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index 43c750cc08e..e30b892f9c5 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -713,3 +713,46 @@ async def test_mutable_proxy_custom_get_method_path_tracking( ) as state: assert isinstance(state, CustomGetState) assert state.registry.entries == {"a": [1, 2]} + + +def test_mutable_proxy_cached_per_field(): + """Repeated reads of a mutable var reuse the proxy until reassignment.""" + state = ProxyTestState() + first = state.items + assert isinstance(first, MutableProxy) + assert state.items is first + # Reassignment invalidates the cached proxy. + state.items = [Item(2)] + second = state.items + assert isinstance(second, MutableProxy) + assert second is not first + assert second[0].id == 2 + # In-place mutation keeps the same wrapped object, so the proxy is reused. + second.append(Item(3)) + assert state.items is second + + +def test_mutable_proxy_cache_not_serialized(): + """The per-instance proxy cache never leaks into pickles or copies.""" + state = ProxyTestState() + state.items.append(Item(1)) # populate the proxy cache + assert "_mutable_proxy_cache" in state.__dict__ + assert "_mutable_proxy_cache" not in state.__getstate__() + + restored = pickle.loads(pickle.dumps(state)) + assert "_mutable_proxy_cache" not in restored.__dict__ + restored_items = restored.items + assert isinstance(restored_items, MutableProxy) + # The restored proxy tracks the restored state, not the original. + assert restored_items._self_state is restored + + +def test_mutable_proxy_iteration_yields_plain_immutables(): + """Iterating a proxied container returns immutable elements unwrapped.""" + state = ProxyTestState() + state.items = [Item(1), Item(2)] + numbers = [item.id for item in state.items] + assert numbers == [1, 2] + assert all(type(n) is int for n in numbers) + # Mutable elements remain wrapped so nested mutations mark the state dirty. + assert all(isinstance(item, MutableProxy) for item in state.items) From 34eb4417798b858a61e627e17423aadb086f84e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 20:05:42 +0000 Subject: [PATCH 05/16] Add changelog fragment Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X --- news/6740.performance.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/6740.performance.md diff --git a/news/6740.performance.md b/news/6740.performance.md new file mode 100644 index 00000000000..3512eb02e06 --- /dev/null +++ b/news/6740.performance.md @@ -0,0 +1 @@ +Speed up MutableProxy: immutable elements retrieved through a proxy skip the dataclasses frame-walk check, and the proxy for each mutable state var is cached per instance instead of rebuilt on every attribute read. From cc8aa03af72ef361d89cabff1c6173659f4cac1d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 20:15:39 +0000 Subject: [PATCH 06/16] Evict cached proxy on reassignment; update proxy identity test Address review feedback: drop the _mutable_proxy_cache entry when a base or backend var is assigned, so the replaced value is not kept alive by the cached proxy until the next read. Update test_set_base_field_via_setter for the new cached-proxy identity semantics. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X --- reflex/state.py | 7 +++++++ tests/units/istate/test_proxy.py | 4 ++++ tests/units/test_state.py | 4 ++-- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/reflex/state.py b/reflex/state.py index 21a54819e52..77046debf89 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -1526,6 +1526,9 @@ def __setattr__(self, name: str, value: Any): if name in self.backend_vars: self._backend_vars.__setitem__(name, value) + if (cache := self.__dict__.get("_mutable_proxy_cache")) is not None: + # Drop the proxy wrapping the replaced value. + cache.pop(name, None) self.dirty_vars.add(name) self._mark_dirty() return @@ -1559,6 +1562,10 @@ def __setattr__(self, name: str, value: Any): # Set the attribute. object.__setattr__(self, name, value) + if (cache := self.__dict__.get("_mutable_proxy_cache")) is not None: + # Drop the proxy wrapping the replaced value. + cache.pop(name, None) + # Add the var to the dirty list. if name in self.base_vars: self.dirty_vars.add(name) diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index e30b892f9c5..f816b64354c 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -730,6 +730,10 @@ def test_mutable_proxy_cached_per_field(): # In-place mutation keeps the same wrapped object, so the proxy is reused. second.append(Item(3)) assert state.items is second + # Reassignment immediately evicts the cache entry, so no strong reference + # to the replaced value lingers until the next read. + state.items = [Item(4)] + assert "items" not in state.__dict__["_mutable_proxy_cache"] def test_mutable_proxy_cache_not_serialized(): diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 3325c4db514..ecf11d40951 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -3258,11 +3258,11 @@ class BaseFieldSetterState(BaseState): bfss.dirty_vars.clear() assert "c1" not in bfss.dirty_vars - # Assert identity of MutableProxy + # Repeated reads reuse the cached MutableProxy for the same field. mp = bfss.c1 assert isinstance(mp, MutableProxy) mp3 = bfss.c1 - assert mp is not mp3 + assert mp is mp3 # Since none of these set calls had values, the state should not be dirty assert not bfss.dirty_vars From b5e404b6cea69534d3110499944b2bbffa4b6a36 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 00:32:25 +0000 Subject: [PATCH 07/16] Declare _mutable_proxy_cache as a reserved state field Address review feedback: instead of managing the proxy cache as an ad-hoc instance __dict__ entry, declare it as a real (non-var) field like _backend_vars/_was_touched and add it to RESERVED_BACKEND_VAR_NAMES, so user vars cannot silently collide with the framework's tracking machinery. The cache is still excluded from pickling and recreated empty on unpickle. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtmC37Y8kJ5S6 --- packages/reflex-base/news/6740.performance.md | 1 + .../src/reflex_base/utils/types.py | 8 +++++- reflex/state.py | 27 ++++++++++--------- tests/units/istate/test_proxy.py | 5 ++-- 4 files changed, 25 insertions(+), 16 deletions(-) create mode 100644 packages/reflex-base/news/6740.performance.md diff --git a/packages/reflex-base/news/6740.performance.md b/packages/reflex-base/news/6740.performance.md new file mode 100644 index 00000000000..b4d796c4c7d --- /dev/null +++ b/packages/reflex-base/news/6740.performance.md @@ -0,0 +1 @@ +Reserve the internal `_mutable_proxy_cache` state field name so user vars cannot collide with the per-instance proxy cache. diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index dcb2dac8af0..f33e5c8c28b 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -162,7 +162,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", + "_mixin", + "_mutable_proxy_cache", +} class Unset: diff --git a/reflex/state.py b/reflex/state.py index 77046debf89..c0f3138d075 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -438,6 +438,12 @@ class BaseState(EvenMoreBasicBaseState): # Whether the state has ever been touched since instantiation. _was_touched: bool = field(default=False, is_var=False) + # Per-field cache of the MutableProxy wrapping each mutable var, so + # repeated reads don't rebuild the proxy. Never pickled. + _mutable_proxy_cache: builtins.dict[str, MutableProxy] = field( + default_factory=builtins.dict, is_var=False + ) + # A special event handler for setting base vars. setvar: ClassVar[EventHandler] @@ -1481,14 +1487,9 @@ def _get_attribute(self, name: str) -> Any: name in super().__getattribute__("base_vars") or name in backend_vars ): # track changes in mutable containers (list, dict, set, etc) - cache = super().__getattribute__("__dict__").get("_mutable_proxy_cache") - if cache is None: - cache = {} - object.__setattr__(self, "_mutable_proxy_cache", cache) + cache = super().__getattribute__("_mutable_proxy_cache") proxy = cache.get(name) - # isinstance also rejects entries degraded by deepcopy, which - # copies a MutableProxy as its unwrapped value. - if not isinstance(proxy, MutableProxy) or proxy.__wrapped__ is not value: + if proxy is None or proxy.__wrapped__ is not value: proxy = MutableProxy(wrapped=value, state=self, field_name=name) cache[name] = proxy return proxy @@ -1526,9 +1527,8 @@ def __setattr__(self, name: str, value: Any): if name in self.backend_vars: self._backend_vars.__setitem__(name, value) - if (cache := self.__dict__.get("_mutable_proxy_cache")) is not None: - # Drop the proxy wrapping the replaced value. - cache.pop(name, None) + # Drop the proxy wrapping the replaced value. + self.__dict__["_mutable_proxy_cache"].pop(name, None) self.dirty_vars.add(name) self._mark_dirty() return @@ -1562,9 +1562,8 @@ def __setattr__(self, name: str, value: Any): # Set the attribute. object.__setattr__(self, name, value) - if (cache := self.__dict__.get("_mutable_proxy_cache")) is not None: - # Drop the proxy wrapping the replaced value. - cache.pop(name, None) + # Drop the proxy wrapping the replaced value. + self.__dict__["_mutable_proxy_cache"].pop(name, None) # Add the var to the dirty list. if name in self.base_vars: @@ -2108,6 +2107,8 @@ def __setstate__(self, state: builtins.dict[str, Any]): """ state["parent_state"] = None state["substates"] = {} + # The proxy cache is never pickled; recreate it on the restored instance. + state.setdefault("_mutable_proxy_cache", {}) for key, value in state.items(): object.__setattr__(self, key, value) diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index f816b64354c..e3eb1e1c5fb 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -740,11 +740,12 @@ def test_mutable_proxy_cache_not_serialized(): """The per-instance proxy cache never leaks into pickles or copies.""" state = ProxyTestState() state.items.append(Item(1)) # populate the proxy cache - assert "_mutable_proxy_cache" in state.__dict__ + assert state.__dict__["_mutable_proxy_cache"] assert "_mutable_proxy_cache" not in state.__getstate__() restored = pickle.loads(pickle.dumps(state)) - assert "_mutable_proxy_cache" not in restored.__dict__ + # The cache is recreated empty on the restored instance. + assert restored.__dict__["_mutable_proxy_cache"] == {} restored_items = restored.items assert isinstance(restored_items, MutableProxy) # The restored proxy tracks the restored state, not the original. From 6d5a4660c77158f7a157ca744ce557a838a8c8fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 19:50:19 +0000 Subject: [PATCH 08/16] Only propagate newly-dirty vars in _mark_dirty_computed_vars Every setattr and proxied mutation triggered a full dirty-propagation pass: an expiry scan over all computed vars plus a dependency re-walk of the entire accumulated dirty_vars set. - Precompute per class which computed vars have an update interval, so the expiry scan is skipped entirely for the common case of none. - Track a per-instance "propagated frontier" so each propagation only processes vars whose dependency closure has not been walked yet. Recomputing a cached var re-materializes its cache, which a later mutation of its dependencies must invalidate again, so recomputes bump a generation counter that resets the frontier. The frontier is transient: cleared by _clean and excluded from pickling. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X --- .../reflex-base/src/reflex_base/vars/base.py | 15 +++++ reflex/state.py | 62 ++++++++++++++++--- tests/units/test_state.py | 57 +++++++++++++++++ 3 files changed, 126 insertions(+), 8 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index 59074c8995b..1853646261f 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -2256,6 +2256,19 @@ def is_computed_var(obj: Any) -> TypeGuard[ComputedVar]: return isinstance(obj, FakeComputedVarBaseClass) +# Incremented whenever a cached computed var is recomputed. State dirty +# propagation uses this to know when an already-propagated dependency needs to +# be re-propagated (a recompute re-materializes a cache that a later mutation +# of its dependencies must invalidate again). +_computed_var_recompute_generation: int = 0 + + +def _bump_computed_var_recompute_generation() -> None: + """Record that a cached computed var was recomputed.""" + global _computed_var_recompute_generation + _computed_var_recompute_generation += 1 + + @dataclasses.dataclass( eq=False, frozen=True, @@ -2599,6 +2612,7 @@ def __get__(self, instance: BaseState | None, owner: type): instance._was_touched = True # Set the last updated timestamp on the state instance. setattr(instance, self._last_updated_attr, datetime.datetime.now()) + _bump_computed_var_recompute_generation() value = getattr(instance, self._cache_attr) # Only validate the return type when the value was just computed. self._check_deprecated_return_type(instance, value) @@ -2864,6 +2878,7 @@ async def _awaitable_result(instance: BaseState = instance) -> RETURN_TYPE: instance._was_touched = True # Set the last updated timestamp on the state instance. setattr(instance, self._last_updated_attr, datetime.datetime.now()) + _bump_computed_var_recompute_generation() value = getattr(instance, self._cache_attr) # Only validate the return type when the value was just computed. self._check_deprecated_return_type(instance, value) diff --git a/reflex/state.py b/reflex/state.py index c0f3138d075..b7c51879c2f 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -56,6 +56,7 @@ from reflex_base.utils.serializers import serializer from reflex_base.utils.types import _isinstance, _validation_depth from reflex_base.vars import Field, VarData, field +from reflex_base.vars import base as reflex_base_vars_base from reflex_base.vars.base import ( ComputedVar, DynamicRouteVar, @@ -369,6 +370,7 @@ def _is_user_descriptor(value: Any) -> bool: "_always_dirty_computed_vars", "_always_dirty_substates", "_potentially_dirty_states", + "_interval_computed_vars", }) @@ -408,6 +410,11 @@ class BaseState(EvenMoreBasicBaseState): # Set of states which might need to be recomputed if vars in this state change. _potentially_dirty_states: ClassVar[set[str]] = set() + # Names of computed vars with an update interval, refreshed whenever + # computed_vars changes. Empty for most classes, which lets dirty + # propagation skip the per-mutation expiry scan. + _interval_computed_vars: ClassVar[tuple[str, ...]] = () + # The parent state. parent_state: BaseState | None = field(default=None, is_var=False) @@ -723,9 +730,22 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs): # Initialize per-class var dependency tracking. cls._var_dependencies = {} cls._init_var_dependency_dicts() + cls._refresh_interval_computed_vars() all_base_state_classes[cls.get_full_name()] = None + @classmethod + def _refresh_interval_computed_vars(cls) -> None: + """Recompute the names of computed vars that have an update interval. + + Must be called whenever cls.computed_vars is modified. + """ + cls._interval_computed_vars = tuple( + name + for name, cvar in cls.computed_vars.items() + if cvar._update_interval is not None + ) + @classmethod def _add_event_handler( cls, @@ -830,6 +850,7 @@ def computed_var_func(state: Self): setattr(cls, unique_var_name, computed_var_func_arg) cls.computed_vars[unique_var_name] = computed_var_func_arg + cls._refresh_interval_computed_vars() 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) @@ -1410,6 +1431,7 @@ def inner_func(self: BaseState) -> list[str]: # Update tracking dicts. cls.computed_vars.update(dynamic_vars) + cls._refresh_interval_computed_vars() cls.vars.update(dynamic_vars) cls._update_substate_inherited_vars(dynamic_vars) @@ -1819,14 +1841,31 @@ async def get_var_value(self, var: Var[VAR_TYPE]) -> VAR_TYPE: 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()) + if self._interval_computed_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) - dirty_vars = self.dirty_vars - while dirty_vars: - calc_vars, dirty_vars = dirty_vars, set() + # Track which dirty vars already had their dependency closure + # propagated, so repeated mutations only process newly-dirty vars. + # Recomputing any cached var re-materializes a cache that a later + # mutation must invalidate again, so the frontier is only valid for + # the recompute generation it was built in. + instance_dict = self.__dict__ + propagated = instance_dict.get("_propagated_dirty_vars") + current_gen = reflex_base_vars_base._computed_var_recompute_generation + if propagated is None: + propagated = set() + object.__setattr__(self, "_propagated_dirty_vars", propagated) + elif instance_dict.get("_propagated_generation") != current_gen: + propagated.clear() + object.__setattr__(self, "_propagated_generation", current_gen) + + new_dirty = self.dirty_vars - propagated + while new_dirty: + propagated |= new_dirty + calc_vars, new_dirty = new_dirty, set() for state_name, cvar in self._dirty_computed_vars(from_vars=calc_vars): if state_name == self.get_full_name(): defining_state = self @@ -1839,7 +1878,8 @@ def _mark_dirty_computed_vars(self) -> None: if actual_var is not None: actual_var.mark_dirty(instance=defining_state) if defining_state is self: - dirty_vars.add(cvar) + if cvar not in propagated: + new_dirty.add(cvar) else: # mark dirty where this var is defined defining_state._mark_dirty() @@ -1850,10 +1890,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( @@ -1973,6 +2014,8 @@ def _clean(self): # Clean this state. self.dirty_vars = set() self.dirty_substates = set() + # Discard the propagation frontier along with the dirty vars it tracked. + self.__dict__.pop("_propagated_dirty_vars", None) def get_value(self, key: str) -> Any: """Get the value of a field (without proxying). @@ -2092,6 +2135,9 @@ def __getstate__(self): state.pop("_was_touched", None) # Proxies wrap live state references and are rebuilt on access. state.pop("_mutable_proxy_cache", None) + # The propagation frontier is transient and rebuilt on demand. + state.pop("_propagated_dirty_vars", None) + state.pop("_propagated_generation", None) # 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 ecf11d40951..3a4b0aba885 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -1452,6 +1452,63 @@ class WrongTypeState(BaseState): assert mock_error.call_count == 1 +def test_computed_var_recompute_after_mid_cycle_read(): + """A dependency mutated again after a mid-cycle read still invalidates the cache.""" + + class FrontierState(BaseState): + v: int = 0 + + @rx.var + def doubled(self) -> int: + return self.v * 2 + + s = FrontierState() + s.v = 1 + # Reading mid-cycle recomputes and re-caches the value. + assert s.doubled == 2 + # Mutating the same dependency again must invalidate the fresh cache, + # even though dirty_vars already contained it. + s.v = 2 + assert s.doubled == 4 + assert s.get_delta()[s.get_full_name()]["doubled" + FIELD_MARKER] == 4 + + +def test_computed_var_recompute_after_mid_cycle_read_across_states(): + """Cross-state dependency invalidation survives a mid-cycle recompute.""" + + class FrontierParentState(BaseState): + v: int = 0 + + class FrontierChildState(FrontierParentState): + @rx.var + def doubled(self) -> int: + return self.v * 2 + + parent = FrontierParentState() + child = parent.substates[FrontierChildState.get_name()] + parent.v = 1 + assert child.doubled == 2 + parent.v = 2 + assert child.doubled == 4 + + +def test_interval_computed_vars_precomputed(): + """Classes precompute which computed vars carry an update interval.""" + + class IntervalFreeState(BaseState): + @rx.var + def untimed(self) -> int: + return 2 + + class IntervalState(BaseState): + @rx.var(interval=15) + def timed(self) -> int: + return 1 + + assert IntervalFreeState._interval_computed_vars == () + assert IntervalState._interval_computed_vars == ("timed",) + + def test_computed_var_cached_depends_on_non_cached(): """Test that a cached var is recalculated if it depends on non-cached ComputedVar.""" From 207644f884f4ab8537d9862203b7fd5bd4eabea0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 20:20:59 +0000 Subject: [PATCH 09/16] Add changelog fragments Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X --- news/6743.performance.md | 1 + packages/reflex-base/news/6743.performance.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 news/6743.performance.md create mode 100644 packages/reflex-base/news/6743.performance.md diff --git a/news/6743.performance.md b/news/6743.performance.md new file mode 100644 index 00000000000..9be8f6e8bbe --- /dev/null +++ b/news/6743.performance.md @@ -0,0 +1 @@ +Dirty propagation now only walks newly-dirty vars per mutation and skips the computed var expiry scan for classes with no interval vars, roughly halving per-mutation overhead in states with computed vars. diff --git a/packages/reflex-base/news/6743.performance.md b/packages/reflex-base/news/6743.performance.md new file mode 100644 index 00000000000..9be8f6e8bbe --- /dev/null +++ b/packages/reflex-base/news/6743.performance.md @@ -0,0 +1 @@ +Dirty propagation now only walks newly-dirty vars per mutation and skips the computed var expiry scan for classes with no interval vars, roughly halving per-mutation overhead in states with computed vars. From ad41b4d0cf615399f9e6cb345d8d2e56a254d72d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 00:33:59 +0000 Subject: [PATCH 10/16] Declare propagation frontier as reserved state fields Match review feedback on the sibling PR: declare _propagated_dirty_vars and _propagated_generation as real (non-var) fields and reserve their names, so user vars cannot silently collide with the framework's tracking machinery. Still transient: excluded from pickling and recreated on unpickle. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X --- .../src/reflex_base/utils/types.py | 2 ++ reflex/state.py | 20 ++++++++++++------- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index f33e5c8c28b..95927a53f23 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -168,6 +168,8 @@ def __call__( "_was_touched", "_mixin", "_mutable_proxy_cache", + "_propagated_dirty_vars", + "_propagated_generation", } diff --git a/reflex/state.py b/reflex/state.py index b7c51879c2f..9cdf55d96bf 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -450,6 +450,12 @@ class BaseState(EvenMoreBasicBaseState): _mutable_proxy_cache: builtins.dict[str, MutableProxy] = field( default_factory=builtins.dict, is_var=False ) + # Dirty vars whose dependency closure was already propagated this cycle. + # Transient: cleared by _clean and never pickled. + _propagated_dirty_vars: set[str] = field(default_factory=set, is_var=False) + + # Recompute generation the propagation frontier was built in. + _propagated_generation: int = field(default=0, is_var=False) # A special event handler for setting base vars. setvar: ClassVar[EventHandler] @@ -1853,14 +1859,11 @@ def _mark_dirty_computed_vars(self) -> None: # mutation must invalidate again, so the frontier is only valid for # the recompute generation it was built in. instance_dict = self.__dict__ - propagated = instance_dict.get("_propagated_dirty_vars") + propagated = instance_dict["_propagated_dirty_vars"] current_gen = reflex_base_vars_base._computed_var_recompute_generation - if propagated is None: - propagated = set() - object.__setattr__(self, "_propagated_dirty_vars", propagated) - elif instance_dict.get("_propagated_generation") != current_gen: + if instance_dict["_propagated_generation"] != current_gen: propagated.clear() - object.__setattr__(self, "_propagated_generation", current_gen) + object.__setattr__(self, "_propagated_generation", current_gen) new_dirty = self.dirty_vars - propagated while new_dirty: @@ -2015,7 +2018,7 @@ def _clean(self): self.dirty_vars = set() self.dirty_substates = set() # Discard the propagation frontier along with the dirty vars it tracked. - self.__dict__.pop("_propagated_dirty_vars", None) + self.__dict__["_propagated_dirty_vars"].clear() def get_value(self, key: str) -> Any: """Get the value of a field (without proxying). @@ -2155,6 +2158,9 @@ def __setstate__(self, state: builtins.dict[str, Any]): state["substates"] = {} # The proxy cache is never pickled; recreate it on the restored instance. state.setdefault("_mutable_proxy_cache", {}) + # The propagation frontier is never pickled; recreate it on restore. + state.setdefault("_propagated_dirty_vars", set()) + state.setdefault("_propagated_generation", 0) for key, value in state.items(): object.__setattr__(self, key, value) From bba6e68a64bf6a65ebb374aed412f560679538a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 00:35:51 +0000 Subject: [PATCH 11/16] Narrow substate type for pyright in frontier test Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X --- tests/units/test_state.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 3a4b0aba885..130062f094b 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -1486,6 +1486,7 @@ def doubled(self) -> int: parent = FrontierParentState() child = parent.substates[FrontierChildState.get_name()] + assert isinstance(child, FrontierChildState) parent.v = 1 assert child.doubled == 2 parent.v = 2 From 56d4cf57d77ab0f3843ccef715398f5eac272a97 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 01:30:33 +0000 Subject: [PATCH 12/16] Write propagation generation via __dict__ for StateProxy compatibility Background task handlers run state methods with self bound to a StateProxy; wrapt's C ObjectProxy (used on Python <= 3.12) rejects object.__setattr__ on the proxy, while __dict__ resolves to the wrapped state's dict on both proxies and plain states. Fixes test_background_task_* failures on 3.10-3.12. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X --- reflex/state.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/reflex/state.py b/reflex/state.py index 9cdf55d96bf..79ff6e9d73d 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -1858,12 +1858,15 @@ def _mark_dirty_computed_vars(self) -> None: # Recomputing any cached var re-materializes a cache that a later # mutation must invalidate again, so the frontier is only valid for # the recompute generation it was built in. + # Go through __dict__ (not setattr) so this also works when self is a + # StateProxy: the proxy exposes the wrapped state's __dict__, while + # object.__setattr__ is rejected by wrapt's C ObjectProxy. instance_dict = self.__dict__ propagated = instance_dict["_propagated_dirty_vars"] current_gen = reflex_base_vars_base._computed_var_recompute_generation if instance_dict["_propagated_generation"] != current_gen: propagated.clear() - object.__setattr__(self, "_propagated_generation", current_gen) + instance_dict["_propagated_generation"] = current_gen new_dirty = self.dirty_vars - propagated while new_dirty: From 8f24b3eb9055f358a4cf54ef815247a0c1ccaae4 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Fri, 17 Jul 2026 18:37:50 -0700 Subject: [PATCH 13/16] Appease pyright on __dict__ generation write --- reflex/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reflex/state.py b/reflex/state.py index 79ff6e9d73d..bc60a06ca16 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -1866,7 +1866,7 @@ def _mark_dirty_computed_vars(self) -> None: current_gen = reflex_base_vars_base._computed_var_recompute_generation if instance_dict["_propagated_generation"] != current_gen: propagated.clear() - instance_dict["_propagated_generation"] = current_gen + instance_dict["_propagated_generation"] = current_gen # pyright: ignore[reportIndexIssue] new_dirty = self.dirty_vars - propagated while new_dirty: From 36746e4eff4fcee80fa986c492b40c67650229a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 01:06:45 +0000 Subject: [PATCH 14/16] Trigger CI on rebased branch Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X From 7d1e9dcd026151e7120578c24437900ea2d6aae6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 01:17:02 +0000 Subject: [PATCH 15/16] Add regression test: StateProxy reassignment evicts cached proxy Covers the async-with background task write path: StateProxy.__setattr__ delegates to setattr on the wrapped state, so BaseState.__setattr__ eviction runs for reassignments made through the proxy. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X --- tests/units/istate/test_proxy.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index e3eb1e1c5fb..df4d6a4a19b 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -736,6 +736,33 @@ def test_mutable_proxy_cached_per_field(): assert "items" not in state.__dict__["_mutable_proxy_cache"] +@pytest.mark.asyncio +async def test_state_proxy_reassignment_evicts_cached_proxy( + attached_mock_event_context: EventContext, +): + """Reassignment through a background-task StateProxy evicts the cached proxy. + + StateProxy.__setattr__ delegates to setattr on the wrapped state, so the + eviction in BaseState.__setattr__ must also cover writes made inside an + `async with self` block. + """ + state = ProxyTestState() + first = state.items + assert isinstance(first, MutableProxy) + assert state.__dict__["_mutable_proxy_cache"]["items"] is first + + state_proxy = StateProxy(state) + # Simulate holding the lock inside `async with self`. + state_proxy._self_mutable = True + state_proxy.items = [Item(9)] + # The write reaches BaseState.__setattr__ on the wrapped state, evicting + # the proxy that wrapped the replaced list. + assert "items" not in state.__dict__["_mutable_proxy_cache"] + second = state.items + assert second is not first + assert second[0].id == 9 + + def test_mutable_proxy_cache_not_serialized(): """The per-instance proxy cache never leaks into pickles or copies.""" state = ProxyTestState() From f9e40102854fabdbbd781e2b0bd9d95a23a74c07 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 01:21:41 +0000 Subject: [PATCH 16/16] Address cubic review: strip env mode whitespace, drop dead immutable guard - _validation_depth now strips REFLEX_ENV_MODE to match the canonical parser's whitespace tolerance, so ' prod ' still selects shallow validation; covered in test_validation_depth_by_env_mode. - Remove the unreachable immutable check in _wrap_mutable: every caller (_wrap_recursive, __getattr__, __getitem__, __iter__) already filters immutables, and proxies only ever wrap mutable values, so the guard only cost an extra is_mutable_type call per wrapped element. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G8cXh3TUbNtbjm2ERqE62X --- packages/reflex-base/src/reflex_base/utils/types.py | 7 ++++--- reflex/istate/proxy.py | 4 ---- tests/units/reflex_base/utils/test_types.py | 6 ++++++ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index 95927a53f23..1c0eb07a6df 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -643,11 +643,11 @@ def does_obj_satisfy_typed_dict( @lru_cache -def _validation_depth_for_mode(raw_mode: str | None) -> int: +def _validation_depth_for_mode(raw_mode: str) -> int: """Get the validation depth for a raw REFLEX_ENV_MODE value. Args: - raw_mode: The raw environment variable value (or None if unset). + raw_mode: The stripped environment variable value ("" if unset). Returns: The `nested` depth to pass to `_isinstance`. @@ -669,7 +669,8 @@ def _validation_depth() -> int: # Read the raw env var directly: interpreting it through # environment.REFLEX_ENV_MODE.get() on this hot path would re-parse the # enum on every state var assignment (and the import would be circular). - return _validation_depth_for_mode(os.environ.get("REFLEX_ENV_MODE")) + # Strip to match the canonical parser's whitespace tolerance. + return _validation_depth_for_mode(os.environ.get("REFLEX_ENV_MODE", "").strip()) def _isinstance( diff --git a/reflex/istate/proxy.py b/reflex/istate/proxy.py index 1c7f009e385..769f697bbff 100644 --- a/reflex/istate/proxy.py +++ b/reflex/istate/proxy.py @@ -668,10 +668,6 @@ def _wrap_mutable(self, value: Any, path: tuple[_AccessSpec, ...]) -> Any: # reference is up to date. if isinstance(value, MutableProxy): value = value.__wrapped__ - # Immutable values (the common case when iterating a container of - # scalars) never need wrapping nor the frame inspection below. - if not is_mutable_type(type(value)): - return value # When called from dataclasses internal code, return the unwrapped value if self._is_called_from_dataclasses_internal(): return value diff --git a/tests/units/reflex_base/utils/test_types.py b/tests/units/reflex_base/utils/test_types.py index 344b01a3ed0..b68eccf6850 100644 --- a/tests/units/reflex_base/utils/test_types.py +++ b/tests/units/reflex_base/utils/test_types.py @@ -1,5 +1,7 @@ """Tests for reflex_base.utils.types.""" +import os + from reflex_base import constants from reflex_base.environment import environment from reflex_base.utils.types import ( @@ -37,5 +39,9 @@ def test_validation_depth_by_env_mode(): assert _validation_depth() == 0 environment.REFLEX_ENV_MODE.set(constants.Env.DEV) assert _validation_depth() == 1 + # The canonical parser tolerates surrounding whitespace; the hot-path + # reader must agree with it. + os.environ["REFLEX_ENV_MODE"] = f" {constants.Env.PROD.value} " + assert _validation_depth() == 0 finally: environment.REFLEX_ENV_MODE.set(initial)