Skip to content

Notify observers on in-place operators of observable collections - #7086

Open
joaoaracheski wants to merge 10 commits into
flet-dev:mainfrom
joaoaracheski:fix-observable-inplace-operators
Open

joaoaracheski wants to merge 10 commits into
flet-dev:mainfrom
joaoaracheski:fix-observable-inplace-operators

Conversation

@joaoaracheski

@joaoaracheski joaoaracheski commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Description

Fixes #6293.

ObservableList overrides the mutating methods (append, extend, insert...) to notify observers, but not the in-place operators. self.users += [x] calls the built-in list.__iadd__, which mutates the list without notifying; Python then assigns the same object back to the attribute, and Observable.__setattr__ sees no change (same object) and doesn't notify either. So the state changes but the UI isn't refreshed, while self.users.append(x) works.

The same happens with *= on lists and |= on dicts. This PR adds:

  • ObservableList.__iadd__ (+=): delegates to extend(), so items are wrapped and observers notified;
  • ObservableList.__imul__ (*=): repeats in place and notifies;
  • ObservableDict.__ior__ (|=): delegates to update() and notifies.

__iadd__ takes a snapshot of the right-hand side first, so items += items doesn't iterate a list that is growing.

Test code

from dataclasses import dataclass, field

import flet as ft


@ft.observable
@dataclass
class App:
    users: list = field(default_factory=list)
    tags: dict = field(default_factory=dict)


app = App()
changes = []


def listener(sender, field):
    changes.append(field)


app.subscribe(listener)
app.users += [1]
app.users *= 2
app.tags |= {"a": 1}
print(changes)

Before: []
After: ['users', 'users', 'tags']

Added three tests to test_observable.py: test_list_inplace_operators_notify and test_dict_inplace_union_notifies fail without the fix and pass with it; test_list_iadd_with_itself guards the items += items case.

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Checklist

  • I signed the CLA.
  • I have performed a self-review of my own code.
  • My code follows the style guidelines of this project.
  • I have commented my code, particularly in hard-to-understand areas.
  • My changes generate no new warnings.
  • New and existing tests pass locally with my changes.
  • I have made corresponding documentation changes, if applicable.
  • I have added changelog entries for user-facing changes, if applicable.
  • I have updated release guide pages and website/sidebars.yml for breaking changes, removals, and deprecations, if applicable.

Additional details

uv run --group test pytest packages/flet/tests: 509 passed, 8 skipped. ruff check and ruff format --check are clean on the changed files.

#6225 (use_state with an updater that mutates in place and returns the same object) is related but not changed here: it follows the same rule as React's useState, so I left it out of this PR.

Suggested changelog entry under Bug fixes, if you'd like me to add it:

  • Fix @ft.observable not refreshing the UI on in-place operators (+=, *= on lists, |= on dicts) (#6293) by @joaoaracheski.

Summary by Sourcery

Fix observable collection mutations so in-place list and dictionary operators consistently refresh observers and handle nested updates correctly.

Bug Fixes:

  • Ensure observable lists and dictionaries notify observers for in-place operators such as +=, *=, and |=.
  • Prevent observable collection updates from missing nested changes or hanging when collections are merged with themselves.

Enhancements:

  • Improve observable collection mutation handling, write-back behavior, and notification consistency while preserving native collection semantics.

Documentation:

  • Document notification behavior and field replacement considerations for augmented assignments on observable collections.

Tests:

  • Expand observable collection coverage for in-place operators, nested mutations, self-extension, lazy and partial updates, and notification edge cases.

Fixes flet-dev#6293. ObservableList now handles += and *=, and ObservableDict handles |=; before, these mutated the collection without notifying, and the following attribute assignment of the same object was treated as no change.
Copilot AI balanced review requested due to automatic review settings November 13, 2026 21:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

@ndonkoHenri

Copy link
Copy Markdown
Contributor

Thanks! Please add changelog entry.

@joaoaracheski

Copy link
Copy Markdown
Contributor Author

Done, added the changelog entry under 1.1.3. Thanks again :)

@FeodorFitsner FeodorFitsner left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for fixing #6293. The Python unit suite passed locally with 513 passed and 9 skipped. I left inline suggestions for preserving collection behavior and notifying once for nested operations, plus a documentation suggestion for synchronous subscribers.

one copy of the list's items.
"""

super().extend([self._wrap(v) for v in it])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we keep extend() incremental for ordinary iterables and special-case direct self-extension? Use super().extend(self) when it is self, and keep super().extend(self._wrap(value) for value in it) otherwise. The built-in self-extension preserves the references of existing entries, including plain nested lists from the field initializer, and += can keep delegating to extend(). Please cover a generator that reads items[-1] between yields and a self-extension test that checks the duplicated nested item with is.

"""

