#6712·textual

Tree.clear()/reset() don't clear or repopulate _tree_nodes

Author: tritsystemCreated Sep 4, 2026Updated Sep 4, 2026

AI disclosure (per AI_POLICY.md): this issue was investigated and written with the assistance of Claude (Anthropic). I read and independently reproduced the underlying code myself before filing — see the repro below, which you can run yourself.

Summary

Tree.clear() and Tree.reset() don't clear (or repopulate) the internal _tree_nodes: dict[NodeID, TreeNode] index, so get_node_by_id() can silently return a stale, discarded node after a clear(), and node IDs can collide with pre-clear entries.

This is the same diagnosis as #6383 (closed for skipping the AI-policy approval step, not because the fix was wrong). I independently reproduced it against a fresh clone of main today before filing, and I'm opening this issue first per the policy, rather than going straight to a PR.

Root cause

Tree.__init__ registers the root correctly, through _add_node():

python
self._tree_nodes: dict[NodeID, TreeNode[TreeDataType]] = {}
self.root = self._add_node(None, text_label, data)   # inserts into _tree_nodes

Tree.clear() (src/textual/widgets/_tree.py) does not follow the same path:

python
self._current_id = 0
self.root = TreeNode(self, None, self._new_id(), root_label, root_data, expanded=root_expanded)

This constructs the new root directly instead of via _add_node(), so:

  1. the old _tree_nodes dict is never cleared — discarded nodes leak in it forever
  2. the new root is never inserted into _tree_nodes

Tree.reset() just calls clear(), so it inherits the same gap.

Reproduction

python
from textual.widgets import Tree

t = Tree("root")
child_a = t.root.add("a")
child_b = t.root.add("b")

print("before:", sorted(t._tree_nodes.keys()))   # [0, 1, 2]
t.clear()
print("after: ", sorted(t._tree_nodes.keys()))   # still [0, 1, 2] -- nothing cleared

resolved = t.get_node_by_id(t.root.id)
print(resolved is t.root)   # False -- resolves to the DISCARDED pre-clear root

Real output from running this against today's main:

before: [0, 1, 2]
after:  [0, 1, 2]
get_node_by_id(new_root.id) is new_root -> False

No live App/terminal needed to reproduce — plain construction + clear() is enough.

Impact

  • get_node_by_id() can silently resolve to a stale, detached node instead of the current one — anything holding a NodeID across a clear()/reset() (a scheduled callback, a persisted "last selected" id) gets the wrong object back, silently, no exception.
  • Nodes from before a clear() that don't get their ID slot reused leak in _tree_nodes forever — unbounded growth for any UI that repeatedly clears and repopulates a Tree (a live log view, a refreshing listing, etc.).
  • DirectoryTree is unaffected, since it defines its own correct clear_node()/reset_node() rather than relying on the base clear()/reset().

Not covered by existing tests: tests/tree/test_tree_clearing.py only checks root.children/label/data after clearing, never _tree_nodes or get_node_by_id() afterward.

Proposed fix

Clear _tree_nodes before rebuilding the root, and construct the new root via _add_node() instead of TreeNode(...) directly, mirroring __init__'s own pattern (same fix shape as #6383). Happy to open a PR once this has a green light, per the AI policy.