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 |
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 afterthe 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: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.composebuildsFooterKeywidgets with.data_bind(compact=Footer.compact).data_bindcallsself.watch(Footer, "compact", ...), storing the entry(FooterKey, cb)on thelong-lived
Footer(the observed object), inFooter.__watchers["compact"]. So even after aFooterKeyis discarded, the entry that points back to it stays onFooter.Footer.on_mountsubscribesbindings_changedtoScreen.bindings_updated_signal. That signal ispublished by
Screen.refresh_bindings(), whichReactive._setinvokes whenever any reactive withthe
bindings=Trueflag changes — includingApp.focusedon every focus move. Crucially it firesregardless of whether the binding set actually differs, so
bindings_changedrunsrecomposeonevery focus/screen change (this is exactly why a debounce guard is needed to suppress no-op recomposes).
recomposediscards the oldFooterKeys and builds new ones, each registering a fresh(FooterKey, cb)entry onFooter.__watchers["compact"]. SinceFooter.compactitself rarelychanges, that watcher list grows by one stale
FooterKeyperbindings_changed, leaking every oldFooterKey(and itsLabel/Stripsubtree) on the long-livedFooter.Minimal example
To observe the leak, instrument the live
Footer(e.g. printlen(self.query_one(Footer).__watchers["compact"])each cycle) — it grows without bound while RSSclimbs.
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_watchAPI. Currently,watch()returnsNone, leaving no way for an app to surgically unregister a single cross-object watcher.Environment
Textual Diagnostics
Versions
Python
Operating System
Terminal
Rich Console options