super().update(*a, **{kk: self._wrap(vv) for kk, vv in k.items()})
super().update({kk: self._wrap(vv) for kk, vv in dict(*a, **k).items()})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we wrap each incoming value as it is inserted? The temporary dict fully consumes the input before changing the destination. For example, a generator that yields two new keys with len(mapping) as their value sees the same length twice with this implementation. Keep dict.update()'s argument handling by consuming either a mapping's keys or an iterable of pairs incrementally, applying wrapped keyword values last, and notifying once after a successful merge. |= can keep delegating to update(). A generator that reads the destination between yields would make a good regression test.


super().extend(self._wrap(v) for v in it)
super().__imul__(n)
self._touch()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A nested augmented assignment currently notifies twice. For example, with a wrapped list at app.mapping["items"], app.mapping["items"] *= 2 notifies from the inner list, then again when Python writes it back through the outer dict's __setitem__. One option is to treat writing the identical observable child back into the same slot as a no-op when both wrappers report to the same owner and field. Apply the list guard to single-index writes only. Please cover nested +=, *=, and |= and the consequent no-op for explicit same-object assignment.

Extend list in place (`+=`) and notify observers.
"""

self.extend(other)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These in-place methods notify synchronously before Python writes the returned object back. I verified that a subscriber which replaces app.items with [99] during app.items += [2] has its replacement overwritten by the write-back. Please document this ordering and mention that direct mutator calls such as app.items.extend([2]) preserve a synchronous subscriber's replacement. Preserving the replacement through augmented assignment can be a follow-up since deferring notification changes callback timing.

…ment notification ordering

- extend() appends one item at a time again; self-extension uses list.extend(self) to keep references.
- update() inserts entries one at a time, wraps values as they are inserted and notifies once.
- Writing the same observable child back into its slot (nested +=, *=, |=) no longer notifies twice.
- Document that augmented assignments notify before Python writes the result back.
@joaoaracheski

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review! Addressed all four points: extend() is incremental again with a special case for self-extension, update() inserts entries one at a time and notifies once, writing the same observable child back into its slot is now a no-op, and the notification ordering is documented on @ft.observable. Added tests for each case.

I learned a lot from this review. Glad to contribute to Flet!

ObservableList.__setitem__ checks whether the value being written is the
item's own observable child, which Python writes back after an augmented
assignment such as `xs[0] += [1]`. That check read the current item with
the raw index, and the assignment then converted the index again. An index
object whose `__index__()` has side effects was therefore called twice and
could target a different item: with `[10, 20]`, `xs[index] = 99` produced
`[10, 99]` where a plain list produces `[99, 20]` (found in a Codex review).

The index is now converted once with `operator.index()` and that integer is
used for both the check and the assignment. Indexes that aren't integers
(e.g. a str or a float) still go straight to `list.__setitem__`, so they
raise the same TypeError, with the same message, as a plain list; an
out-of-range index raises the same IndexError.

Tests:
- `test_set_item_reads_custom_index_once`: `__index__()` is called once and
  the right item changes (fails before this change).
- `test_update_reads_mapping_keys`: `update()` takes a mapping's entries from
  `keys()`, as `dict.update()` does, and wraps the inserted values.
- `test_merge_wraps_nested_values` gains an `ior-pairs` case: `|=` with an
  iterable of key-value pairs.
ObservableList and ObservableDict each had identical copies of `_touch()`
(notify the owner under the field name) and `_wrap()` (wrap a nested list
or dict before storing it), and the same-child write-back check lived in a
module-level `_is_written_back(container, current, value)` function that
both classes called.

They now inherit from a private `_ObservableCollection` base class that
holds all three:
- `_touch()` and `_wrap()` move there unchanged;
- `_is_written_back(current, value)` becomes a regular method, since it
  compares the value against the container's own owner and field, so the
  `container` argument is gone;
- `isinstance(value, _ObservableCollection)` replaces the check against
  the two concrete types.

The base class declares empty `__slots__` (plus type hints for
`_owner_ref` and `_field`), and each subclass keeps its own `__init__` and
`__slots__`, so instances still have no `__dict__`, the constructors keep
their signatures (`iterable=` / `mapping=`), and `isinstance(x, list)` /
`isinstance(x, dict)` still hold.

No behavior change: the existing observable tests (nested in-place
operators, same-child write-backs, custom index, notifications) pass
unchanged, and pyright and mypy report the same diagnostics on the file
as before.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: UI not updating immediately when using self.users += [...] in observable dataclass

4 participants