#6666·textual

Memory leak — watchers from `self.watch(other, ...)` / `data_bind` are not removed when the owner is removed from the DOM

Author: cdxjclCreated Jul 22, 2026Updated Aug 24, 2026

The bug

If a short-lived widget does self.watch(some_long_lived_object, "attr", ...) — e.g. self.watch(self.app, "theme", ...) — the widget and its whole subtree are never released after the widget is removed, as long as the observed attribute does not change again.

The watcher entry (self, callback) is stored on the observed object's __watchers["attr"]. It is only pruned in Reactive._check_watchers, which runs when the observed reactive changes:

python
watchers[:] = [
    (reactable, callback)
    for reactable, callback in watchers
    if not reactable._closing
]

So if the observed attribute is stable, the watcher (and the widget it references) stays on the long-lived object forever.

Concrete example (Footer)

Footer.compose builds FooterKey widgets with .data_bind(compact=Footer.compact). data_bind calls self.watch(Footer, "compact", ...), storing the entry (FooterKey, cb) on the long-lived Footer (the observed object), in Footer.__watchers["compact"]. So even after a FooterKey is discarded, the entry that points back to it stays on Footer.

Footer.on_mount subscribes bindings_changed to Screen.bindings_updated_signal. That signal is published by Screen.refresh_bindings(), which Reactive._set invokes whenever any reactive with the bindings=True flag changes — including App.focused on every focus move. Crucially it fires regardless of whether the binding set actually differs, so bindings_changed runs recompose on every focus/screen change (this is exactly why a debounce guard is needed to suppress no-op recomposes).

recompose discards the old FooterKeys and builds new ones, each registering a fresh (FooterKey, cb) entry on Footer.__watchers["compact"]. Since Footer.compact itself rarely changes, that watcher list grows by one stale FooterKey per bindings_changed, leaking every old FooterKey (and its Label/Strip subtree) on the long-lived Footer.

Minimal example

python
from textual.app import App, ComposeResult
from textual.widgets import Footer, Label

class LeakApp(App):
    BINDINGS = [("a", "dummy", "do a"), ("b", "dummy", "do b")]

    def compose(self) -> ComposeResult:
        yield Label("focus me")
        yield Footer()

    def on_mount(self) -> None:
        self.set_interval(0.1, self.cycle_focus)

    def cycle_focus(self) -> None:
        # Focusing between widgets triggers App.focused (a bindings=True reactive) to
        # change, which publishes Screen.bindings_updated_signal -> Footer.bindings_changed
        # -> recompose, registering a new FooterKey watcher each time; old FooterKey
        # objects are never released (compact rarely changes, so __watchers is never pruned).
        label = self.query_one(Label)
        label.focus()
        self.screen.focus_next()

if __name__ == "__main__":
    LeakApp().run()

To observe the leak, instrument the live Footer (e.g. print len(self.query_one(Footer).__watchers["compact"]) each cycle) — it grows without bound while RSS climbs.

Expected behavior

When a reactable is removed/unmounted, proactively drop its watcher entries from the observed object (e.g. in remove()/unmount), instead of relying solely on the observed value changing.

Also, it might be worth considering exposing a public remove_watch API. Currently, watch() returns None, leaving no way for an app to surgically unregister a single cross-object watcher.

Environment

  • Textual: 8.2.8
  • Rich: 15.0.0
  • Python: 3.14.6 (CPython, MSC v.1944 64 bit AMD64)
  • OS: Windows 11 (10.0.26200)
  • Terminal: Windows Terminal

Textual Diagnostics

Versions

Name Value
Textual 8.2.8
Rich 15.0.0

Python

Name Value
Version 3.14.6
Implementation CPython
Compiler MSC v.1944 64 bit (AMD64)
Executable C:\Users\foo\bar.venv\Scripts\python.exe

Operating System

Name Value
System Windows
Release 11
Version 10.0.26200

Terminal

Name Value
Terminal Application Windows Terminal
TERM Not set
COLORTERM Not set
FORCE_COLOR Not set
NO_COLOR Not set

Rich Console options

Name Value
size width=119, height=40
legacy_windows False
min_width 1
max_width 119
is_terminal True
encoding utf-8
max_height 40
justify None
overflow None
no_wrap False
highlight None
markup None
height None