From d0593158a59d876a6bdb3d0798985ed83d0353ec Mon Sep 17 00:00:00 2001 From: Farhan Date: Sat, 17 Oct 2026 00:34:06 +0500 Subject: [PATCH 1/5] fix: rehydrate state trees that expire during a background task A background task releases the state lock while it runs. If the session expires in that time, `async with self` gets a recreated tree, and the delta carries only the handler's changes. The browser keeps the values of the expired session. A recreated `rx.State` root has empty `router_data`. A foreground event already uses this signal to rehydrate. `StateProxy.__aenter__` now uses the same signal: it restores the router from the event context and runs `BaseStateEventProcessor._rehydrate`. The hydrate snapshot carries `is_hydrated=false`, so the browser keeps its client storage. Closes #7478 --- ...und-task-expired-state-rehydrate.bugfix.md | 1 + ...und-task-expired-state-rehydrate.bugfix.md | 1 + .../event/processor/base_state_processor.py | 3 +- reflex/istate/proxy.py | 12 +++ .../processor/test_base_state_processor.py | 93 +++++++++++++++++++ 5 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 news/+background-task-expired-state-rehydrate.bugfix.md create mode 100644 packages/reflex-base/news/+background-task-expired-state-rehydrate.bugfix.md diff --git a/news/+background-task-expired-state-rehydrate.bugfix.md b/news/+background-task-expired-state-rehydrate.bugfix.md new file mode 100644 index 00000000000..b98e8becffa --- /dev/null +++ b/news/+background-task-expired-state-rehydrate.bugfix.md @@ -0,0 +1 @@ +A background task that finishes after its session expired now rehydrates the page, so the browser no longer keeps stale values from the expired session. diff --git a/packages/reflex-base/news/+background-task-expired-state-rehydrate.bugfix.md b/packages/reflex-base/news/+background-task-expired-state-rehydrate.bugfix.md new file mode 100644 index 00000000000..7b0fbd66636 --- /dev/null +++ b/packages/reflex-base/news/+background-task-expired-state-rehydrate.bugfix.md @@ -0,0 +1 @@ +A background task that re-enters an expired session runs the same rehydrate step as a foreground event, so the page drops values from the expired session and keeps its route and browser storage. diff --git a/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py b/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py index 06438a25b76..876f35e2d2f 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py @@ -393,7 +393,8 @@ class BaseStateEventProcessor(EventProcessor): frontend. """ - async def _rehydrate(self, root_state: BaseState): + @staticmethod + async def _rehydrate(root_state: BaseState): """Rehydrate the state by calling the hydrate event handler. Args: diff --git a/reflex/istate/proxy.py b/reflex/istate/proxy.py index f15d4d1d3cc..862f1473ceb 100644 --- a/reflex/istate/proxy.py +++ b/reflex/istate/proxy.py @@ -267,6 +267,18 @@ async def __aenter__(self) -> Self: token=self._self_substate_token, event=self._self_event ) mutable_state = await self._self_actx.__aenter__() + from reflex.state import State + + if type(mutable_state) is State and not mutable_state.router_data: + # The tree expired while the lock was released: restore it the + # way a foreground event does, so the client drops stale values. + from reflex_base.event.processor import BaseStateEventProcessor + + if ctx.router_data: + mutable_state.router_data = mutable_state._update_router_vars( + ctx.router_data, {} + ) + await BaseStateEventProcessor._rehydrate(mutable_state) self._self_mutable = True self._self_entered_context = True super().__setattr__( diff --git a/tests/units/reflex_base/event/processor/test_base_state_processor.py b/tests/units/reflex_base/event/processor/test_base_state_processor.py index 39ce030323b..4926ec44ac1 100644 --- a/tests/units/reflex_base/event/processor/test_base_state_processor.py +++ b/tests/units/reflex_base/event/processor/test_base_state_processor.py @@ -492,6 +492,99 @@ async def fg(self): ) +def _expire_token(state_manager: StateManager, token: str) -> None: + """Drop every state stored for a token, as its expiration would. + + Args: + state_manager: The memory or redis state manager holding the token. + token: The client token to expire. + """ + if isinstance(state_manager, StateManagerMemory): + state_manager._purge_token(BaseStateToken(ident=token, cls=State)) + return + assert isinstance(state_manager, StateManagerRedis) + keys = state_manager.redis._internals["keys"] # pyright: ignore[reportAttributeAccessIssue] + for key in [key for key in keys if key.startswith(token.encode())]: + del keys[key] + + +@pytest.mark.parametrize( + "processor_state_manager", ["in_process", "redis"], indirect=True +) +async def test_background_task_rehydrates_state_tree_expired_while_unlocked( + wired_app: App, + real_base_state_processor: BaseStateEventProcessor, + emitted_deltas: list[tuple[str, Mapping[str, Mapping[str, Any]]]], + token: str, +): + """A background task re-entering an expired tree sends the full recreated state. + + Args: + wired_app: The App wired to the processor's state manager. + real_base_state_processor: The unmocked BaseStateEventProcessor. + emitted_deltas: List to capture emitted deltas. + token: The client token. + """ + waiting = asyncio.Event() + release = asyncio.Event() + + class ExpiryState(State): + value: int = 0 + + @event(background=True) + async def bg(self): + waiting.set() + await release.wait() + async with self: + self.value = 100 + + class ExpiryChildState(ExpiryState): + child: int = 0 + + @event + def set_child(self): + self.child = 13 + + router_data = { + RouteVar.PATH: "/post/[pid]", + RouteVar.ORIGIN: "/post/abc", + RouteVar.QUERY: {"pid": "abc"}, + RouteVar.CLIENT_TOKEN: token, + } + + set_child, bg = ( + Event.from_event_type(spec, router_data=router_data)[0] + for spec in (ExpiryChildState.set_child(), ExpiryState.bg()) + ) + assert real_base_state_processor._root_context is not None + state_manager = real_base_state_processor._root_context.state_manager + async with real_base_state_processor as processor: + await (await processor.enqueue(token, set_child)) + await processor.enqueue(token, bg) + await asyncio.wait_for(waiting.wait(), timeout=2) + _expire_token(state_manager, token) + emitted_deltas.clear() + release.set() + await processor.join(5) + + root_name = State.get_full_name() + child_name = ExpiryChildState.get_full_name() + is_hydrated_key = CompileVars.IS_HYDRATED + FIELD_MARKER + snapshots = [d for _, d in emitted_deltas if child_name in d] + assert snapshots, f"The expired child state was never resent: {emitted_deltas}" + snapshot = snapshots[0] + # Not hydrated, so the browser does not overwrite its client storage. + assert snapshot[root_name][is_hydrated_key] is False + assert snapshot[child_name]["child" + FIELD_MARKER] == 0 + page = snapshot[root_name][constants.ROUTER_PAGE + FIELD_MARKER] + assert page.raw_path == "/post/abc" + session = snapshot[root_name][constants.ROUTER_SESSION + FIELD_MARKER] + assert session.client_token == token + assert emitted_deltas[-1][1][ExpiryState.get_full_name()] == { + "value" + FIELD_MARKER: 100 + } + + async def test_background_yield_inside_context_flushes_delta_before_event( wired_app: App, real_base_state_processor: BaseStateEventProcessor, From dae5bf47e9a6791ab59feb2a85dc7d705c7752ee Mon Sep 17 00:00:00 2001 From: Farhan Date: Sat, 17 Oct 2026 00:40:53 +0500 Subject: [PATCH 2/5] fix: release the state lock when rehydrating an expired tree fails Mark the proxy mutable before the rehydrate, so a failure or a cancellation goes through __aexit__ and closes the state manager context. --- reflex/istate/proxy.py | 10 +++++----- tests/units/istate/test_proxy.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/reflex/istate/proxy.py b/reflex/istate/proxy.py index 862f1473ceb..e11395392d1 100644 --- a/reflex/istate/proxy.py +++ b/reflex/istate/proxy.py @@ -267,6 +267,11 @@ async def __aenter__(self) -> Self: token=self._self_substate_token, event=self._self_event ) mutable_state = await self._self_actx.__aenter__() + self._self_mutable = True + self._self_entered_context = True + super().__setattr__( + "__wrapped__", mutable_state.get_substate(self._self_substate_path) + ) from reflex.state import State if type(mutable_state) is State and not mutable_state.router_data: @@ -279,11 +284,6 @@ async def __aenter__(self) -> Self: ctx.router_data, {} ) await BaseStateEventProcessor._rehydrate(mutable_state) - self._self_mutable = True - self._self_entered_context = True - super().__setattr__( - "__wrapped__", mutable_state.get_substate(self._self_substate_path) - ) 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()) diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index 2138b4d0e8a..7be7a6744e8 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -16,6 +16,7 @@ import pytest from reflex_base.constants.state import FIELD_MARKER from reflex_base.event.context import EventContext +from reflex_base.event.processor import BaseStateEventProcessor from reflex_base.utils.exceptions import ImmutableStateError import reflex as rx @@ -186,6 +187,30 @@ async def mock_modify_state_context(*args, **kwargs): pass +@pytest.mark.asyncio +async def test_state_proxy_releases_lock_when_rehydrate_fails( + attached_mock_event_context: EventContext, monkeypatch: pytest.MonkeyPatch +): + """A failed rehydrate of an expired tree must release the state lock.""" + state_proxy = StateProxy(ProxyTestState()) + + async def failing_rehydrate(root_state: BaseState): # noqa: RUF029 + msg = "Simulated rehydrate failure" + raise RuntimeError(msg) + + monkeypatch.setattr(BaseStateEventProcessor, "_rehydrate", failing_rehydrate) + with pytest.raises(RuntimeError, match="Simulated rehydrate failure"): + async with state_proxy: + pass + + state_token = BaseStateToken( + ident=attached_mock_event_context.token, cls=ProxyTestState + ) + async with asyncio.timeout(1): + async with attached_mock_event_context.state_manager.modify_state(state_token): + pass + + @dataclasses.dataclass class TaggedModel: """A dataclass with a mutable attribute for attr-path refresh tests.""" From ec45a144d368afb835f6c32a6814646133dfa1d3 Mon Sep 17 00:00:00 2001 From: Farhan Date: Sat, 17 Oct 2026 00:40:57 +0500 Subject: [PATCH 3/5] docs: scope the rehydrate news to the loaded state tree --- news/+background-task-expired-state-rehydrate.bugfix.md | 2 +- .../news/+background-task-expired-state-rehydrate.bugfix.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/news/+background-task-expired-state-rehydrate.bugfix.md b/news/+background-task-expired-state-rehydrate.bugfix.md index b98e8becffa..0f2042a7cac 100644 --- a/news/+background-task-expired-state-rehydrate.bugfix.md +++ b/news/+background-task-expired-state-rehydrate.bugfix.md @@ -1 +1 @@ -A background task that finishes after its session expired now rehydrates the page, so the browser no longer keeps stale values from the expired session. +A background task that re-enters an expired session now rehydrates the loaded state tree, so the browser no longer shows stale values for those states. diff --git a/packages/reflex-base/news/+background-task-expired-state-rehydrate.bugfix.md b/packages/reflex-base/news/+background-task-expired-state-rehydrate.bugfix.md index 7b0fbd66636..b2d12ce428c 100644 --- a/packages/reflex-base/news/+background-task-expired-state-rehydrate.bugfix.md +++ b/packages/reflex-base/news/+background-task-expired-state-rehydrate.bugfix.md @@ -1 +1 @@ -A background task that re-enters an expired session runs the same rehydrate step as a foreground event, so the page drops values from the expired session and keeps its route and browser storage. +A background task that re-enters an expired session runs the same rehydrate step as a foreground event. The browser drops stale values of the loaded states and keeps its route and browser storage. From 1792b36f9eca03af8f4fd9a7329783cad8abf8bc Mon Sep 17 00:00:00 2001 From: Farhan Date: Sat, 17 Oct 2026 00:56:37 +0500 Subject: [PATCH 4/5] test: disable oplock when simulating Redis expiry Oplock keeps the tree cached in the process, so deleting the Redis keys does not expire it. --- .../reflex_base/event/processor/test_base_state_processor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/units/reflex_base/event/processor/test_base_state_processor.py b/tests/units/reflex_base/event/processor/test_base_state_processor.py index 4926ec44ac1..be42b887f44 100644 --- a/tests/units/reflex_base/event/processor/test_base_state_processor.py +++ b/tests/units/reflex_base/event/processor/test_base_state_processor.py @@ -558,6 +558,9 @@ def set_child(self): ) assert real_base_state_processor._root_context is not None state_manager = real_base_state_processor._root_context.state_manager + if isinstance(state_manager, StateManagerRedis): + # Oplock keeps the tree cached in the process, so deleting keys does not expire it. + state_manager._oplock_enabled = False async with real_base_state_processor as processor: await (await processor.enqueue(token, set_child)) await processor.enqueue(token, bg) From 95148bc18c5f3f85748ad35778cf6daaa48b59ca Mon Sep 17 00:00:00 2001 From: Farhan Date: Mon, 19 Oct 2026 14:32:03 +0500 Subject: [PATCH 5/5] fix: keep a background rehydrate from loading the page the client left The background task's route is the one it started on. After the tree expires, the client may be on another page, so the rehydrate stays routeless and the client's next event loads its current page. --- ...und-task-expired-state-rehydrate.bugfix.md | 2 +- reflex/istate/proxy.py | 10 +-- .../processor/test_base_state_processor.py | 81 ++++++++++++++++++- 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/packages/reflex-base/news/+background-task-expired-state-rehydrate.bugfix.md b/packages/reflex-base/news/+background-task-expired-state-rehydrate.bugfix.md index b2d12ce428c..329bd57cafe 100644 --- a/packages/reflex-base/news/+background-task-expired-state-rehydrate.bugfix.md +++ b/packages/reflex-base/news/+background-task-expired-state-rehydrate.bugfix.md @@ -1 +1 @@ -A background task that re-enters an expired session runs the same rehydrate step as a foreground event. The browser drops stale values of the loaded states and keeps its route and browser storage. +A background task that re-enters an expired session now rehydrates the loaded states. The browser drops their stale values and keeps its route and browser storage; its next event runs the current page's on_load. diff --git a/reflex/istate/proxy.py b/reflex/istate/proxy.py index e11395392d1..67abb9e73b9 100644 --- a/reflex/istate/proxy.py +++ b/reflex/istate/proxy.py @@ -275,14 +275,12 @@ async def __aenter__(self) -> Self: from reflex.state import State if type(mutable_state) is State and not mutable_state.router_data: - # The tree expired while the lock was released: restore it the - # way a foreground event does, so the client drops stale values. + # The tree expired while the lock was released: rehydrate it, so + # the client drops stale values. The task's route may be a page + # the client left, so the tree stays routeless and the client's + # next event loads its current page. from reflex_base.event.processor import BaseStateEventProcessor - if ctx.router_data: - mutable_state.router_data = mutable_state._update_router_vars( - ctx.router_data, {} - ) await BaseStateEventProcessor._rehydrate(mutable_state) except (Exception, asyncio.CancelledError): # Restore the proxy to a consistent state since __aexit__ will not be called when __aenter__ raises. diff --git a/tests/units/reflex_base/event/processor/test_base_state_processor.py b/tests/units/reflex_base/event/processor/test_base_state_processor.py index be42b887f44..b1613881257 100644 --- a/tests/units/reflex_base/event/processor/test_base_state_processor.py +++ b/tests/units/reflex_base/event/processor/test_base_state_processor.py @@ -579,15 +579,90 @@ def set_child(self): # Not hydrated, so the browser does not overwrite its client storage. assert snapshot[root_name][is_hydrated_key] is False assert snapshot[child_name]["child" + FIELD_MARKER] == 0 + # Routeless: the client's next event sets its current page. page = snapshot[root_name][constants.ROUTER_PAGE + FIELD_MARKER] - assert page.raw_path == "/post/abc" - session = snapshot[root_name][constants.ROUTER_SESSION + FIELD_MARKER] - assert session.client_token == token + assert page.raw_path == "" assert emitted_deltas[-1][1][ExpiryState.get_full_name()] == { "value" + FIELD_MARKER: 100 } +@pytest.mark.parametrize( + "processor_state_manager", ["in_process", "redis"], indirect=True +) +async def test_background_rehydrate_does_not_load_the_route_the_client_left( + wired_app: App, + real_base_state_processor: BaseStateEventProcessor, + token: str, +): + """A tree that expires during a background task does not reload the task's old page. + + Args: + wired_app: The App wired to the processor's state manager. + real_base_state_processor: The unmocked BaseStateEventProcessor. + token: The client token. + """ + waiting = asyncio.Event() + release = asyncio.Event() + + class LeftPageState(State): + loaded: list[str] = [] + + @event + def load_a(self): + self.loaded = [*self.loaded, "a"] + + @event + def load_b(self): + self.loaded = [*self.loaded, "b"] + + @event + def ping(self): + pass + + @event(background=True) + async def bg(self): + waiting.set() + await release.wait() + async with self: + pass + + wired_app.add_page( + lambda: rx.text("a"), route="/page-a", on_load=LeftPageState.load_a + ) + wired_app.add_page( + lambda: rx.text("b"), route="/page-b", on_load=LeftPageState.load_b + ) + assert real_base_state_processor._root_context is not None + state_manager = real_base_state_processor._root_context.state_manager + if isinstance(state_manager, StateManagerRedis): + # Oplock keeps the tree cached in the process, so deleting keys does not expire it. + state_manager._oplock_enabled = False + + async with real_base_state_processor as processor: + await processor.enqueue( + token, _client_event(LeftPageState.bg(), _view("/page-a")) + ) + await asyncio.wait_for(waiting.wait(), timeout=2) + # The client moves to /page-b, then the tree expires. + await _send( + processor, token, _client_event(LeftPageState.ping(), _view("/page-b")) + ) + _expire_token(state_manager, token) + release.set() + await processor.join(5) + # The client's next event on /page-b. + await _send( + processor, token, _client_event(LeftPageState.ping(), _view("/page-b")) + ) + + async with _read_back(real_base_state_processor, token) as root: + assert (await root.get_state(LeftPageState)).loaded == ["b"], ( + "the background rehydrate loaded the route the client had left" + ) + assert root.router.url.path == "/page-b" + + async def test_background_yield_inside_context_flushes_delta_before_event( wired_app: App, real_base_state_processor: BaseStateEventProcessor,