From 915dd921c5668b82b2af040e48684a1a2b2dc82c Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Tue, 4 Aug 2026 16:28:48 +0530 Subject: [PATCH 1/5] Avoid root dirty delta for shared state events --- news/6392.bugfix.md | 1 + reflex/istate/shared.py | 8 ++++++++ tests/units/test_state.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 news/6392.bugfix.md diff --git a/news/6392.bugfix.md b/news/6392.bugfix.md new file mode 100644 index 00000000000..755e5824602 --- /dev/null +++ b/news/6392.bugfix.md @@ -0,0 +1 @@ +Avoid leaking temporary router dirtiness into linked/shared state event deltas. diff --git a/reflex/istate/shared.py b/reflex/istate/shared.py index 41b1f519cd3..dc95808b421 100644 --- a/reflex/istate/shared.py +++ b/reflex/istate/shared.py @@ -102,11 +102,19 @@ async def _patch_state( linked_state.dirty_vars.update(linked_state.computed_vars) linked_state._mark_dirty() # Apply the updates into the existing state tree for rehydrate. + # For regular linked-state events this router dirtiness is temporary: + # it forces router-dependent computed vars to resolve for the patched + # tree, but should not leak into the event's final delta. root_state = original_state._get_root_state() + root_dirty_vars = set(root_state.dirty_vars) + root_dirty_substates = set(root_state.dirty_substates) root_state.dirty_vars.add("router") root_state.dirty_vars.add(ROUTER_DATA) root_state._mark_dirty() await root_state._get_resolved_delta() + if not full_delta: + root_state.dirty_vars = root_dirty_vars + root_state.dirty_substates = root_dirty_substates yield finally: original_parent_state.substates[state_name] = original_state diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 7106287a43d..7f965bac4ac 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -922,6 +922,41 @@ def test_setting_inherited_backend_var_does_not_mark_child_touched( assert not child_touched +class _LinkedStatePatchRoot(BaseState): + """Root state for testing linked-state dirty propagation.""" + + +class _LinkedStatePatchShared(_LinkedStatePatchRoot): + """Substate used to exercise _patch_state without full SharedState setup.""" + + counter: int = 0 + + +@pytest.mark.asyncio +async def test_linked_state_event_does_not_dirty_root_state(): + """Linked-state events should not leak temporary router dirtiness.""" + from reflex.istate.shared import _patch_state + + private_tree = _LinkedStatePatchRoot() + linked_tree = _LinkedStatePatchRoot() + + shared_state_name = _LinkedStatePatchShared.get_name() + private_state = private_tree.substates[shared_state_name] + linked_state = linked_tree.substates[shared_state_name] + + assert isinstance(private_state, _LinkedStatePatchShared) + assert isinstance(linked_state, _LinkedStatePatchShared) + + private_tree._clean() + + async with _patch_state(private_state, linked_state, full_delta=False): + linked_state.counter = 1 + + assert "router" not in private_tree.dirty_vars + assert constants.ROUTER_DATA not in private_tree.dirty_vars + assert private_tree.get_full_name() not in private_tree.get_delta() + + @pytest.mark.asyncio async def test_process_event_simple( token: str, From 63b081b12bcb20e8013c87f5a9aa80ebf9573bd1 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Tue, 4 Aug 2026 16:55:16 +0530 Subject: [PATCH 2/5] Handle linked state patch resolve failures --- news/{6392.bugfix.md => 6841.bugfix.md} | 0 reflex/istate/shared.py | 10 +++--- tests/units/test_state.py | 41 +++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) rename news/{6392.bugfix.md => 6841.bugfix.md} (100%) diff --git a/news/6392.bugfix.md b/news/6841.bugfix.md similarity index 100% rename from news/6392.bugfix.md rename to news/6841.bugfix.md diff --git a/reflex/istate/shared.py b/reflex/istate/shared.py index dc95808b421..47d12976ebb 100644 --- a/reflex/istate/shared.py +++ b/reflex/istate/shared.py @@ -111,10 +111,12 @@ async def _patch_state( root_state.dirty_vars.add("router") root_state.dirty_vars.add(ROUTER_DATA) root_state._mark_dirty() - await root_state._get_resolved_delta() - if not full_delta: - root_state.dirty_vars = root_dirty_vars - root_state.dirty_substates = root_dirty_substates + try: + await root_state._get_resolved_delta() + finally: + if not full_delta: + root_state.dirty_vars = root_dirty_vars + root_state.dirty_substates = root_dirty_substates yield finally: original_parent_state.substates[state_name] = original_state diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 7f965bac4ac..83caf03b3cc 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -925,6 +925,8 @@ def test_setting_inherited_backend_var_does_not_mark_child_touched( class _LinkedStatePatchRoot(BaseState): """Root state for testing linked-state dirty propagation.""" + value: int = 0 + class _LinkedStatePatchShared(_LinkedStatePatchRoot): """Substate used to exercise _patch_state without full SharedState setup.""" @@ -957,6 +959,45 @@ async def test_linked_state_event_does_not_dirty_root_state(): assert private_tree.get_full_name() not in private_tree.get_delta() +@pytest.mark.asyncio +async def test_linked_state_patch_restores_root_dirty_state_on_resolve_error( + monkeypatch: pytest.MonkeyPatch, +): + """Temporary root dirtiness should be cleaned if delta resolution fails.""" + from reflex.istate.shared import _patch_state + + private_tree = _LinkedStatePatchRoot() + linked_tree = _LinkedStatePatchRoot() + + shared_state_name = _LinkedStatePatchShared.get_name() + private_state = private_tree.substates[shared_state_name] + linked_state = linked_tree.substates[shared_state_name] + + assert isinstance(private_state, _LinkedStatePatchShared) + assert isinstance(linked_state, _LinkedStatePatchShared) + + private_tree.value = 1 + private_tree.dirty_substates.add("existing") + original_dirty_vars = set(private_tree.dirty_vars) + original_dirty_substates = set(private_tree.dirty_substates) + + async def raise_resolve_error(): + await asyncio.sleep(0) + msg = "delta resolution failed" + raise RuntimeError(msg) + + object.__setattr__(private_tree, "_get_resolved_delta", raise_resolve_error) + + with pytest.raises(RuntimeError, match="delta resolution failed"): + async with _patch_state(private_state, linked_state, full_delta=False): + pass + + assert private_tree.dirty_vars == original_dirty_vars + assert private_tree.dirty_substates == original_dirty_substates + assert private_tree.substates[shared_state_name] is private_state + assert linked_state.parent_state is linked_tree + + @pytest.mark.asyncio async def test_process_event_simple( token: str, From c19e5a2c8389ea87dc6c5282b2a2571ff89f8142 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Tue, 4 Aug 2026 17:01:49 +0530 Subject: [PATCH 3/5] Use valid news fragment type for Pillow pin --- news/{6836.chore.md => 6836.misc.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename news/{6836.chore.md => 6836.misc.md} (100%) diff --git a/news/6836.chore.md b/news/6836.misc.md similarity index 100% rename from news/6836.chore.md rename to news/6836.misc.md From 4a76d69c197944fae934a4bf9c7e74de9f26d5b2 Mon Sep 17 00:00:00 2001 From: Harsh Thakare Date: Sat, 8 Aug 2026 10:04:53 +0530 Subject: [PATCH 4/5] Restore descendant dirty state on linked patch errors --- reflex/istate/shared.py | 17 +++++++++++++++++ tests/units/test_state.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/reflex/istate/shared.py b/reflex/istate/shared.py index 47d12976ebb..52de59f5711 100644 --- a/reflex/istate/shared.py +++ b/reflex/istate/shared.py @@ -108,11 +108,28 @@ async def _patch_state( root_state = original_state._get_root_state() root_dirty_vars = set(root_state.dirty_vars) root_dirty_substates = set(root_state.dirty_substates) + dirty_state_snapshots: list[tuple[BaseState, set[str], set[str]]] = [] + if not full_delta: + states_to_snapshot = [root_state] + while states_to_snapshot: + state = states_to_snapshot.pop() + dirty_state_snapshots.append(( + state, + set(state.dirty_vars), + set(state.dirty_substates), + )) + states_to_snapshot.extend(state.substates.values()) root_state.dirty_vars.add("router") root_state.dirty_vars.add(ROUTER_DATA) root_state._mark_dirty() try: await root_state._get_resolved_delta() + except BaseException: + if not full_delta: + for state, dirty_vars, dirty_substates in dirty_state_snapshots: + state.dirty_vars = dirty_vars + state.dirty_substates = dirty_substates + raise finally: if not full_delta: root_state.dirty_vars = root_dirty_vars diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 83caf03b3cc..3d95a4ecf69 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -998,6 +998,36 @@ async def raise_resolve_error(): assert linked_state.parent_state is linked_tree +@pytest.mark.asyncio +async def test_linked_state_patch_restores_descendant_dirty_state_on_resolve_error(): + """Temporary descendant dirtiness should be cleaned on resolution failure.""" + from reflex.istate.shared import _patch_state + + private_tree = _LinkedStatePatchRoot() + linked_tree = _LinkedStatePatchRoot() + + shared_state_name = _LinkedStatePatchShared.get_name() + private_state = private_tree.substates[shared_state_name] + linked_state = linked_tree.substates[shared_state_name] + + async def raise_resolve_error(): + await asyncio.sleep(0) + linked_state.dirty_vars.add("temporary") + linked_state._mark_dirty() + msg = "descendant delta resolution failed" + raise RuntimeError(msg) + + object.__setattr__(private_tree, "_get_resolved_delta", raise_resolve_error) + + with pytest.raises(RuntimeError, match="descendant delta resolution failed"): + async with _patch_state(private_state, linked_state, full_delta=False): + pass + + assert linked_state.dirty_vars == set() + assert linked_state.dirty_substates == set() + assert private_tree.dirty_substates == set() + + @pytest.mark.asyncio async def test_process_event_simple( token: str, From de329806a6a1a7ebe4a1f1fb17e56ad35cbcf3b5 Mon Sep 17 00:00:00 2001 From: HARSH THAKARE <151563948+harsh21234i@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:20:20 +0530 Subject: [PATCH 5/5] Remove unrelated Pillow news fragment --- news/6836.misc.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 news/6836.misc.md diff --git a/news/6836.misc.md b/news/6836.misc.md deleted file mode 100644 index 07089e2e605..00000000000 --- a/news/6836.misc.md +++ /dev/null @@ -1 +0,0 @@ -Update dev pin Pillow==12.3.0 to avoid various CVE reports