Skip to content

ObservableCollection leaks change handlers when observable items are removed or replaced #6139

Description

@falkoschindler

First Check

  • I added a very descriptive title here.
  • This is not a security issue (those should be reported via the security advisory instead).
  • This is not a Q&A. I am sure something is wrong with NiceGUI or its documentation.
  • I used the GitHub search to find a similar issue and came up empty.

Example Code

from nicegui.observables import ObservableList

items = ObservableList([{}], on_change=lambda: print('change'))
for _ in range(3):
    items[:] = list(items)  # prints "change" 3 times in total

items[0]['x'] = 1  # prints "change" 4 times instead of once

Description

ObservableCollection._observe registers the container's _handle_change on any item that is already an ObservableCollection (observables.py, lines 45-47), and no removal or replacement operation (remove, pop, del, clear, item assignment) ever deregisters it. Handlers therefore accumulate:

  • Rebuilding a list in place (lst[:] = [...]) wraps the incoming plain list in a temporary ObservableList whose __init__ re-observes every surviving item. The temporary list is discarded immediately, but it stays reachable via the bound _handle_change handlers it left behind — one extra handler per rebuild, forever.
  • Plain re-assignment (options['series'] = [...]) behaves the same way: the replacement list re-observes the surviving items, and the handlers pointing at the discarded old list remain registered.
  • After N rebuilds, a single mutation of a surviving item fires N+1 change events instead of one (i.e. N+1× element.update() when the collection backs an element's props), and none of the discarded containers can be garbage-collected.

This hits the common pattern of updating element options by rebuilding a list, e.g. chart.options['series'] = [s for s in chart.options['series'] if ...] on ui.highchart/ui.echart/ui.aggrid. A long-running app that does this repeatedly accumulates handlers and dead containers without bound. (The "Adding and removing series" demo added in #6138 originally used this pattern; it now uses list.remove as a workaround.)

A related wart with the same root cause: re-inserting an item into a collection it already belongs to registers a duplicate handler (its _parent already bubbles events), so subsequent changes fire twice. on_change only guards against the item's own handler, not against duplicates or the parent's.

Expected behavior: removing or replacing an item detaches it from the container, so a subsequent mutation of a surviving item fires exactly one change event and discarded containers can be collected.

A fix probably needs explicit deregistration bookkeeping: an item can belong to several containers (which is why the single _parent reference is complemented by the extra-handler mechanism), so removal operations would have to unregister exactly the handler belonging to the removing container. Since Props, storage, and bindings all build on observables, the change deserves careful tests.

NiceGUI Version

main (3.14.0.post7.dev0)

Python Version

3.11.7

Browser

Other

Operating System

macOS

Additional Context

Browser and operating system are irrelevant — the behavior is purely server-side and reproducible without running an app.

Metadata

Metadata

Assignees

Labels

bugType/scope: Incorrect behavior in existing functionalityreviewStatus: PR is open and needs review🟠 majorPriority: Important, but not urgent

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions