fix(istate): mark state vars dirty on in-place operators
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.