From b165793b3887005a3587df7795b8322ce38f228a Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Mon, 2 Mar 2026 00:06:15 +0100 Subject: [PATCH 1/2] fix: unwrap MutableProxy values in _mark_dirty before writing --- reflex/istate/proxy.py | 39 +++++ tests/units/istate/test_proxy.py | 235 ++++++++++++++++++++++++++++++- 2 files changed, 273 insertions(+), 1 deletion(-) diff --git a/reflex/istate/proxy.py b/reflex/istate/proxy.py index 80c3f83e1fe..b8fca3d95a1 100644 --- a/reflex/istate/proxy.py +++ b/reflex/istate/proxy.py @@ -454,6 +454,42 @@ def __repr__(self) -> str: """ return f"{type(self).__name__}({self.__wrapped__})" + @staticmethod + def _unwrap_proxy_arg(value: Any) -> Any: + """Unwrap MutableProxy instances from a value before writing. + + Proxies are read-path wrappers that must not leak into underlying + data structures. Handles scalars and common containers (list, tuple, + set, dict) whose elements may be proxies. + + Args: + value: The value to unwrap. + + Returns: + The unwrapped value. + """ + if isinstance(value, MutableProxy): + return value.__wrapped__ + if isinstance(value, list): + return [ + item.__wrapped__ if isinstance(item, MutableProxy) else item + for item in value + ] + if isinstance(value, (tuple, set)): + unwrapped = ( + item.__wrapped__ if isinstance(item, MutableProxy) else item + for item in value + ) + return type(value)(unwrapped) + if isinstance(value, dict): + return { + (k.__wrapped__ if isinstance(k, MutableProxy) else k): ( + v.__wrapped__ if isinstance(v, MutableProxy) else v + ) + for k, v in value.items() + } + return value + def _mark_dirty( self, wrapped: Callable | None = None, @@ -477,6 +513,9 @@ def _mark_dirty( self._self_state.dirty_vars.add(self._self_field_name) self._self_state._mark_dirty() if wrapped is not None: + args = tuple(self._unwrap_proxy_arg(a) for a in args) + if kwargs: + kwargs = {k: self._unwrap_proxy_arg(v) for k, v in kwargs.items()} return wrapped(*args, **(kwargs or {})) return None diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index b71d91e619d..01749034de3 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -1,4 +1,4 @@ -"""Tests for MutableProxy pickle behavior.""" +"""Tests for MutableProxy behavior.""" import dataclasses import pickle @@ -35,3 +35,236 @@ def test_mutable_proxy_pickle_preserves_object_identity(): assert unpickled["direct"][0].id == 1 assert unpickled["proxied"][0].id == 1 assert unpickled["direct"][0] is unpickled["proxied"][0] + + +class UnwrapListState(rx.State): + """Test state for list unwrap tests.""" + + data: list[dict[str, int]] = [] + + +class UnwrapDictState(rx.State): + """Test state for dict unwrap tests.""" + + data: dict[str, dict[str, int]] = {} + + +class UnwrapSetState(rx.State): + """Test state for set unwrap tests.""" + + data: set[int] = set() + + +def test_append_unwraps_proxy(): + """Appending a proxy-wrapped value must store the unwrapped original.""" + state = UnwrapListState() + state.data = [{"a": 1}] + + proxy = state.data + assert isinstance(proxy, MutableProxy) + + # Iterate to get a proxy-wrapped element + items = list(proxy) + assert len(items) == 1 + assert isinstance(items[0], MutableProxy) + + # Append the proxy-wrapped item back + proxy.append(items[0]) + + # The underlying list must contain raw dicts, not proxies + underlying: list[dict[str, int]] = object.__getattribute__(proxy, "__wrapped__") + assert len(underlying) == 2 + for item in underlying: + assert not isinstance(item, MutableProxy), ( + f"Proxy leaked into underlying list: {type(item)}" + ) + assert underlying[0] is underlying[1] # same object identity + + +def test_setitem_unwraps_proxy(): + """Setting an item via __setitem__ must unwrap proxy values.""" + state = UnwrapListState() + state.data = [{"a": 1}, {"b": 2}] + + proxy = state.data + assert isinstance(proxy, MutableProxy) + + # Get proxy-wrapped element via __getitem__ + item = proxy[0] + assert isinstance(item, MutableProxy) + + # Assign it to a different index + proxy[1] = item + + underlying: list[dict[str, int]] = object.__getattribute__(proxy, "__wrapped__") + assert not isinstance(underlying[1], MutableProxy) + assert underlying[0] is underlying[1] + + +def test_extend_unwraps_proxies(): + """Extending with a list of proxy-wrapped values must unwrap each.""" + state = UnwrapListState() + state.data = [{"a": 1}, {"b": 2}] + + proxy = state.data + # Collect proxy-wrapped elements via iteration + wrapped_items = list(proxy) + assert all(isinstance(item, MutableProxy) for item in wrapped_items) + + # Extend with the wrapped items + proxy.extend(wrapped_items) + + underlying: list[dict[str, int]] = object.__getattribute__(proxy, "__wrapped__") + assert len(underlying) == 4 + for item in underlying: + assert not isinstance(item, MutableProxy), ( + f"Proxy leaked into underlying list via extend: {type(item)}" + ) + + +def test_insert_unwraps_proxy(): + """Inserting a proxy-wrapped value must unwrap it.""" + state = UnwrapListState() + state.data = [{"a": 1}] + + proxy = state.data + item = proxy[0] + assert isinstance(item, MutableProxy) + + proxy.insert(0, item) + + underlying: list[dict[str, int]] = object.__getattribute__(proxy, "__wrapped__") + assert len(underlying) == 2 + assert not isinstance(underlying[0], MutableProxy) + + +def test_dict_setitem_unwraps_proxy(): + """Setting a dict value via __setitem__ must unwrap proxy values.""" + state = UnwrapDictState() + state.data = {"key": {"a": 1}} + + proxy = state.data + assert isinstance(proxy, MutableProxy) + + value = proxy["key"] + assert isinstance(value, MutableProxy) + + proxy["other"] = value + + underlying: dict[str, dict[str, int]] = object.__getattribute__( + proxy, "__wrapped__" + ) + assert not isinstance(underlying["other"], MutableProxy) + assert underlying["key"] is underlying["other"] + + +def test_iterate_append_does_not_cause_infinite_growth(): + """Iterating + appending proxied values must not grow the list unboundedly.""" + state = UnwrapListState() + state.data = [{"a": 1}] + + proxy = state.data + original_len = 1 + + # Iterate and append each item once + for item in list(proxy): # snapshot via list() to avoid mutation during iter + proxy.append(item) + + underlying: list[dict[str, int]] = object.__getattribute__(proxy, "__wrapped__") + assert len(underlying) == original_len * 2 + for item in underlying: + assert not isinstance(item, MutableProxy) + + +def test_setattr_unwraps_proxy(): + """Setting an attribute on a proxied object must unwrap proxy values.""" + + @dataclasses.dataclass + class Container: + items: list[int] = dataclasses.field(default_factory=list) + + class ContainerState(rx.State): + container: Container = Container(items=[1, 2, 3]) + + state = ContainerState() + proxy = state.container + assert isinstance(proxy, MutableProxy) + + # Get the items attribute (will be wrapped) + items = proxy.items + assert isinstance(items, MutableProxy) + + # Assign it back via setattr + proxy.items = items + + underlying: Container = object.__getattribute__(proxy, "__wrapped__") + assert not isinstance(underlying.items, MutableProxy) + + +def test_unwrap_proxy_arg_passthrough(): + """Non-proxy, non-container values pass through unchanged.""" + assert MutableProxy._unwrap_proxy_arg(42) == 42 + assert MutableProxy._unwrap_proxy_arg("hello") == "hello" + assert MutableProxy._unwrap_proxy_arg(None) is None + assert MutableProxy._unwrap_proxy_arg(3.14) == 3.14 + + +def test_unwrap_proxy_arg_tuple(): + """Tuples containing proxies are unwrapped element-wise.""" + state = UnwrapListState() + obj1, obj2 = {"a": 1}, {"b": 2} + p1 = MutableProxy(obj1, state, "data") + p2 = MutableProxy(obj2, state, "data") + + result = MutableProxy._unwrap_proxy_arg((p1, p2)) + assert isinstance(result, tuple) + assert result[0] is obj1 + assert result[1] is obj2 + + +def test_unwrap_proxy_arg_set(): + """Sets containing proxies are unwrapped element-wise.""" + state = UnwrapSetState() + p1 = MutableProxy(1, state, "data") + p2 = MutableProxy(2, state, "data") + + result = MutableProxy._unwrap_proxy_arg({p1, p2}) + assert isinstance(result, set) + assert result == {1, 2} + + +def test_unwrap_proxy_arg_dict(): + """Dict keys and values that are proxies are unwrapped.""" + state = UnwrapListState() + key = MutableProxy("k", state, "data") + val = MutableProxy({"a": 1}, state, "data") + + result = MutableProxy._unwrap_proxy_arg({key: val}) + assert isinstance(result, dict) + assert "k" in result + assert result["k"] is object.__getattribute__(val, "__wrapped__") + + +def test_dirty_tracking_preserved_after_unwrap(): + """Mutations via proxy must still mark the state dirty after the fix.""" + state = UnwrapListState() + state.data = [{"a": 1}] + state._clean() + assert not state.dirty_vars + + # append via proxy should still mark dirty + proxy = state.data + proxy.append({"b": 2}) + assert "data" in state.dirty_vars + + state._clean() + + # __setitem__ via proxy should still mark dirty + proxy[0] = {"c": 3} + assert "data" in state.dirty_vars + + state._clean() + + # nested mutation via proxy should still mark dirty + proxy[0]["d"] = 4 + assert "data" in state.dirty_vars From 18488dddcb47a79afd1804405ece60a3249e877f Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Mon, 2 Mar 2026 00:14:24 +0100 Subject: [PATCH 2/2] ruffing --- tests/units/istate/test_proxy.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/units/istate/test_proxy.py b/tests/units/istate/test_proxy.py index 01749034de3..1ddd51f4cd3 100644 --- a/tests/units/istate/test_proxy.py +++ b/tests/units/istate/test_proxy.py @@ -1,6 +1,7 @@ """Tests for MutableProxy behavior.""" import dataclasses +import math import pickle import reflex as rx @@ -206,7 +207,7 @@ def test_unwrap_proxy_arg_passthrough(): assert MutableProxy._unwrap_proxy_arg(42) == 42 assert MutableProxy._unwrap_proxy_arg("hello") == "hello" assert MutableProxy._unwrap_proxy_arg(None) is None - assert MutableProxy._unwrap_proxy_arg(3.14) == 3.14 + assert MutableProxy._unwrap_proxy_arg(math.pi) == math.pi def test_unwrap_proxy_arg_tuple():