Memory leak — watchers from `self.watch(other, ...)` / `data_bind` are not removed when the owner is removed from the DOM
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:
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
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 |
Source: Textualize/textual