fix(istate): mark state vars dirty on in-place operators

DE
6d ago
+182-0

What's wrong

Mutating a list, dict or set state var with an augmented assignment operator (+=, *=, |=, &=, -=, ^=) does not mark the field dirty when the proxy is not immediately re-assigned:

class AliasState(rx.State):
    items: list[str] = ["a"]

    def add_via_alias(self):
        items = self.items
        items += ["b"]

    def add_direct(self):
        self.items += ["b"]
after alias  +=: ['a', 'b'] dirty: []  delta: {}
after direct +=: ['a', 'b'] dirty: ['items']  delta keys: ['items_rx_state_']

The backend list really changes in both cases, but only the second one reaches the frontend. self.items += [...] looks fine purely because the implicit re-assignment goes through __setattr__, which marks the field dirty. Hold the proxy in a local, or let the operation happen inside a call (some(state.items) doing +=), and the write is lost with no exception and no log.

The same gap applies to state.mapping |= {...} and state.selected |= {...}.

Why

MutableProxy marks a field dirty by wrapping the named mutating methods (append, update, add, ...) listed in __mark_dirty_attrs__ and installing a wrapt.FunctionWrapper from __getattr__. Augmented assignment never goes through __getattr__: CPython resolves __iadd__ on the type, so it lands on wrapt.ObjectProxy, which mutates the wrapped object and returns the proxy without calling _mark_dirty.

That also means an ImmutableMutableProxy, handed out for state access outside a mutable async context, mutates state silently instead of raising ImmutableStateError the way every named mutating method does.

Fix

Implement __iadd__, __imul__, __ior__, __iand__, __isub__ and __ixor__ on MutableProxy and route each through _mark_dirty(super().__ixx__, args=(other,)) - the same shape as the existing __setitem__, __delitem__ and __setattr__. The return value is still the proxy, so a local alias keeps being tracked after the operation:

after  type: MutableProxy
append tracked after +=: {'items'} [1, 2, 3]

Tests

tests/units/istate/test_proxy.py covers the list operators, the four set operators, dict |=, and the immutable-proxy case (which now raises ImmutableStateError and leaves the state untouched).

Against the previous proxy.py, with only the new tests applied:

FAILED tests/units/istate/test_proxy.py::test_mutable_proxy_inplace_list_operations_mark_dirty
FAILED tests/units/istate/test_proxy.py::test_mutable_proxy_inplace_set_operations_mark_dirty
FAILED tests/units/istate/test_proxy.py::test_mutable_proxy_inplace_dict_operations_mark_dirty
FAILED tests/units/istate/test_proxy.py::test_immutable_mutable_proxy_inplace_operations_raise
4 failed, 98 passed

After the fix:

102 passed                                    # tests/units/istate/test_proxy.py
259 passed                                    # tests/units/istate

news/+proxy-inplace-operator-dirty.bugfix.md added as CONTRIBUTING asks.

Local checks: ruff check and ruff format --check are clean on both changed files (the one unconventional-import-alias warning in test_proxy.py:1324 is pre-existing). pyright could not be installed in this environment, so it was not run locally.