From 8e5dfd700e0a19702e4789bc3b4bbdf29524de57 Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:27:39 +0530 Subject: [PATCH 01/33] Fix stale substates after background task state expiry --- ...refresh-expired-background-state.bugfix.md | 1 + reflex/istate/manager/__init__.py | 12 ++++++ reflex/istate/manager/disk.py | 5 +++ reflex/istate/manager/memory.py | 2 + .../test_memory_state_manager_expiration.py | 40 +++++++++++++++++++ 5 files changed, 60 insertions(+) create mode 100644 news/+7478-refresh-expired-background-state.bugfix.md diff --git a/news/+7478-refresh-expired-background-state.bugfix.md b/news/+7478-refresh-expired-background-state.bugfix.md new file mode 100644 index 00000000000..30f0d3a1d08 --- /dev/null +++ b/news/+7478-refresh-expired-background-state.bugfix.md @@ -0,0 +1 @@ +Mark recreated state trees dirty so background-task completion deltas refresh expired substate values in the client. diff --git a/reflex/istate/manager/__init__.py b/reflex/istate/manager/__init__.py index 156c5af687e..3d8f7f1cecf 100644 --- a/reflex/istate/manager/__init__.py +++ b/reflex/istate/manager/__init__.py @@ -333,6 +333,18 @@ async def __aexit__( return await self._state_context.__aexit__(exc_type, exc, tb) or suppressed +def _mark_state_tree_dirty(state: "BaseState") -> None: + """Mark a newly created state tree for a complete client update. + + Args: + state: The root of the state tree to mark. + """ + state.dirty_vars.update(state._frontend_var_names) + state._mark_dirty() + for substate in state.substates.values(): + _mark_state_tree_dirty(substate) + + def _release_state_tree(state: "BaseState"): """Clear the substate links of a purged state tree. diff --git a/reflex/istate/manager/disk.py b/reflex/istate/manager/disk.py index 920e19867fb..d8d812ea18a 100644 --- a/reflex/istate/manager/disk.py +++ b/reflex/istate/manager/disk.py @@ -18,6 +18,7 @@ StateManager, StateModificationContext, _default_token_expiration, + _mark_state_tree_dirty, _release_state_tree, ) from reflex.istate.manager.token import TOKEN_TYPE, BaseStateToken, StateToken @@ -177,6 +178,7 @@ async def populate_substates( fresh_instance = await root_state.get_state(substate) instance = await self.load_state(substate_token) + is_new_state = instance is None if instance is not None: # Ensure all substates exist, even if they weren't serialized previously. instance.substates = fresh_instance.substates @@ -184,6 +186,8 @@ async def populate_substates( instance = fresh_instance state.substates[substate.get_name()] = instance instance.parent_state = state + if is_new_state: + _mark_state_tree_dirty(instance) await self.populate_substates(token, instance, root_state) @@ -216,6 +220,7 @@ async def get_state( fresh_root_state = root_state_cls(_reflex_internal_init=True) if root_state is None: root_state = fresh_root_state + _mark_state_tree_dirty(root_state) elif not isinstance(root_state, BaseState): msg = "Deserialized state is not an instance of BaseState, cannot populate substates." raise TypeError(msg) diff --git a/reflex/istate/manager/memory.py b/reflex/istate/manager/memory.py index bbbb1608026..cb2fe63eddf 100644 --- a/reflex/istate/manager/memory.py +++ b/reflex/istate/manager/memory.py @@ -12,6 +12,7 @@ StateManager, StateModificationContext, _default_token_expiration, + _mark_state_tree_dirty, _release_state_tree, ) from reflex.istate.manager.token import TOKEN_TYPE, BaseStateToken, StateToken @@ -59,6 +60,7 @@ def _get_or_create_state(self, token: StateToken[TOKEN_TYPE]) -> TOKEN_TYPE: self.states[key] = token.cls.get_root_state()( _reflex_internal_init=True ) + _mark_state_tree_dirty(self.states[key]) else: self.states[key] = token.cls() return cast(TOKEN_TYPE, self.states[key]) diff --git a/tests/integration/test_memory_state_manager_expiration.py b/tests/integration/test_memory_state_manager_expiration.py index f4d3e88d7dc..af221c90cac 100644 --- a/tests/integration/test_memory_state_manager_expiration.py +++ b/tests/integration/test_memory_state_manager_expiration.py @@ -13,6 +13,8 @@ def MemoryExpirationApp(): """Reflex app that exposes state expiration through a simple counter UI.""" + import asyncio + import reflex as rx class State(rx.State): @@ -22,6 +24,20 @@ class State(rx.State): def increment(self): self.counter += 1 + @rx.event(background=True) + async def delayed_update(self): + await asyncio.sleep(2) + async with self: + self.counter = 100 + + class ChildState(State): + child_counter: int = 0 + + @rx.event + def set_values(self): + self.counter = 11 + self.child_counter = 13 + app = rx.App() @app.add_page @@ -33,7 +49,14 @@ def index(): is_read_only=True, ), rx.text(State.counter, id="counter"), + rx.text(ChildState.child_counter, id="child-counter"), rx.button("Increment", id="increment", on_click=State.increment), + rx.button("Set values", id="set-values", on_click=ChildState.set_values), + rx.button( + "Delayed update", + id="delayed-update", + on_click=State.delayed_update, + ), ) @@ -157,3 +180,20 @@ def test_memory_state_manager_delays_expiration_after_use_end_to_end( increment.click() AppHarness.expect(lambda: counter.text == "1") assert token_input.get_attribute("value") == token + + + +def test_background_task_refreshes_recreated_substate( + memory_expiration_app: AppHarness, + driver: WebDriver, +): + """A delayed background update must send defaults for expired substates.""" + counter = driver.find_element(By.ID, "counter") + child_counter = driver.find_element(By.ID, "child-counter") + driver.find_element(By.ID, "set-values").click() + AppHarness.expect(lambda: counter.text == "11") + AppHarness.expect(lambda: child_counter.text == "13") + + driver.find_element(By.ID, "delayed-update").click() + AppHarness.expect(lambda: counter.text == "100", timeout=10) + AppHarness.expect(lambda: child_counter.text == "0", timeout=10) From ebc95c159c2bf605ad46cb29b59a1c417da9a4fd Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:40:31 +0530 Subject: [PATCH 02/33] Wait for the expiration test page to render --- tests/integration/test_memory_state_manager_expiration.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_memory_state_manager_expiration.py b/tests/integration/test_memory_state_manager_expiration.py index af221c90cac..2888b510d0a 100644 --- a/tests/integration/test_memory_state_manager_expiration.py +++ b/tests/integration/test_memory_state_manager_expiration.py @@ -188,8 +188,12 @@ def test_background_task_refreshes_recreated_substate( driver: WebDriver, ): """A delayed background update must send defaults for expired substates.""" - counter = driver.find_element(By.ID, "counter") - child_counter = driver.find_element(By.ID, "child-counter") + counter = AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "counter") + ) + child_counter = AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "child-counter") + ) driver.find_element(By.ID, "set-values").click() AppHarness.expect(lambda: counter.text == "11") AppHarness.expect(lambda: child_counter.text == "13") From 2e3dbc1e0cd4d2908a945741abb7091972773b5a Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:40:52 +0530 Subject: [PATCH 03/33] Format the background expiry regression test --- tests/integration/test_memory_state_manager_expiration.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/test_memory_state_manager_expiration.py b/tests/integration/test_memory_state_manager_expiration.py index 2888b510d0a..baa85d454c2 100644 --- a/tests/integration/test_memory_state_manager_expiration.py +++ b/tests/integration/test_memory_state_manager_expiration.py @@ -182,7 +182,6 @@ def test_memory_state_manager_delays_expiration_after_use_end_to_end( assert token_input.get_attribute("value") == token - def test_background_task_refreshes_recreated_substate( memory_expiration_app: AppHarness, driver: WebDriver, From 5038a6764a59d0576bb2e5d9e4d865fcd1289a12 Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:07:11 +0530 Subject: [PATCH 04/33] Avoid state recreation overhead on normal requests --- reflex/istate/manager/disk.py | 5 ----- reflex/istate/manager/memory.py | 2 -- reflex/istate/proxy.py | 9 ++++++--- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/reflex/istate/manager/disk.py b/reflex/istate/manager/disk.py index d8d812ea18a..920e19867fb 100644 --- a/reflex/istate/manager/disk.py +++ b/reflex/istate/manager/disk.py @@ -18,7 +18,6 @@ StateManager, StateModificationContext, _default_token_expiration, - _mark_state_tree_dirty, _release_state_tree, ) from reflex.istate.manager.token import TOKEN_TYPE, BaseStateToken, StateToken @@ -178,7 +177,6 @@ async def populate_substates( fresh_instance = await root_state.get_state(substate) instance = await self.load_state(substate_token) - is_new_state = instance is None if instance is not None: # Ensure all substates exist, even if they weren't serialized previously. instance.substates = fresh_instance.substates @@ -186,8 +184,6 @@ async def populate_substates( instance = fresh_instance state.substates[substate.get_name()] = instance instance.parent_state = state - if is_new_state: - _mark_state_tree_dirty(instance) await self.populate_substates(token, instance, root_state) @@ -220,7 +216,6 @@ async def get_state( fresh_root_state = root_state_cls(_reflex_internal_init=True) if root_state is None: root_state = fresh_root_state - _mark_state_tree_dirty(root_state) elif not isinstance(root_state, BaseState): msg = "Deserialized state is not an instance of BaseState, cannot populate substates." raise TypeError(msg) diff --git a/reflex/istate/manager/memory.py b/reflex/istate/manager/memory.py index cb2fe63eddf..bbbb1608026 100644 --- a/reflex/istate/manager/memory.py +++ b/reflex/istate/manager/memory.py @@ -12,7 +12,6 @@ StateManager, StateModificationContext, _default_token_expiration, - _mark_state_tree_dirty, _release_state_tree, ) from reflex.istate.manager.token import TOKEN_TYPE, BaseStateToken, StateToken @@ -60,7 +59,6 @@ def _get_or_create_state(self, token: StateToken[TOKEN_TYPE]) -> TOKEN_TYPE: self.states[key] = token.cls.get_root_state()( _reflex_internal_init=True ) - _mark_state_tree_dirty(self.states[key]) else: self.states[key] = token.cls() return cast(TOKEN_TYPE, self.states[key]) diff --git a/reflex/istate/proxy.py b/reflex/istate/proxy.py index f15d4d1d3cc..2b5f4dcc85b 100644 --- a/reflex/istate/proxy.py +++ b/reflex/istate/proxy.py @@ -267,11 +267,14 @@ async def __aenter__(self) -> Self: token=self._self_substate_token, event=self._self_event ) mutable_state = await self._self_actx.__aenter__() + refreshed_state = mutable_state.get_substate(self._self_substate_path) + if refreshed_state is not self.__wrapped__: + from reflex.istate.manager import _mark_state_tree_dirty + + _mark_state_tree_dirty(mutable_state) self._self_mutable = True self._self_entered_context = True - super().__setattr__( - "__wrapped__", mutable_state.get_substate(self._self_substate_path) - ) + super().__setattr__("__wrapped__", refreshed_state) except (Exception, asyncio.CancelledError): # Restore the proxy to a consistent state since __aexit__ will not be called when __aenter__ raises. await self.__aexit__(*sys.exc_info()) From b56ecafdd89da17f9235475a53bf9d8b625073fc Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Mon, 12 Oct 2026 13:57:08 +0530 Subject: [PATCH 05/33] Fix expired state refresh tracking --- reflex/istate/manager/__init__.py | 48 +++++++++++++++---- reflex/istate/manager/disk.py | 4 ++ reflex/istate/manager/memory.py | 7 +-- reflex/istate/proxy.py | 13 +++-- .../test_memory_state_manager_expiration.py | 40 ++++++++++++++-- tests/units/istate/manager/test_expiration.py | 29 +++++++++++ tests/units/istate/manager/test_redis.py | 16 +++++++ tests/units/istate/test_proxy.py | 30 ++++++++++++ 8 files changed, 168 insertions(+), 19 deletions(-) diff --git a/reflex/istate/manager/__init__.py b/reflex/istate/manager/__init__.py index 3d8f7f1cecf..486c191abc4 100644 --- a/reflex/istate/manager/__init__.py +++ b/reflex/istate/manager/__init__.py @@ -5,7 +5,8 @@ import logging import sys from abc import ABC, abstractmethod -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Iterator +from contextvars import ContextVar from types import TracebackType from typing import TYPE_CHECKING, Generic, TypedDict, overload @@ -19,6 +20,9 @@ from reflex.utils import console, prerequisites logger = logging.getLogger(__name__) +_STATE_RECREATION_TRACKER: ContextVar[list["BaseState"] | None] = ContextVar( + "state_recreation_tracker", default=None +) if TYPE_CHECKING: from reflex.state import BaseState @@ -333,16 +337,44 @@ async def __aexit__( return await self._state_context.__aexit__(exc_type, exc, tb) or suppressed -def _mark_state_tree_dirty(state: "BaseState") -> None: - """Mark a newly created state tree for a complete client update. +@contextlib.contextmanager +def _track_state_recreation() -> Iterator[list["BaseState"]]: + """Track state instances recreated inside the current async context. + + Yields: + The state instances reported as recreated before the context exits. + """ + recreated_states: list[BaseState] = [] + tracker_token = _STATE_RECREATION_TRACKER.set(recreated_states) + try: + yield recreated_states + finally: + _STATE_RECREATION_TRACKER.reset(tracker_token) + + +def _report_state_recreation(state: "BaseState") -> None: + """Report a state recreated during a tracked modification context. Args: - state: The root of the state tree to mark. + state: The newly recreated state. """ - state.dirty_vars.update(state._frontend_var_names) - state._mark_dirty() - for substate in state.substates.values(): - _mark_state_tree_dirty(substate) + if (recreated_states := _STATE_RECREATION_TRACKER.get()) is not None: + recreated_states.append(state) + + +def _report_state_tree_recreation(state: "BaseState") -> None: + """Report every state in a newly recreated tree. + + Args: + state: The root of the recreated state tree. + """ + if (recreated_states := _STATE_RECREATION_TRACKER.get()) is None: + return + pending = [state] + while pending: + current = pending.pop() + recreated_states.append(current) + pending.extend(current.substates.values()) def _release_state_tree(state: "BaseState"): diff --git a/reflex/istate/manager/disk.py b/reflex/istate/manager/disk.py index 920e19867fb..197ed64287a 100644 --- a/reflex/istate/manager/disk.py +++ b/reflex/istate/manager/disk.py @@ -19,6 +19,7 @@ StateModificationContext, _default_token_expiration, _release_state_tree, + _report_state_recreation, ) from reflex.istate.manager.token import TOKEN_TYPE, BaseStateToken, StateToken from reflex.state import BaseState @@ -186,6 +187,8 @@ async def populate_substates( instance.parent_state = state await self.populate_substates(token, instance, root_state) + if instance is fresh_instance: + _report_state_recreation(instance) @override async def get_state( @@ -216,6 +219,7 @@ async def get_state( fresh_root_state = root_state_cls(_reflex_internal_init=True) if root_state is None: root_state = fresh_root_state + _report_state_recreation(root_state) elif not isinstance(root_state, BaseState): msg = "Deserialized state is not an instance of BaseState, cannot populate substates." raise TypeError(msg) diff --git a/reflex/istate/manager/memory.py b/reflex/istate/manager/memory.py index bbbb1608026..99c7921b1a3 100644 --- a/reflex/istate/manager/memory.py +++ b/reflex/istate/manager/memory.py @@ -13,6 +13,7 @@ StateModificationContext, _default_token_expiration, _release_state_tree, + _report_state_tree_recreation, ) from reflex.istate.manager.token import TOKEN_TYPE, BaseStateToken, StateToken @@ -56,9 +57,9 @@ def _get_or_create_state(self, token: StateToken[TOKEN_TYPE]) -> TOKEN_TYPE: key = token.cache_key if key not in self.states: if isinstance(token, BaseStateToken): - self.states[key] = token.cls.get_root_state()( - _reflex_internal_init=True - ) + state = token.cls.get_root_state()(_reflex_internal_init=True) + _report_state_tree_recreation(state) + self.states[key] = state else: self.states[key] = token.cls() return cast(TOKEN_TYPE, self.states[key]) diff --git a/reflex/istate/proxy.py b/reflex/istate/proxy.py index 2b5f4dcc85b..368fcf53f5b 100644 --- a/reflex/istate/proxy.py +++ b/reflex/istate/proxy.py @@ -266,12 +266,15 @@ async def __aenter__(self) -> Self: self._self_actx = ctx.state_manager.modify_state_with_links( token=self._self_substate_token, event=self._self_event ) - mutable_state = await self._self_actx.__aenter__() + from reflex.istate.manager import _track_state_recreation + + with _track_state_recreation() as recreated_states: + mutable_state = await self._self_actx.__aenter__() + for state in recreated_states: + state._was_touched = True + state.dirty_vars.update(state._frontend_var_names) + state._mark_dirty() refreshed_state = mutable_state.get_substate(self._self_substate_path) - if refreshed_state is not self.__wrapped__: - from reflex.istate.manager import _mark_state_tree_dirty - - _mark_state_tree_dirty(mutable_state) self._self_mutable = True self._self_entered_context = True super().__setattr__("__wrapped__", refreshed_state) diff --git a/tests/integration/test_memory_state_manager_expiration.py b/tests/integration/test_memory_state_manager_expiration.py index baa85d454c2..0b55da9008e 100644 --- a/tests/integration/test_memory_state_manager_expiration.py +++ b/tests/integration/test_memory_state_manager_expiration.py @@ -38,9 +38,22 @@ def set_values(self): self.counter = 11 self.child_counter = 13 + class CounterComponent(rx.ComponentState): + value: int = 0 + + @rx.event + def set_value(self): + self.value = 17 + + @classmethod + def get_component(cls): + return rx.vstack( + rx.text(cls.value, id="component-counter"), + rx.button("Set component", id="set-component", on_click=cls.set_value), + ) + app = rx.App() - @app.add_page def index(): return rx.vstack( rx.input( @@ -59,6 +72,18 @@ def index(): ), ) + app.add_page(index) + + def background_page(): + """Render the background expiry case with a component substate. + + Returns: + The page with state and component counters. + """ + return rx.vstack(index(), CounterComponent.create()) + + app.add_page(background_page, route="/background") + @pytest.fixture def memory_expiration_app( @@ -182,21 +207,30 @@ def test_memory_state_manager_delays_expiration_after_use_end_to_end( assert token_input.get_attribute("value") == token -def test_background_task_refreshes_recreated_substate( +def test_background_task_refreshes_recreated_state_tree( memory_expiration_app: AppHarness, driver: WebDriver, ): - """A delayed background update must send defaults for expired substates.""" + """A delayed background update must reset expired child and component state.""" + frontend_url = memory_expiration_app.frontend_url + assert frontend_url is not None + driver.get(frontend_url.removesuffix("/") + "/background") counter = AppHarness.poll_for_or_raise_timeout( lambda: driver.find_element(By.ID, "counter") ) child_counter = AppHarness.poll_for_or_raise_timeout( lambda: driver.find_element(By.ID, "child-counter") ) + component_counter = AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "component-counter") + ) driver.find_element(By.ID, "set-values").click() AppHarness.expect(lambda: counter.text == "11") AppHarness.expect(lambda: child_counter.text == "13") + driver.find_element(By.ID, "set-component").click() + AppHarness.expect(lambda: component_counter.text == "17") driver.find_element(By.ID, "delayed-update").click() AppHarness.expect(lambda: counter.text == "100", timeout=10) AppHarness.expect(lambda: child_counter.text == "0", timeout=10) + AppHarness.expect(lambda: component_counter.text == "0", timeout=10) diff --git a/tests/units/istate/manager/test_expiration.py b/tests/units/istate/manager/test_expiration.py index 93907e03569..af682389da9 100644 --- a/tests/units/istate/manager/test_expiration.py +++ b/tests/units/istate/manager/test_expiration.py @@ -9,6 +9,7 @@ import pytest import pytest_asyncio +from reflex.istate.manager import _track_state_recreation from reflex.istate.manager.disk import StateManagerDisk from reflex.istate.manager.memory import StateManagerMemory from reflex.istate.manager.token import BaseStateToken @@ -183,6 +184,34 @@ async def test_disk_state_manager_set_state_refreshes_expiration( assert state_manager_disk._token_last_touched[token][0] > touched_at +@pytest.mark.asyncio +async def test_disk_state_manager_reports_recreated_states( + state_manager_disk: StateManagerDisk, + token: str, +): + """Missing persisted states should be reported for a background refresh.""" + state_token = BaseStateToken(ident=token, cls=ExpiringChildState) + + with _track_state_recreation() as recreated_states: + root = await state_manager_disk.get_state(state_token) + child = await root.get_state(ExpiringChildState) + + assert recreated_states == [root, child] + assert not root.dirty_vars + assert not child.dirty_vars + + root.value = 7 + await state_manager_disk.set_state(state_token, root) + state_manager_disk._purge_token(state_token) + + with _track_state_recreation() as recreated_states: + reloaded = await state_manager_disk.get_state(state_token) + reloaded_child = await reloaded.get_state(ExpiringChildState) + + assert getattr(reloaded, "value", None) == 7 + assert recreated_states == [reloaded_child] + + @pytest.mark.asyncio async def test_disk_state_manager_writes_expired_pending_state_tree( state_manager_disk: StateManagerDisk, diff --git a/tests/units/istate/manager/test_redis.py b/tests/units/istate/manager/test_redis.py index 5ed218e1ef4..0962f808009 100644 --- a/tests/units/istate/manager/test_redis.py +++ b/tests/units/istate/manager/test_redis.py @@ -94,6 +94,22 @@ async def test_get_state_reads_tree_in_one_command(): await manager.close() +@pytest.mark.asyncio +async def test_get_state_does_not_dirty_persisted_states_on_reload(): + """Only recreated Redis states need defaults sent to the frontend.""" + redis = mock_redis() + manager = StateManagerRedis(redis=redis) + token = BaseStateToken(ident="persisted-read", cls=RedisTestState) + + state = await manager.get_state(token) + await manager.set_state(token, state) + reloaded_state = await manager.get_state(token) + + assert not reloaded_state.dirty_vars + assert all(not child.dirty_vars for child in reloaded_state.substates.values()) + await manager.close() + + @pytest.mark.asyncio async def test_get_state_reuses_populated_tree_without_reading(): """Fetching an already attached state must not contact Redis or replace it.""" diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index 2138b4d0e8a..e09f630a277 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -609,6 +609,36 @@ async def test_immutable_mutable_proxy_async_context_manager( assert state.data["b"] == [2, 4] +@pytest.mark.asyncio +async def test_state_proxy_does_not_emit_delta_for_reloaded_unchanged_state( + token: str, + attached_mock_event_context: EventContext, + emitted_deltas: list[tuple[str, Mapping[str, Mapping[str, Any]]]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A freshly deserialized but unchanged state must not send a full delta.""" + state_manager = attached_mock_event_context.state_manager + state_token = BaseStateToken(ident=token, cls=ProxyTestState) + + async with state_manager.modify_state(state_token) as state: + state._clean() + state_proxy = StateProxy(state) + + original_modify_state = state_manager.modify_state + + @asynccontextmanager + async def reload_state(token: BaseStateToken, **context: Any): + async with original_modify_state(token, **context) as state: + yield token.deserialize(data=token.serialize(state)) + + monkeypatch.setattr(state_manager, "modify_state", reload_state) + + async with state_proxy: + pass + + assert not emitted_deltas + + @pytest.mark.asyncio async def test_immutable_mutable_proxy_async_context_dict_method_paths( token: str, attached_mock_event_context: EventContext From eed95348054f88124734daa82b28e85c8567c32c Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Mon, 12 Oct 2026 14:37:53 +0530 Subject: [PATCH 06/33] Track recreated Redis state and avoid task retention --- reflex/istate/manager/__init__.py | 10 +++++ reflex/istate/manager/memory.py | 10 +++-- reflex/istate/manager/redis.py | 2 + tests/units/istate/manager/test_expiration.py | 26 +++++++++++- tests/units/istate/manager/test_redis.py | 40 ++++++++++++++++++- tests/units/istate/test_proxy.py | 4 ++ 6 files changed, 86 insertions(+), 6 deletions(-) diff --git a/reflex/istate/manager/__init__.py b/reflex/istate/manager/__init__.py index 486c191abc4..3ebe6efee1c 100644 --- a/reflex/istate/manager/__init__.py +++ b/reflex/istate/manager/__init__.py @@ -352,6 +352,16 @@ def _track_state_recreation() -> Iterator[list["BaseState"]]: _STATE_RECREATION_TRACKER.reset(tracker_token) +@contextlib.contextmanager +def _without_state_recreation_tracking() -> Iterator[None]: + """Temporarily prevent child tasks from inheriting state tracking.""" + tracker_token = _STATE_RECREATION_TRACKER.set(None) + try: + yield + finally: + _STATE_RECREATION_TRACKER.reset(tracker_token) + + def _report_state_recreation(state: "BaseState") -> None: """Report a state recreated during a tracked modification context. diff --git a/reflex/istate/manager/memory.py b/reflex/istate/manager/memory.py index 99c7921b1a3..5463884249a 100644 --- a/reflex/istate/manager/memory.py +++ b/reflex/istate/manager/memory.py @@ -14,6 +14,7 @@ _default_token_expiration, _release_state_tree, _report_state_tree_recreation, + _without_state_recreation_tracking, ) from reflex.istate.manager.token import TOKEN_TYPE, BaseStateToken, StateToken @@ -140,10 +141,11 @@ def _ensure_expiration_task(self): """Ensure the expiration background task is running.""" if self._expiration_task is None or self._expiration_task.done(): asyncio.get_running_loop() # Ensure we're in an event loop. - self._expiration_task = asyncio.create_task( - self._expire_states(), - name="StateManagerMemory|Expiration", - ) + with _without_state_recreation_tracking(): + self._expiration_task = asyncio.create_task( + self._expire_states(), + name="StateManagerMemory|Expiration", + ) @override async def get_state(self, token: StateToken[TOKEN_TYPE]) -> TOKEN_TYPE: diff --git a/reflex/istate/manager/redis.py b/reflex/istate/manager/redis.py index 2d7d25ad244..266ffc006c2 100644 --- a/reflex/istate/manager/redis.py +++ b/reflex/istate/manager/redis.py @@ -28,6 +28,7 @@ StateManager, StateModificationContext, _default_token_expiration, + _report_state_recreation, ) from reflex.istate.manager.token import TOKEN_TYPE, BaseStateToken, StateToken from reflex.state import BaseState @@ -384,6 +385,7 @@ async def get_state( init_substates=False, _reflex_internal_init=True, ) + _report_state_recreation(state) state_full_name = state.get_full_name() flat_state_tree[state_full_name] = state if state.get_parent_state() is not None: diff --git a/tests/units/istate/manager/test_expiration.py b/tests/units/istate/manager/test_expiration.py index af682389da9..566f851a1bf 100644 --- a/tests/units/istate/manager/test_expiration.py +++ b/tests/units/istate/manager/test_expiration.py @@ -9,7 +9,7 @@ import pytest import pytest_asyncio -from reflex.istate.manager import _track_state_recreation +from reflex.istate.manager import _STATE_RECREATION_TRACKER, _track_state_recreation from reflex.istate.manager.disk import StateManagerDisk from reflex.istate.manager.memory import StateManagerMemory from reflex.istate.manager.token import BaseStateToken @@ -62,6 +62,30 @@ async def state_manager_memory() -> AsyncGenerator[StateManagerMemory]: await state_manager.close() +@pytest.mark.asyncio +async def test_expiration_task_does_not_inherit_recreation_tracker( + monkeypatch: pytest.MonkeyPatch, +): + """The long-lived expiry task must not retain tracked state instances.""" + state_manager = StateManagerMemory(token_expiration=60) + tracker_values: list[object] = [] + + async def capture_tracker() -> None: + await asyncio.sleep(0) + tracker_values.append(_STATE_RECREATION_TRACKER.get()) + + monkeypatch.setattr(state_manager, "_expire_states", capture_tracker) + with _track_state_recreation(): + state_manager._ensure_expiration_task() + + expiration_task = state_manager._expiration_task + assert expiration_task is not None + await expiration_task + await state_manager.close() + + assert tracker_values == [None] + + @pytest.mark.asyncio async def test_memory_state_manager_evicts_expired_state( state_manager_memory: StateManagerMemory, diff --git a/tests/units/istate/manager/test_redis.py b/tests/units/istate/manager/test_redis.py index 0962f808009..127dc3c4c5b 100644 --- a/tests/units/istate/manager/test_redis.py +++ b/tests/units/istate/manager/test_redis.py @@ -16,6 +16,7 @@ import pytest_asyncio from reflex_base.utils.exceptions import EnvironmentVarValueError +from reflex.istate.manager import _track_state_recreation from reflex.istate.manager.redis import ( StateManagerRedis, _default_lock_expiration, @@ -102,14 +103,51 @@ async def test_get_state_does_not_dirty_persisted_states_on_reload(): token = BaseStateToken(ident="persisted-read", cls=RedisTestState) state = await manager.get_state(token) + state.foo = "persisted" + for substate in manager._get_populated_states(state).values(): + substate._was_touched = True await manager.set_state(token, state) - reloaded_state = await manager.get_state(token) + with _track_state_recreation() as recreated_states: + reloaded_state = await manager.get_state(token) + assert isinstance(reloaded_state, RedisTestState) + assert reloaded_state.foo == "persisted" + assert not recreated_states assert not reloaded_state.dirty_vars assert all(not child.dirty_vars for child in reloaded_state.substates.values()) await manager.close() +@pytest.mark.asyncio +async def test_get_state_reports_missing_redis_substates(): + """Missing Redis state keys are reported without reporting persisted states.""" + redis = mock_redis() + manager = StateManagerRedis(redis=redis) + token = BaseStateToken(ident="missing-substate", cls=RedisTestState) + state_classes = sorted( + manager._get_required_state_classes(RedisTestState, subclasses=True), + key=lambda cls: cls.get_full_name(), + ) + persisted_state = RedisTestState(foo="persisted") + redis.mget = AsyncMock( + return_value=[ + persisted_state._serialize() if cls is RedisTestState else None + for cls in state_classes + ] + ) + + with _track_state_recreation() as recreated_states: + state = await manager.get_state(token) + + assert isinstance(state, RedisTestState) + assert state.foo == "persisted" + assert {type(recreated).__name__ for recreated in recreated_states} == { + "SubState1", + "SubState2", + } + await manager.close() + + @pytest.mark.asyncio async def test_get_state_reuses_populated_tree_without_reading(): """Fetching an already attached state must not contact Redis or replace it.""" diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index e09f630a277..1289ac02356 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -21,6 +21,7 @@ import reflex as rx from reflex.istate.data import HeaderData, PageData, RouterData from reflex.istate.manager import StateManager +from reflex.istate.manager.redis import StateManagerRedis from reflex.istate.manager.token import BaseStateToken from reflex.istate.proxy import ( ImmutableMutableProxy, @@ -377,6 +378,9 @@ async def test_inherited_mutable_var_marks_its_owner( """ state_token = BaseStateToken(ident=token, cls=InheritedListSubState) async with state_manager.modify_state(state_token) as root: + if isinstance(state_manager, StateManagerRedis): + for state in state_manager._get_populated_states(root).values(): + state._was_touched = True proxy = StateProxy( root.get_substate(InheritedListSubState.get_full_name().split(".")) ) From ad2ca874709d9139b1710b689aebe1a03b3b3f03 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Mon, 12 Oct 2026 14:43:17 +0530 Subject: [PATCH 07/33] Clear recreation tracking for Redis tasks --- reflex/istate/manager/redis.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/reflex/istate/manager/redis.py b/reflex/istate/manager/redis.py index 266ffc006c2..566a61510bf 100644 --- a/reflex/istate/manager/redis.py +++ b/reflex/istate/manager/redis.py @@ -29,6 +29,7 @@ StateModificationContext, _default_token_expiration, _report_state_recreation, + _without_state_recreation_tracking, ) from reflex.istate.manager.token import TOKEN_TYPE, BaseStateToken, StateToken from reflex.state import BaseState @@ -800,10 +801,11 @@ async def lease_breaker(): lock_key not in self._local_leases and await self._n_lock_contenders(self._lock_key(token)) == 0 ): - self._local_leases[lock_key] = task = asyncio.create_task( - lease_breaker(), - name=f"reflex_lease_breaker|{lock_key}|{lock_id.decode()}", - ) + with _without_state_recreation_tracking(): + self._local_leases[lock_key] = task = asyncio.create_task( + lease_breaker(), + name=f"reflex_lease_breaker|{lock_key}|{lock_id.decode()}", + ) # Fetch the requested state into the cache. self._cached_states[lock_key] = await self.get_state(token) return task @@ -910,12 +912,13 @@ async def _subscribe_lock_updates(self): def _ensure_lock_task(self) -> None: """Ensure the lock updates subscriber task is running.""" - ensure_task( - owner=self, - task_attribute="_lock_task", - coro_function=self._subscribe_lock_updates, - suppress_exceptions=[Exception], - ) + with _without_state_recreation_tracking(): + ensure_task( + owner=self, + task_attribute="_lock_task", + coro_function=self._subscribe_lock_updates, + suppress_exceptions=[Exception], + ) async def _ensure_lock_task_subscribed(self, timeout: float | None = None) -> None: """Ensure the lock updates subscriber task is running and subscribed to avoid missing notifications. From 90652d439285ffa43e19b7c586e18c02bda32b2a Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Mon, 12 Oct 2026 15:30:50 +0530 Subject: [PATCH 08/33] Avoid evaluating unavailable computed vars on state recreation --- reflex/istate/proxy.py | 4 +-- tests/units/istate/test_proxy.py | 43 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/reflex/istate/proxy.py b/reflex/istate/proxy.py index 368fcf53f5b..eae218b9909 100644 --- a/reflex/istate/proxy.py +++ b/reflex/istate/proxy.py @@ -272,8 +272,8 @@ async def __aenter__(self) -> Self: mutable_state = await self._self_actx.__aenter__() for state in recreated_states: state._was_touched = True - state.dirty_vars.update(state._frontend_var_names) - state._mark_dirty() + state.dirty_vars.update(state.base_vars) + state._mark_dirty(state.base_vars) refreshed_state = mutable_state.get_substate(self._self_substate_path) self._self_mutable = True self._self_entered_context = True diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index 1289ac02356..9f8795803ef 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -139,6 +139,30 @@ class ProxyTestState(rx.State): items: list[Item] = [] +class RuntimeComputedProxyState(BaseState): + """State with a runtime-only computed value and a field-backed one.""" + + value: int = 1 + + @rx.var + def doubled(self) -> int: + """A computed value that can be invalidated by the state field. + + Returns: + The doubled state value. + """ + return self.value * 2 + + @rx.var + def runtime_value(self) -> str: + """A route-provided value that is not available in background state. + + Returns: + The route-provided value. + """ + return self.dynamic # pyright: ignore[reportAttributeAccessIssue] + + def test_mutable_proxy_pickle_preserves_object_identity(): """Test that same object referenced directly and via proxy maintains identity.""" state = ProxyTestState() @@ -187,6 +211,25 @@ async def mock_modify_state_context(*args, **kwargs): pass +@pytest.mark.asyncio +async def test_state_proxy_recreation_skips_unavailable_runtime_computed_var( + attached_mock_event_context: EventContext, + emitted_deltas: list[tuple[str, Mapping[str, Mapping[str, Any]]]], +): + """Recreated fields refresh computed dependencies without evaluating route-only vars.""" + state = RuntimeComputedProxyState() + state_proxy = StateProxy(state) + + async with state_proxy: + pass + + delta = emitted_deltas[-1][1] + values = delta[RuntimeComputedProxyState.get_full_name()] + assert values["value" + FIELD_MARKER] == 1 + assert values["doubled" + FIELD_MARKER] == 2 + assert "runtime_value" + FIELD_MARKER not in values + + @dataclasses.dataclass class TaggedModel: """A dataclass with a mutable attribute for attr-path refresh tests.""" From c1eb672c50407bb72dd30927b4c403fbdc3d835b Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Mon, 12 Oct 2026 18:05:41 +0530 Subject: [PATCH 09/33] Invalidate computed vars from recreated backend fields --- reflex/istate/proxy.py | 7 ++++++- tests/units/istate/test_proxy.py | 13 ++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/reflex/istate/proxy.py b/reflex/istate/proxy.py index eae218b9909..c64b0ab31f9 100644 --- a/reflex/istate/proxy.py +++ b/reflex/istate/proxy.py @@ -271,9 +271,14 @@ async def __aenter__(self) -> Self: with _track_state_recreation() as recreated_states: mutable_state = await self._self_actx.__aenter__() for state in recreated_states: + recreated_fields = { + name + for name, field in state.__fields__.items() + if field._owner is type(state) + } state._was_touched = True state.dirty_vars.update(state.base_vars) - state._mark_dirty(state.base_vars) + state._mark_dirty(recreated_fields) refreshed_state = mutable_state.get_substate(self._self_substate_path) self._self_mutable = True self._self_entered_context = True diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index 9f8795803ef..79db9158f7f 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -140,9 +140,10 @@ class ProxyTestState(rx.State): class RuntimeComputedProxyState(BaseState): - """State with a runtime-only computed value and a field-backed one.""" + """State with runtime-only and field-backed computed values.""" value: int = 1 + _backend_value: int = 2 @rx.var def doubled(self) -> int: @@ -153,6 +154,15 @@ def doubled(self) -> int: """ return self.value * 2 + @rx.var + def doubled_backend(self) -> int: + """A computed value that depends only on a backend field. + + Returns: + The doubled backend value. + """ + return self._backend_value * 2 + @rx.var def runtime_value(self) -> str: """A route-provided value that is not available in background state. @@ -227,6 +237,7 @@ async def test_state_proxy_recreation_skips_unavailable_runtime_computed_var( values = delta[RuntimeComputedProxyState.get_full_name()] assert values["value" + FIELD_MARKER] == 1 assert values["doubled" + FIELD_MARKER] == 2 + assert values["doubled_backend" + FIELD_MARKER] == 4 assert "runtime_value" + FIELD_MARKER not in values From d8a8d04ee752e989707db28eaee37dd1c359d195 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Wed, 14 Oct 2026 14:22:14 +0530 Subject: [PATCH 10/33] fix: refresh cross-state vars after state recreation --- reflex/istate/manager/__init__.py | 2 +- reflex/istate/manager/memory.py | 4 ++-- reflex/istate/manager/redis.py | 6 ++--- reflex/istate/proxy.py | 37 ++++++++++++++++++++++++++++ tests/units/istate/test_proxy.py | 40 ++++++++++++++++++++++++++++++- 5 files changed, 82 insertions(+), 7 deletions(-) diff --git a/reflex/istate/manager/__init__.py b/reflex/istate/manager/__init__.py index 3ebe6efee1c..237703a1f8d 100644 --- a/reflex/istate/manager/__init__.py +++ b/reflex/istate/manager/__init__.py @@ -353,7 +353,7 @@ def _track_state_recreation() -> Iterator[list["BaseState"]]: @contextlib.contextmanager -def _without_state_recreation_tracking() -> Iterator[None]: +def _no_state_recreation_tracking() -> Iterator[None]: """Temporarily prevent child tasks from inheriting state tracking.""" tracker_token = _STATE_RECREATION_TRACKER.set(None) try: diff --git a/reflex/istate/manager/memory.py b/reflex/istate/manager/memory.py index 5463884249a..56abad9493b 100644 --- a/reflex/istate/manager/memory.py +++ b/reflex/istate/manager/memory.py @@ -12,9 +12,9 @@ StateManager, StateModificationContext, _default_token_expiration, + _no_state_recreation_tracking, _release_state_tree, _report_state_tree_recreation, - _without_state_recreation_tracking, ) from reflex.istate.manager.token import TOKEN_TYPE, BaseStateToken, StateToken @@ -141,7 +141,7 @@ def _ensure_expiration_task(self): """Ensure the expiration background task is running.""" if self._expiration_task is None or self._expiration_task.done(): asyncio.get_running_loop() # Ensure we're in an event loop. - with _without_state_recreation_tracking(): + with _no_state_recreation_tracking(): self._expiration_task = asyncio.create_task( self._expire_states(), name="StateManagerMemory|Expiration", diff --git a/reflex/istate/manager/redis.py b/reflex/istate/manager/redis.py index 566a61510bf..77c2509cd1b 100644 --- a/reflex/istate/manager/redis.py +++ b/reflex/istate/manager/redis.py @@ -28,8 +28,8 @@ StateManager, StateModificationContext, _default_token_expiration, + _no_state_recreation_tracking, _report_state_recreation, - _without_state_recreation_tracking, ) from reflex.istate.manager.token import TOKEN_TYPE, BaseStateToken, StateToken from reflex.state import BaseState @@ -801,7 +801,7 @@ async def lease_breaker(): lock_key not in self._local_leases and await self._n_lock_contenders(self._lock_key(token)) == 0 ): - with _without_state_recreation_tracking(): + with _no_state_recreation_tracking(): self._local_leases[lock_key] = task = asyncio.create_task( lease_breaker(), name=f"reflex_lease_breaker|{lock_key}|{lock_id.decode()}", @@ -912,7 +912,7 @@ async def _subscribe_lock_updates(self): def _ensure_lock_task(self) -> None: """Ensure the lock updates subscriber task is running.""" - with _without_state_recreation_tracking(): + with _no_state_recreation_tracking(): ensure_task( owner=self, task_attribute="_lock_task", diff --git a/reflex/istate/proxy.py b/reflex/istate/proxy.py index c64b0ab31f9..100bc074159 100644 --- a/reflex/istate/proxy.py +++ b/reflex/istate/proxy.py @@ -85,6 +85,42 @@ ) +def _mark_recreated_cross_state_dependencies( + root_state: BaseState, recreated_states: Sequence[BaseState] +) -> None: + """Invalidate dependents in recreated states when their sources were not reset. + + Args: + root_state: The loaded root state tree. + recreated_states: States rebuilt by the current state-manager operation. + """ + recreated_names = {state.get_full_name() for state in recreated_states} + if not recreated_names: + return + + states_by_name = {} + pending = [root_state] + while pending: + state = pending.pop() + states_by_name[state.get_full_name()] = state + pending.extend(state.substates.values()) + + for source_state in states_by_name.values(): + for dependent_vars in source_state._var_dependencies.values(): + for target_name, computed_var_name in dependent_vars: + if target_name not in recreated_names: + continue + target_state = states_by_name.get(target_name) + if target_state is None or computed_var_name in target_state.dirty_vars: + continue + computed_var = target_state.computed_vars.get(computed_var_name) + if computed_var is None: + continue + computed_var.mark_dirty(instance=target_state) + target_state.dirty_vars.add(computed_var_name) + target_state._mark_dirty((computed_var_name,)) + + def _dataclass_proxy_namespace(wrapped_cls: type) -> dict[str, Any]: """Collect the dataclass metadata to define a wrapped type's proxy class with. @@ -279,6 +315,7 @@ async def __aenter__(self) -> Self: state._was_touched = True state.dirty_vars.update(state.base_vars) state._mark_dirty(recreated_fields) + _mark_recreated_cross_state_dependencies(mutable_state, recreated_states) refreshed_state = mutable_state.get_substate(self._self_substate_path) self._self_mutable = True self._self_entered_context = True diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index 79db9158f7f..d0b50563858 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -11,7 +11,7 @@ from collections.abc import Mapping from contextlib import asynccontextmanager from operator import attrgetter -from typing import Any, ClassVar +from typing import Any, ClassVar, cast import pytest from reflex_base.constants.state import FIELD_MARKER @@ -28,6 +28,7 @@ MutableProxy, ReadOnlyStateProxy, StateProxy, + _mark_recreated_cross_state_dependencies, _proxy_class, is_mutable_type, ) @@ -241,6 +242,43 @@ async def test_state_proxy_recreation_skips_unavailable_runtime_computed_var( assert "runtime_value" + FIELD_MARKER not in values +@pytest.mark.asyncio +async def test_recreated_state_invalidates_computed_vars_from_sibling_state( + attached_mock_event_context: EventContext, +): + """A recreated state's cross-state computed var must not retain its old cache.""" + + class RootState(BaseState): + pass + + class SourceState(RootState): + value: int = 7 + + class TargetState(RootState): + @rx.var + async def source_value(self) -> int: + source = await self.get_state(SourceState) + return source.value + + root = RootState() + source = cast( + SourceState, root.get_substate(SourceState.get_full_name().split(".")) + ) + target = cast( + TargetState, root.get_substate(TargetState.get_full_name().split(".")) + ) + computed_var = TargetState.computed_vars["source_value"] + cast(dict[str, Any], target.__dict__)[computed_var._cache_attr] = 1 + + _mark_recreated_cross_state_dependencies(root, [target]) + + assert "source_value" in target.dirty_vars + assert computed_var._cache_attr not in target.__dict__ + assert await target.source_value == source.value + delta = await root._get_resolved_delta() + assert delta[target.get_full_name()]["source_value" + FIELD_MARKER] == source.value + + @dataclasses.dataclass class TaggedModel: """A dataclass with a mutable attribute for attr-path refresh tests.""" From 52bfa624423dadbe509c465d00843bb7533822d5 Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:25:56 +0530 Subject: [PATCH 11/33] ci: install SQLAlchemy asyncio extra in legacy smoke test --- .github/workflows/examples.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index c34ec5b014a..39420ca1cb0 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -82,7 +82,7 @@ jobs: - name: Install reflex 0.8.23 into a fresh venv run: | uv venv --python 3.12 "$RUNNER_TEMP/reflex-0.8.23" - uv pip install --python "$RUNNER_TEMP/reflex-0.8.23/bin/python" "reflex==0.8.23" "plotly>=6.0,<7.0" + uv pip install --python "$RUNNER_TEMP/reflex-0.8.23/bin/python" "reflex==0.8.23" "sqlalchemy[asyncio]" "plotly>=6.0,<7.0" echo "$RUNNER_TEMP/reflex-0.8.23/bin" >> "$GITHUB_PATH" - name: Show the reflex under test working-directory: examples/playground From e39ccb4ae55bc4595ac230d0799224ebcefe9ff0 Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:41:35 +0530 Subject: [PATCH 12/33] fix: include unloaded sibling dependencies on state recreation --- reflex/istate/proxy.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/reflex/istate/proxy.py b/reflex/istate/proxy.py index 100bc074159..94a5a1cdf63 100644 --- a/reflex/istate/proxy.py +++ b/reflex/istate/proxy.py @@ -105,8 +105,12 @@ def _mark_recreated_cross_state_dependencies( states_by_name[state.get_full_name()] = state pending.extend(state.substates.values()) - for source_state in states_by_name.values(): - for dependent_vars in source_state._var_dependencies.values(): + state_classes = [type(root_state)] + for state_cls in state_classes: + state_classes.extend(state_cls.get_substates()) + + for source_state_cls in state_classes: + for dependent_vars in source_state_cls._var_dependencies.values(): for target_name, computed_var_name in dependent_vars: if target_name not in recreated_names: continue From 8f3f11b94840aa10ef7f4ba3b6e6a9c099bcf52e Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:42:09 +0530 Subject: [PATCH 13/33] test: cover recreated dependencies from unloaded siblings --- tests/units/istate/test_proxy.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index d0b50563858..6216b0094b7 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -279,6 +279,37 @@ async def source_value(self) -> int: assert delta[target.get_full_name()]["source_value" + FIELD_MARKER] == source.value +def test_recreated_dependency_is_found_when_source_sibling_is_unloaded( + attached_mock_event_context: EventContext, +): + """Use class dependency metadata when a Redis-loaded source sibling is absent.""" + + class RootState(BaseState): + pass + + class SourceState(RootState): + value: int = 7 + + class TargetState(RootState): + @rx.var + async def source_value(self) -> int: + source = await self.get_state(SourceState) + return source.value + + root = RootState() + target = cast( + TargetState, root.get_substate(TargetState.get_full_name().split(".")) + ) + root.substates.pop(SourceState.get_name()) + computed_var = TargetState.computed_vars["source_value"] + cast(dict[str, Any], target.__dict__)[computed_var._cache_attr] = 1 + + _mark_recreated_cross_state_dependencies(root, [target]) + + assert "source_value" in target.dirty_vars + assert computed_var._cache_attr not in target.__dict__ + + @dataclasses.dataclass class TaggedModel: """A dataclass with a mutable attribute for attr-path refresh tests.""" From f43427f5d1eb66ff2122a959f20a182336fae519 Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:51:49 +0530 Subject: [PATCH 14/33] fix: avoid mutating state-class loop iterator --- reflex/istate/proxy.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/reflex/istate/proxy.py b/reflex/istate/proxy.py index 94a5a1cdf63..ddfe09d5402 100644 --- a/reflex/istate/proxy.py +++ b/reflex/istate/proxy.py @@ -105,11 +105,10 @@ def _mark_recreated_cross_state_dependencies( states_by_name[state.get_full_name()] = state pending.extend(state.substates.values()) - state_classes = [type(root_state)] - for state_cls in state_classes: - state_classes.extend(state_cls.get_substates()) - - for source_state_cls in state_classes: + pending_state_classes = [type(root_state)] + while pending_state_classes: + source_state_cls = pending_state_classes.pop() + pending_state_classes.extend(source_state_cls.get_substates()) for dependent_vars in source_state_cls._var_dependencies.values(): for target_name, computed_var_name in dependent_vars: if target_name not in recreated_names: From d6be382b0f97c6a3b2222b67a6525f8074876688 Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:43:04 +0530 Subject: [PATCH 15/33] fix: refresh recreated states without persisting them --- reflex/istate/proxy.py | 54 ++++++------------------------------------ 1 file changed, 7 insertions(+), 47 deletions(-) diff --git a/reflex/istate/proxy.py b/reflex/istate/proxy.py index ddfe09d5402..6abd0646d0e 100644 --- a/reflex/istate/proxy.py +++ b/reflex/istate/proxy.py @@ -85,45 +85,6 @@ ) -def _mark_recreated_cross_state_dependencies( - root_state: BaseState, recreated_states: Sequence[BaseState] -) -> None: - """Invalidate dependents in recreated states when their sources were not reset. - - Args: - root_state: The loaded root state tree. - recreated_states: States rebuilt by the current state-manager operation. - """ - recreated_names = {state.get_full_name() for state in recreated_states} - if not recreated_names: - return - - states_by_name = {} - pending = [root_state] - while pending: - state = pending.pop() - states_by_name[state.get_full_name()] = state - pending.extend(state.substates.values()) - - pending_state_classes = [type(root_state)] - while pending_state_classes: - source_state_cls = pending_state_classes.pop() - pending_state_classes.extend(source_state_cls.get_substates()) - for dependent_vars in source_state_cls._var_dependencies.values(): - for target_name, computed_var_name in dependent_vars: - if target_name not in recreated_names: - continue - target_state = states_by_name.get(target_name) - if target_state is None or computed_var_name in target_state.dirty_vars: - continue - computed_var = target_state.computed_vars.get(computed_var_name) - if computed_var is None: - continue - computed_var.mark_dirty(instance=target_state) - target_state.dirty_vars.add(computed_var_name) - target_state._mark_dirty((computed_var_name,)) - - def _dataclass_proxy_namespace(wrapped_cls: type) -> dict[str, Any]: """Collect the dataclass metadata to define a wrapped type's proxy class with. @@ -310,15 +271,14 @@ async def __aenter__(self) -> Self: with _track_state_recreation() as recreated_states: mutable_state = await self._self_actx.__aenter__() for state in recreated_states: - recreated_fields = { + state_cls = type(state) + state.dirty_vars.update( name - for name, field in state.__fields__.items() - if field._owner is type(state) - } - state._was_touched = True - state.dirty_vars.update(state.base_vars) - state._mark_dirty(recreated_fields) - _mark_recreated_cross_state_dependencies(mutable_state, recreated_states) + for name, field in state_cls.get_fields().items() + if field._owner is state_cls + ) + state.dirty_vars.update(state.computed_vars) + state._mark_dirty() refreshed_state = mutable_state.get_substate(self._self_substate_path) self._self_mutable = True self._self_entered_context = True From b7da0b9ab51ac0b4535eb034cb9e7a41ab2ecfe5 Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:43:36 +0530 Subject: [PATCH 16/33] test: cover computed refresh on recreated state --- tests/units/istate/test_proxy.py | 89 +------------------------------- 1 file changed, 2 insertions(+), 87 deletions(-) diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index 6216b0094b7..59b1cf7cc10 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -28,7 +28,6 @@ MutableProxy, ReadOnlyStateProxy, StateProxy, - _mark_recreated_cross_state_dependencies, _proxy_class, is_mutable_type, ) @@ -140,40 +139,6 @@ class ProxyTestState(rx.State): items: list[Item] = [] -class RuntimeComputedProxyState(BaseState): - """State with runtime-only and field-backed computed values.""" - - value: int = 1 - _backend_value: int = 2 - - @rx.var - def doubled(self) -> int: - """A computed value that can be invalidated by the state field. - - Returns: - The doubled state value. - """ - return self.value * 2 - - @rx.var - def doubled_backend(self) -> int: - """A computed value that depends only on a backend field. - - Returns: - The doubled backend value. - """ - return self._backend_value * 2 - - @rx.var - def runtime_value(self) -> str: - """A route-provided value that is not available in background state. - - Returns: - The route-provided value. - """ - return self.dynamic # pyright: ignore[reportAttributeAccessIssue] - - def test_mutable_proxy_pickle_preserves_object_identity(): """Test that same object referenced directly and via proxy maintains identity.""" state = ProxyTestState() @@ -222,26 +187,6 @@ async def mock_modify_state_context(*args, **kwargs): pass -@pytest.mark.asyncio -async def test_state_proxy_recreation_skips_unavailable_runtime_computed_var( - attached_mock_event_context: EventContext, - emitted_deltas: list[tuple[str, Mapping[str, Mapping[str, Any]]]], -): - """Recreated fields refresh computed dependencies without evaluating route-only vars.""" - state = RuntimeComputedProxyState() - state_proxy = StateProxy(state) - - async with state_proxy: - pass - - delta = emitted_deltas[-1][1] - values = delta[RuntimeComputedProxyState.get_full_name()] - assert values["value" + FIELD_MARKER] == 1 - assert values["doubled" + FIELD_MARKER] == 2 - assert values["doubled_backend" + FIELD_MARKER] == 4 - assert "runtime_value" + FIELD_MARKER not in values - - @pytest.mark.asyncio async def test_recreated_state_invalidates_computed_vars_from_sibling_state( attached_mock_event_context: EventContext, @@ -270,7 +215,8 @@ async def source_value(self) -> int: computed_var = TargetState.computed_vars["source_value"] cast(dict[str, Any], target.__dict__)[computed_var._cache_attr] = 1 - _mark_recreated_cross_state_dependencies(root, [target]) + target.dirty_vars.update(target.computed_vars) + target._mark_dirty() assert "source_value" in target.dirty_vars assert computed_var._cache_attr not in target.__dict__ @@ -279,37 +225,6 @@ async def source_value(self) -> int: assert delta[target.get_full_name()]["source_value" + FIELD_MARKER] == source.value -def test_recreated_dependency_is_found_when_source_sibling_is_unloaded( - attached_mock_event_context: EventContext, -): - """Use class dependency metadata when a Redis-loaded source sibling is absent.""" - - class RootState(BaseState): - pass - - class SourceState(RootState): - value: int = 7 - - class TargetState(RootState): - @rx.var - async def source_value(self) -> int: - source = await self.get_state(SourceState) - return source.value - - root = RootState() - target = cast( - TargetState, root.get_substate(TargetState.get_full_name().split(".")) - ) - root.substates.pop(SourceState.get_name()) - computed_var = TargetState.computed_vars["source_value"] - cast(dict[str, Any], target.__dict__)[computed_var._cache_attr] = 1 - - _mark_recreated_cross_state_dependencies(root, [target]) - - assert "source_value" in target.dirty_vars - assert computed_var._cache_attr not in target.__dict__ - - @dataclasses.dataclass class TaggedModel: """A dataclass with a mutable attribute for attr-path refresh tests.""" From f665f9a145765d9117835b78c6eec9b3c24ee90a Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:43:55 +0530 Subject: [PATCH 17/33] test: preserve browser token after state expiry --- tests/integration/test_memory_state_manager_expiration.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/integration/test_memory_state_manager_expiration.py b/tests/integration/test_memory_state_manager_expiration.py index 0b55da9008e..a6e0b72c1be 100644 --- a/tests/integration/test_memory_state_manager_expiration.py +++ b/tests/integration/test_memory_state_manager_expiration.py @@ -215,6 +215,11 @@ def test_background_task_refreshes_recreated_state_tree( frontend_url = memory_expiration_app.frontend_url assert frontend_url is not None driver.get(frontend_url.removesuffix("/") + "/background") + token_input = AppHarness.poll_for_or_raise_timeout( + lambda: driver.find_element(By.ID, "token") + ) + token = token_input.get_attribute("value") + assert token is not None counter = AppHarness.poll_for_or_raise_timeout( lambda: driver.find_element(By.ID, "counter") ) @@ -234,3 +239,4 @@ def test_background_task_refreshes_recreated_state_tree( AppHarness.expect(lambda: counter.text == "100", timeout=10) AppHarness.expect(lambda: child_counter.text == "0", timeout=10) AppHarness.expect(lambda: component_counter.text == "0", timeout=10) + AppHarness.expect(lambda: token_input.get_attribute("value") == token) From d18e0fa35ab08163d15c9b431a9b1f976ef9b49c Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:46:20 +0530 Subject: [PATCH 18/33] feat: add per-token state manifest key --- reflex/istate/manager/token.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/reflex/istate/manager/token.py b/reflex/istate/manager/token.py index f6838b7abc6..c380bd59cd5 100644 --- a/reflex/istate/manager/token.py +++ b/reflex/istate/manager/token.py @@ -162,6 +162,14 @@ def _state_key(self, cls: type[BaseState]) -> str: """ return f"{self.ident}_{cls.get_full_name()}" + def _state_manifest_key(self) -> str: + """Get the Redis key recording state keys previously persisted for this token. + + Returns: + The Redis key for this token's persisted-state manifest. + """ + return f"{self.ident}_reflex_state_manifest" + def __str__(self) -> str: """The key used in the underlying StateManager store. From 8aebb75f7c9da8c2fc4d32723248afc9d1b5dcce Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:47:05 +0530 Subject: [PATCH 19/33] fix: track Redis states persisted before expiration --- reflex/istate/manager/redis.py | 58 +++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/reflex/istate/manager/redis.py b/reflex/istate/manager/redis.py index 77c2509cd1b..0907b501f4f 100644 --- a/reflex/istate/manager/redis.py +++ b/reflex/istate/manager/redis.py @@ -129,16 +129,19 @@ class OplockFound(Exception): # noqa: N818 """Indicates that an opportunistic lock was found.""" -# KEYS: the lock key, then the state keys. ARGV: the lock id, the state -# expiration in seconds, then the serialized states in KEYS order. Writes only -# while the lock is held by ARGV[1], and returns the lock's remaining PTTL, or -# nil when the lock changed hands or expired and nothing was written. +# KEYS: the lock key, the state manifest key, then the state keys. +# ARGV: the lock id, the state expiration in seconds, then serialized states +# in KEYS order. The manifest tracks which per-state keys have been persisted. _FENCED_SAVE_SCRIPT = """ if redis.call('GET', KEYS[1]) ~= ARGV[1] then return nil end -for i = 2, #KEYS do - redis.call('SET', KEYS[i], ARGV[i + 1], 'EX', ARGV[2]) +for i = 3, #KEYS do + redis.call('SET', KEYS[i], ARGV[i], 'EX', ARGV[2]) + redis.call('SADD', KEYS[2], KEYS[i]) +end +if #KEYS > 2 then + redis.call('PEXPIRE', KEYS[2], tonumber(ARGV[2]) * 1000) end return redis.call('PTTL', KEYS[1]) """ @@ -359,18 +362,26 @@ async def get_state( key=lambda x: x.get_full_name(), ) - # Read the tree atomically with one command instead of a transaction - # containing a GET for each state. An already populated tree needs no IO. - redis_states = ( - await self.redis.mget([ - token._state_key(state_cls) for state_cls in required_state_classes - ]) - if required_state_classes - else [] - ) + # Read state values and their persisted-key manifest in one round trip. + state_keys = [ + token._state_key(state_cls) for state_cls in required_state_classes + ] + if required_state_classes: + pipeline = self.redis.pipeline(transaction=False) + pipeline.mget(state_keys) + pipeline.smembers(token._state_manifest_key()) + redis_states, manifest_keys = await pipeline.execute() + persisted_state_keys = { + key.decode() if isinstance(key, bytes) else key + for key in manifest_keys + } + else: + redis_states = [] + persisted_state_keys = set() - for state_cls, redis_state in zip( + for state_cls, state_key, redis_state in zip( required_state_classes, + state_keys, redis_states, strict=True, ): @@ -381,12 +392,13 @@ async def get_state( with contextlib.suppress(StateSchemaMismatchError): state = BaseState._deserialize(data=redis_state) if state is None: - # Key didn't exist or schema mismatch so create a new instance for this token. + # A missing never-persisted state has no stale client value to reset. state = state_cls( init_substates=False, _reflex_internal_init=True, ) - _report_state_recreation(state) + if redis_state is not None or state_key in persisted_state_keys: + _report_state_recreation(state) state_full_name = state.get_full_name() flat_state_tree[state_full_name] = state if state.get_parent_state() is not None: @@ -441,11 +453,18 @@ async def set_state( pickle_state = token.serialize(state) writes = [(str(token), pickle_state)] if pickle_state else [] + manifest_key = ( + token._state_manifest_key() + if isinstance(token, BaseStateToken) + else f"{token.lock_key}_reflex_state_manifest" + ) if lock_id is None: if writes: pipeline = self.redis.pipeline(transaction=False) for key, pickle_state in writes: pipeline.set(key, pickle_state, ex=self.token_expiration) + pipeline.sadd(manifest_key, key) + pipeline.pexpire(manifest_key, self.token_expiration * 1000) await pipeline.execute() return @@ -463,8 +482,9 @@ async def set_state( fenced_save = cast("Callable[..., Awaitable[int | None]]", self.redis.eval) pttl = await fenced_save( _FENCED_SAVE_SCRIPT, - 1 + len(writes), + 2 + len(writes), lock_key, + manifest_key, *(key for key, _ in writes), lock_id, self.token_expiration, From 01709baaed19e0f86098b163545bd64a90f5267e Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:47:23 +0530 Subject: [PATCH 20/33] test: model persisted Redis state manifests --- tests/units/mock_redis.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/units/mock_redis.py b/tests/units/mock_redis.py index 1cbbd65ed95..66584f19889 100644 --- a/tests/units/mock_redis.py +++ b/tests/units/mock_redis.py @@ -123,6 +123,15 @@ async def mock_srem(key: KeyT, value: EncodableT) -> int: return 1 return 0 + async def mock_smembers(key: KeyT) -> set[EncodableT]: # noqa: RUF029 + _expire_keys() + keyset = keys.get(_key_bytes(key)) + if keyset is None: + return set() + if not isinstance(keyset, set): + raise TypeError(WRONGTYPE_MESSAGE) + return set(keyset) + async def mock_scard(key: KeyT) -> int: # noqa: RUF029 _expire_keys() keyset = keys.get(_key_bytes(key)) @@ -176,6 +185,12 @@ def set( def get(self, key: KeyT): self.results.append(redis_mock.get(key=key)) + def mget(self, keys: list[KeyT]): + self.results.append(redis_mock.mget(keys)) + + def smembers(self, key: KeyT): + self.results.append(redis_mock.smembers(key=key)) + def sadd(self, key: KeyT, value: EncodableT): self.results.append(redis_mock.sadd(key=key, value=value)) @@ -183,7 +198,7 @@ def pexpire(self, key: KeyT, px: int, xx: bool = False): self.results.append(redis_mock.pexpire(key=key, px=px, xx=xx)) async def execute(self): - results = await asyncio.gather(*self.results) + results = [await result for result in self.results] self.results = [] return results @@ -211,12 +226,15 @@ async def mock_eval(script: str, numkeys: int, *keys_and_args: Any) -> Any: if script != _FENCED_SAVE_SCRIPT: msg = "mock_redis only emulates the state manager's fenced save script." raise NotImplementedError(msg) - lock_key, *state_keys = keys_and_args[:numkeys] + lock_key, manifest_key, *state_keys = keys_and_args[:numkeys] lock_id, expiration, *payloads = keys_and_args[numkeys:] if await redis_mock.get(lock_key) != lock_id: return None for key, payload in zip(state_keys, payloads, strict=True): await redis_mock.set(key, payload, ex=int(expiration)) + await redis_mock.sadd(manifest_key, key) + if state_keys: + await redis_mock.pexpire(manifest_key, int(expiration) * 1000) return await redis_mock.pttl(lock_key) async def pttl(key: KeyT) -> int: # noqa: RUF029 @@ -297,6 +315,7 @@ async def listen() -> AsyncGenerator[dict[str, Any], None]: redis_mock.sadd = mock_sadd redis_mock.srem = mock_srem redis_mock.scard = mock_scard + redis_mock.smembers = mock_smembers redis_mock.pexpire = mock_pexpire redis_mock.pipeline = pipeline redis_mock.eval = mock_eval From 21c08af1f19f1eb097ed81bfc5d41be9cda0747f Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:48:02 +0530 Subject: [PATCH 21/33] test: cover Redis state expiry provenance --- tests/units/istate/manager/test_redis.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/units/istate/manager/test_redis.py b/tests/units/istate/manager/test_redis.py index 127dc3c4c5b..f1d85e621fe 100644 --- a/tests/units/istate/manager/test_redis.py +++ b/tests/units/istate/manager/test_redis.py @@ -104,8 +104,6 @@ async def test_get_state_does_not_dirty_persisted_states_on_reload(): state = await manager.get_state(token) state.foo = "persisted" - for substate in manager._get_populated_states(state).values(): - substate._was_touched = True await manager.set_state(token, state) with _track_state_recreation() as recreated_states: reloaded_state = await manager.get_state(token) @@ -129,6 +127,10 @@ async def test_get_state_reports_missing_redis_substates(): key=lambda cls: cls.get_full_name(), ) persisted_state = RedisTestState(foo="persisted") + await redis.sadd( + token._state_manifest_key(), + *(token._state_key(cls) for cls in state_classes[1:]), + ) redis.mget = AsyncMock( return_value=[ persisted_state._serialize() if cls is RedisTestState else None @@ -1233,7 +1235,9 @@ def recording_eval(script: str, numkeys: int, *keys_and_args: Any) -> Any: await state_manager_redis.set_state(token, state, lock_id=lock_id) assert not calls - assert saved_keys == [(lock_key, token._state_key(TreeFirst))] + assert saved_keys == [ + (lock_key, token._state_manifest_key(), token._state_key(TreeFirst)) + ] assert await _stored_tree_states(state_manager_redis, token) == {TreeFirst} await redis.delete(lock_key) From adb779567c32624dd21218af031a134610c4a0f0 Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:48:11 +0530 Subject: [PATCH 22/33] test: avoid forcing Redis substates to persist --- tests/units/istate/test_proxy.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index 59b1cf7cc10..8bebf9de7b5 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -416,9 +416,6 @@ async def test_inherited_mutable_var_marks_its_owner( """ state_token = BaseStateToken(ident=token, cls=InheritedListSubState) async with state_manager.modify_state(state_token) as root: - if isinstance(state_manager, StateManagerRedis): - for state in state_manager._get_populated_states(root).values(): - state._was_touched = True proxy = StateProxy( root.get_substate(InheritedListSubState.get_full_name().split(".")) ) From 5819c58ef6a4785336c16eaa6966aed34dcab72e Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:48:18 +0530 Subject: [PATCH 23/33] test: remove unused Redis manager import --- tests/units/istate/test_proxy.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index 8bebf9de7b5..4135fdee48e 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -21,7 +21,6 @@ import reflex as rx from reflex.istate.data import HeaderData, PageData, RouterData from reflex.istate.manager import StateManager -from reflex.istate.manager.redis import StateManagerRedis from reflex.istate.manager.token import BaseStateToken from reflex.istate.proxy import ( ImmutableMutableProxy, From 1d23cefd20d389004d580b590c649cf4f82a5ad8 Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:48:45 +0530 Subject: [PATCH 24/33] fix: migrate existing Redis keys into state manifest --- reflex/istate/manager/redis.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/reflex/istate/manager/redis.py b/reflex/istate/manager/redis.py index 0907b501f4f..aa54733e947 100644 --- a/reflex/istate/manager/redis.py +++ b/reflex/istate/manager/redis.py @@ -375,6 +375,20 @@ async def get_state( key.decode() if isinstance(key, bytes) else key for key in manifest_keys } + untracked_keys = [ + key + for key, redis_state in zip(state_keys, redis_states, strict=True) + if redis_state is not None and key not in persisted_state_keys + ] + if untracked_keys: + # Learn existing Redis keys on first access after introducing the manifest. + manifest_pipeline = self.redis.pipeline(transaction=False) + for key in untracked_keys: + manifest_pipeline.sadd(token._state_manifest_key(), key) + manifest_pipeline.pexpire( + token._state_manifest_key(), self.token_expiration * 1000 + ) + await manifest_pipeline.execute() else: redis_states = [] persisted_state_keys = set() From d9d4702212d79a1e15070f686ca1d381b593ade4 Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:48:54 +0530 Subject: [PATCH 25/33] test: seed Redis manifest with recorded keys --- tests/units/istate/manager/test_redis.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/units/istate/manager/test_redis.py b/tests/units/istate/manager/test_redis.py index f1d85e621fe..4fcfff5a20b 100644 --- a/tests/units/istate/manager/test_redis.py +++ b/tests/units/istate/manager/test_redis.py @@ -67,8 +67,8 @@ class RedisAppObjectState(BaseState): @pytest.mark.asyncio -async def test_get_state_reads_tree_in_one_command(): - """Read persisted and missing states together, preserving their tree positions.""" +async def test_get_state_reads_tree_in_one_round_trip(): + """Read persisted and missing states together in one round trip.""" redis = mock_redis() manager = StateManagerRedis(redis=redis) token = BaseStateToken(ident="batched-read", cls=RedisTestState) @@ -127,10 +127,11 @@ async def test_get_state_reports_missing_redis_substates(): key=lambda cls: cls.get_full_name(), ) persisted_state = RedisTestState(foo="persisted") - await redis.sadd( - token._state_manifest_key(), - *(token._state_key(cls) for cls in state_classes[1:]), - ) + for state_cls in state_classes[1:]: + await redis.sadd( + token._state_manifest_key(), + token._state_key(state_cls), + ) redis.mget = AsyncMock( return_value=[ persisted_state._serialize() if cls is RedisTestState else None From c7dd8639800f809f58f0a6ca4a25933e94fa803f Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:56:14 +0530 Subject: [PATCH 26/33] test: wait for token before checking state refresh --- tests/integration/test_memory_state_manager_expiration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_memory_state_manager_expiration.py b/tests/integration/test_memory_state_manager_expiration.py index a6e0b72c1be..a1fb2a8b048 100644 --- a/tests/integration/test_memory_state_manager_expiration.py +++ b/tests/integration/test_memory_state_manager_expiration.py @@ -218,7 +218,7 @@ def test_background_task_refreshes_recreated_state_tree( token_input = AppHarness.poll_for_or_raise_timeout( lambda: driver.find_element(By.ID, "token") ) - token = token_input.get_attribute("value") + token = memory_expiration_app.poll_for_value(token_input) assert token is not None counter = AppHarness.poll_for_or_raise_timeout( lambda: driver.find_element(By.ID, "counter") From 657c51f7c717a04ebac1d6eaf034b23a09ceea90 Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:56:29 +0530 Subject: [PATCH 27/33] test: exercise proxy recreation for computed refresh --- tests/units/istate/test_proxy.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index 4135fdee48e..af742df517c 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -189,8 +189,12 @@ async def mock_modify_state_context(*args, **kwargs): @pytest.mark.asyncio async def test_recreated_state_invalidates_computed_vars_from_sibling_state( attached_mock_event_context: EventContext, + emitted_deltas: list[tuple[str, Mapping[str, Mapping[str, Any]]]], + monkeypatch: pytest.MonkeyPatch, ): - """A recreated state's cross-state computed var must not retain its old cache.""" + """StateProxy recreation refreshes a cross-state computed var cache.""" + + from reflex.istate.manager import _report_state_recreation class RootState(BaseState): pass @@ -211,17 +215,28 @@ async def source_value(self) -> int: target = cast( TargetState, root.get_substate(TargetState.get_full_name().split(".")) ) + root._clean() computed_var = TargetState.computed_vars["source_value"] cast(dict[str, Any], target.__dict__)[computed_var._cache_attr] = 1 - target.dirty_vars.update(target.computed_vars) - target._mark_dirty() + @asynccontextmanager + async def recreate_state_context(*args, **kwargs): + _report_state_recreation(target) + yield root + + monkeypatch.setattr( + attached_mock_event_context.state_manager, + "modify_state_with_links", + recreate_state_context, + ) + async with StateProxy(target): + pass - assert "source_value" in target.dirty_vars assert computed_var._cache_attr not in target.__dict__ - assert await target.source_value == source.value - delta = await root._get_resolved_delta() - assert delta[target.get_full_name()]["source_value" + FIELD_MARKER] == source.value + assert emitted_deltas[0][1][target.get_full_name()][ + "source_value" + FIELD_MARKER + ] == source.value + assert not target._was_touched @dataclasses.dataclass From 990f7671940f2627a5d249ce5fe9664bcb92d592 Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:59:02 +0530 Subject: [PATCH 28/33] style: format Redis manifest comprehension --- reflex/istate/manager/redis.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/reflex/istate/manager/redis.py b/reflex/istate/manager/redis.py index aa54733e947..8adad04e671 100644 --- a/reflex/istate/manager/redis.py +++ b/reflex/istate/manager/redis.py @@ -372,8 +372,7 @@ async def get_state( pipeline.smembers(token._state_manifest_key()) redis_states, manifest_keys = await pipeline.execute() persisted_state_keys = { - key.decode() if isinstance(key, bytes) else key - for key in manifest_keys + key.decode() if isinstance(key, bytes) else key for key in manifest_keys } untracked_keys = [ key From 532ee34d4a24f0a3593bc0e29f1114021a97ac91 Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:59:09 +0530 Subject: [PATCH 29/33] style: format proxy regression assertion --- tests/units/istate/test_proxy.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index af742df517c..88ce50fd1a9 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -233,9 +233,10 @@ async def recreate_state_context(*args, **kwargs): pass assert computed_var._cache_attr not in target.__dict__ - assert emitted_deltas[0][1][target.get_full_name()][ - "source_value" + FIELD_MARKER - ] == source.value + assert ( + emitted_deltas[0][1][target.get_full_name()]["source_value" + FIELD_MARKER] + == source.value + ) assert not target._was_touched From 286afb826ae919b332b881a4a34a1cae8f1958f1 Mon Sep 17 00:00:00 2001 From: harsh21234i <151563948+harsh21234i@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:34:35 +0530 Subject: [PATCH 30/33] fix: preserve framework state during recreation refresh --- reflex/istate/proxy.py | 27 +++++++++++++++++++++------ tests/units/istate/test_proxy.py | 3 +-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/reflex/istate/proxy.py b/reflex/istate/proxy.py index 6abd0646d0e..79ae76753f8 100644 --- a/reflex/istate/proxy.py +++ b/reflex/istate/proxy.py @@ -211,6 +211,7 @@ def __init__( self._self_actx_lock = asyncio.Lock() self._self_actx_lock_holder = None self._self_parent_state_proxy = parent_state_proxy + self._self_recreated_states: list[BaseState] = [] # Whether `async with self` was ever entered; a background handler that # never did emitted no delta, so the processor flushes once for it. self._self_entered_context = False @@ -270,14 +271,20 @@ async def __aenter__(self) -> Self: with _track_state_recreation() as recreated_states: mutable_state = await self._self_actx.__aenter__() + self._self_recreated_states = recreated_states + from reflex.state import State + for state in recreated_states: state_cls = type(state) - state.dirty_vars.update( - name - for name, field in state_cls.get_fields().items() - if field._owner is state_cls - ) + if state_cls is not State: + state.dirty_vars.update( + name + for name, field in state_cls.get_fields().items() + if field._owner is state_cls + ) state.dirty_vars.update(state.computed_vars) + for computed_var in state.computed_vars.values(): + computed_var.mark_dirty(instance=state) state._mark_dirty() refreshed_state = mutable_state.get_substate(self._self_substate_path) self._self_mutable = True @@ -303,7 +310,15 @@ async def __aexit__(self, *exc_info: Any) -> None: try: if self._self_mutable and self._self_actx is not None: root_state = self.__wrapped__._get_root_state() - delta = await root_state._get_resolved_delta() + touched_states = [ + (state, state._was_touched) for state in self._self_recreated_states + ] + try: + delta = await root_state._get_resolved_delta() + finally: + for state, was_touched in touched_states: + state._was_touched = was_touched + self._self_recreated_states.clear() root_state._clean() # When the frontend vars are modified emit the delta to the frontend. if delta: diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index 88ce50fd1a9..2069d77a607 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -193,7 +193,6 @@ async def test_recreated_state_invalidates_computed_vars_from_sibling_state( monkeypatch: pytest.MonkeyPatch, ): """StateProxy recreation refreshes a cross-state computed var cache.""" - from reflex.istate.manager import _report_state_recreation class RootState(BaseState): @@ -232,7 +231,7 @@ async def recreate_state_context(*args, **kwargs): async with StateProxy(target): pass - assert computed_var._cache_attr not in target.__dict__ + assert target.__dict__[computed_var._cache_attr] == source.value assert ( emitted_deltas[0][1][target.get_full_name()]["source_value" + FIELD_MARKER] == source.value From 1e7d91786166e00b86d501e12d4033531d66cc9e Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Thu, 15 Oct 2026 00:54:04 +0530 Subject: [PATCH 31/33] test: cover proxy refresh after state expiry --- tests/units/istate/test_proxy.py | 67 ++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index 2069d77a607..54fe44b1233 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -21,6 +21,7 @@ import reflex as rx from reflex.istate.data import HeaderData, PageData, RouterData from reflex.istate.manager import StateManager +from reflex.istate.manager.memory import StateManagerMemory from reflex.istate.manager.token import BaseStateToken from reflex.istate.proxy import ( ImmutableMutableProxy, @@ -190,10 +191,8 @@ async def mock_modify_state_context(*args, **kwargs): async def test_recreated_state_invalidates_computed_vars_from_sibling_state( attached_mock_event_context: EventContext, emitted_deltas: list[tuple[str, Mapping[str, Mapping[str, Any]]]], - monkeypatch: pytest.MonkeyPatch, ): """StateProxy recreation refreshes a cross-state computed var cache.""" - from reflex.istate.manager import _report_state_recreation class RootState(BaseState): pass @@ -207,36 +206,44 @@ async def source_value(self) -> int: source = await self.get_state(SourceState) return source.value - root = RootState() - source = cast( - SourceState, root.get_substate(SourceState.get_full_name().split(".")) - ) - target = cast( - TargetState, root.get_substate(TargetState.get_full_name().split(".")) + state_manager = StateManagerMemory(token_expiration=60) + original_state_manager = attached_mock_event_context.state_manager + object.__setattr__(attached_mock_event_context, "state_manager", state_manager) + state_token = BaseStateToken( + ident=attached_mock_event_context.token, + cls=TargetState, ) - root._clean() - computed_var = TargetState.computed_vars["source_value"] - cast(dict[str, Any], target.__dict__)[computed_var._cache_attr] = 1 - - @asynccontextmanager - async def recreate_state_context(*args, **kwargs): - _report_state_recreation(target) - yield root - - monkeypatch.setattr( - attached_mock_event_context.state_manager, - "modify_state_with_links", - recreate_state_context, - ) - async with StateProxy(target): - pass + try: + async with state_manager.modify_state(state_token) as root: + source = cast( + SourceState, root.get_substate(SourceState.get_full_name().split(".")) + ) + target = cast( + TargetState, root.get_substate(TargetState.get_full_name().split(".")) + ) + source.value = 1 + computed_var = TargetState.computed_vars["source_value"] + cast(dict[str, Any], target.__dict__)[computed_var._cache_attr] = 1 + + state_proxy = StateProxy(target) + state_manager._purge_token(state_token) + async with state_proxy: + pass - assert target.__dict__[computed_var._cache_attr] == source.value - assert ( - emitted_deltas[0][1][target.get_full_name()]["source_value" + FIELD_MARKER] - == source.value - ) - assert not target._was_touched + recreated_target = state_proxy.__wrapped__ + assert recreated_target.__dict__[computed_var._cache_attr] == 7 + assert ( + emitted_deltas[0][1][recreated_target.get_full_name()][ + "source_value" + FIELD_MARKER + ] + == 7 + ) + assert not recreated_target._was_touched + finally: + await state_manager.close() + object.__setattr__( + attached_mock_event_context, "state_manager", original_state_manager + ) @dataclasses.dataclass From 927020fc066fdcb08469b5fee54a9063c93ac24d Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Thu, 15 Oct 2026 01:06:33 +0530 Subject: [PATCH 32/33] test: type async Redis mock call --- tests/units/istate/manager/test_redis.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/units/istate/manager/test_redis.py b/tests/units/istate/manager/test_redis.py index 4fcfff5a20b..0b4ce7d3b58 100644 --- a/tests/units/istate/manager/test_redis.py +++ b/tests/units/istate/manager/test_redis.py @@ -7,9 +7,9 @@ import time import uuid from collections import Counter -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Awaitable from types import ModuleType -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, Mock import pytest @@ -128,9 +128,12 @@ async def test_get_state_reports_missing_redis_substates(): ) persisted_state = RedisTestState(foo="persisted") for state_cls in state_classes[1:]: - await redis.sadd( - token._state_manifest_key(), - token._state_key(state_cls), + await cast( + Awaitable[int], + redis.sadd( + token._state_manifest_key(), + token._state_key(state_cls), + ), ) redis.mget = AsyncMock( return_value=[ From b30c8976955e1150f81c7d003d1d5914e76b0dcb Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Wed, 14 Oct 2026 14:39:39 -0700 Subject: [PATCH 33/33] test: isolate proxy recovery state from application fixtures Root the proxy test state at BaseState so recovery does not load unrelated rx.State subclasses collected from other test modules. Reproduced the DynamicState missing-field failure with test_app collected before the fix; the focused reproduction and all 286 proxy/app tests pass afterward. --- tests/units/istate/test_proxy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index 54fe44b1233..f53e140753f 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -133,8 +133,8 @@ class Item: id: int -class ProxyTestState(rx.State): - """Test state with a list field.""" +class ProxyTestState(BaseState): + """Isolated state tree with a list field for proxy tests.""" items: list[Item] = []