Repository: Textualize/textual

Issue:
`DirectoryTree.clear_node` doesn't actually clear the node
The current implementation of `DirectoryTree.clear_node` assigns to `node` but then does nothing with it, which means it doesn't really clear the node passed in:

https://github.com/Textualize/textual/blob/ca2c11bdb86d7f48acadee534a1764777b444d34/src/textual/widgets/_directory_tree.py#L188-L208


Discussion:

Resolving patch:
diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py
--- a/src/textual/widgets/_directory_tree.py
+++ b/src/textual/widgets/_directory_tree.py
@@ -5,20 +5,19 @@
 from pathlib import Path
 from typing import TYPE_CHECKING, Callable, ClassVar, Iterable, Iterator
 
-from ..await_complete import AwaitComplete
-
-if TYPE_CHECKING:
-    from typing_extensions import Self
-
 from rich.style import Style
 from rich.text import Text, TextType
 
 from .. import work
+from ..await_complete import AwaitComplete
 from ..message import Message
 from ..reactive import var
 from ..worker import Worker, WorkerCancelled, WorkerFailed, get_current_worker
 from ._tree import TOGGLE_STYLE, Tree, TreeNode
 
+if TYPE_CHECKING:
+    from typing_extensions import Self
+
 
 @dataclass
 class DirEntry:
@@ -164,7 +163,7 @@ def _add_to_load_queue(self, node: TreeNode[DirEntry]) -> AwaitComplete:
 
         Returns:
             An optionally awaitable object that can be awaited until the
-            load queue has finished processing.
+                load queue has finished processing.
         """
         assert node.data is not None
         if not node.data.loaded:
@@ -174,16 +173,18 @@ def _add_to_load_queue(self, node: TreeNode[DirEntry]) -> AwaitComplete:
         return AwaitComplete(self._load_queue.join())
 
     def reload(self) -> AwaitComplete:
-        """Reload the `DirectoryTree` contents."""
-        self.reset(str(self.path), DirEntry(self.PATH(self.path)))
+        """Reload the `DirectoryTree` contents.
+
+        Returns:
+            An optionally awaitable that ensures the tree has finished reloading.
+        """
         # Orphan the old queue...
         self._load_queue = Queue()
+        # ... reset the root node ...
+        processed = self.reload_node(self.root)
         # ...and replace the old load with a new one.
         self._loader()
-        # We have a fresh queue, we have a fresh loader, get the fresh root
-        # loading up.
-        queue_processed = self._add_to_load_queue(self.root)
-        return queue_processed
+        return processed
 
     def clear_node(self, node: TreeNode[DirEntry]) -> Self:
         """Clear all nodes under the given node.
@@ -192,17 +193,7 @@ def clear_node(self, node: TreeNode[DirEntry]) -> Self:
             The `Tree` instance.
         """
         self._clear_line_cache()
-        node_label = node._label
-        node_data = node.data
-        node_parent = node.parent
-        node = TreeNode(
-            self,
-            node_parent,
-            self._new_id(),
-            node_label,
-            node_data,
-            expanded=True,
-        )
+        node.remove_children()
         self._updates += 1
         self.refresh()
         return self
@@ -225,6 +216,86 @@ def reset_node(
         node.data = data
         return self
 
+    async def _reload(self, node: TreeNode[DirEntry]) -> None:
+        """Reloads the subtree rooted at the given node while preserving state.
+
+        After reloading the subtree, nodes that were expanded and still exist
+        will remain expanded and the highlighted node will be preserved, if it
+        still exists. If it doesn't, highlighting goes up to the first parent
+        directory that still exists.
+
+        Args:
+            node: The root of the subtree to reload.
+        """
+        async with self.lock:
+            # Track nodes that were expanded before reloading.
+            currently_open: set[Path] = set()
+            to_check: list[TreeNode[DirEntry]] = [node]
+            while to_check:
+                checking = to_check.pop()
+                if checking.allow_expand and checking.is_expanded:
+                    if checking.data:
+                        currently_open.add(checking.data.path)
+                    to_check.extend(checking.children)
+
+            # Track node that was highlighted before reloading.
+            highlighted_path: None | Path = None
+            if self.cursor_line > -1:
+                highlighted_node = self.get_node_at_line(self.cursor_line)
+                if highlighted_node is not None and highlighted_node.data is not None:
+                    highlighted_path = highlighted_node.data.path
+
+            if node.data is not None:
+                self.reset_node(
+                    node, str(node.data.path.name), DirEntry(self.PATH(node.data.path))
+                )
+
+            # Reopen nodes that were expanded and still exist.
+            to_reopen = [node]
+            while to_reopen:
+                reopening = to_reopen.pop()
+                if not reopening.data:
+                    continue
+                if reopening.allow_expand and (
+                    reopening.data.path in currently_open or reopening == node
+                ):
+                    try:
+                        content = await self._load_directory(reopening).wait()
+                    except (WorkerCancelled, WorkerFailed):
+                        continue
+                    reopening.data.loaded = True
+                    self._populate_node(reopening, content)
+                    to_reopen.extend(reopening.children)
+                    reopening.expand()
+
+            if highlighted_path is None:
+                return
+
+            # Restore the highlighted path and consider the parents as fallbacks.
+            looking = [node]
+            highlight_candidates = set(highlighted_path.parents)
+            highlight_candidates.add(highlighted_path)
+            best_found: None | TreeNode[DirEntry] = None
+            while looking:
+                checking = looking.pop()
+                checking_path = (
+                    checking.data.path if checking.data is not None else None
+                )
+                if checking_path in highlight_candidates:
+                    best_found = checking
+                    if checking_path == highlighted_path:
+                        break
+                if (
+                    checking.allow_expand
+                    and checking.is_expanded
+                    and checking_path in highlighted_path.parents
+                ):
+                    looking.extend(checking.children)
+            if best_found is not None:
+                # We need valid lines. Make sure the tree lines have been computed:
+                _ = self._tree_lines
+                self.cursor_line = best_found.line
+
     def reload_node(self, node: TreeNode[DirEntry]) -> AwaitComplete:
         """Reload the given node's contents.
 
@@ -233,12 +304,12 @@ def reload_node(self, node: TreeNode[DirEntry]) -> AwaitComplete:
         or any other nodes).
 
         Args:
-            node: The node to reload.
+            node: The root of the subtree to reload.
+
+        Returns:
+            An optionally awaitable that ensures the subtree has finished reloading.
         """
-        self.reset_node(
-            node, str(node.data.path.name), DirEntry(self.PATH(node.data.path))
-        )
-        return self._add_to_load_queue(node)
+        return AwaitComplete(self._reload(node))
 
     def validate_path(self, path: str | Path) -> Path:
         """Ensure that the path is of the `Path` type.
@@ -398,7 +469,7 @@ def _directory_content(self, location: Path, worker: Worker) -> Iterator[Path]:
         except PermissionError:
             pass
 
-    @work(thread=True)
+    @work(thread=True, exit_on_error=False)
     def _load_directory(self, node: TreeNode[DirEntry]) -> list[Path]:
         """Load the directory contents for a given node.
 
@@ -425,28 +496,29 @@ async def _loader(self) -> None:
             # this blocks if the queue is empty.
             node = await self._load_queue.get()
             content: list[Path] = []
-            try:
-                # Spin up a short-lived thread that will load the content of
-                # the directory associated with that node.
-                content = await self._load_directory(node).wait()
-            except WorkerCancelled:
-                # The worker was cancelled, that would suggest we're all
-                # done here and we should get out of the loader in general.
-                break
-            except WorkerFailed:
-                # This particular worker failed to start. We don't know the
-                # reason so let's no-op that (for now anyway).
-                pass
-            else:
-                # We're still here and we have directory content, get it into
-                # the tree.
-                if content:
-                    self._populate_node(node, content)
-            finally:
-                # Mark this iteration as done.
-                self._load_queue.task_done()
-
-    async def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None:
+            async with self.lock:
+                try:
+                    # Spin up a short-lived thread that will load the content of
+                    # the directory associated with that node.
+                    content = await self._load_directory(node).wait()
+                except WorkerCancelled:
+                    # The worker was cancelled, that would suggest we're all
+                    # done here and we should get out of the loader in general.
+                    break
+                except WorkerFailed:
+                    # This particular worker failed to start. We don't know the
+                    # reason so let's no-op that (for now anyway).
+                    pass
+                else:
+                    # We're still here and we have directory content, get it into
+                    # the tree.
+                    if content:
+                        self._populate_node(node, content)
+                finally:
+                    # Mark this iteration as done.
+                    self._load_queue.task_done()
+
+    async def _on_tree_node_expanded(self, event: Tree.NodeExpanded[DirEntry]) -> None:
         event.stop()
         dir_entry = event.node.data
         if dir_entry is None:
@@ -456,7 +528,7 @@ async def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None:
         else:
             self.post_message(self.FileSelected(event.node, dir_entry.path))
 
-    def _on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
+    def _on_tree_node_selected(self, event: Tree.NodeSelected[DirEntry]) -> None:
         event.stop()
         dir_entry = event.node.data
         if dir_entry is None:
diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py
--- a/src/textual/widgets/_tree.py
+++ b/src/textual/widgets/_tree.py
@@ -2,6 +2,7 @@
 
 from __future__ import annotations
 
+from asyncio import Lock
 from dataclasses import dataclass
 from typing import TYPE_CHECKING, ClassVar, Generic, Iterable, NewType, TypeVar, cast
 
@@ -615,8 +616,10 @@ def __init__(
         self.root = self._add_node(None, text_label, data)
         """The root node of the tree."""
         self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1024)
-        self._tree_lines_cached: list[_TreeLine] | None = None
+        self._tree_lines_cached: list[_TreeLine[TreeDataType]] | None = None
         self._cursor_node: TreeNode[TreeDataType] | None = None
+        self.lock = Lock()
+        """Used to synchronise stateful directory tree operations."""
 
         super().__init__(name=name, id=id, classes=classes, disabled=disabled)
 
@@ -815,7 +818,7 @@ def _invalidate(self) -> None:
         self.root._reset()
         self.refresh(layout=True)
 
-    def _on_mouse_move(self, event: events.MouseMove):
+    def _on_mouse_move(self, event: events.MouseMove) -> None:
         meta = event.style.meta
         if meta and "line" in meta:
             self.hover_line = meta["line"]
@@ -948,7 +951,7 @@ def _refresh_node(self, node: TreeNode[TreeDataType]) -> None:
                 self._refresh_line(line_no)
 
     @property
-    def _tree_lines(self) -> list[_TreeLine]:
+    def _tree_lines(self) -> list[_TreeLine[TreeDataType]]:
         if self._tree_lines_cached is None:
             self._build()
         assert self._tree_lines_cached is not None
@@ -957,13 +960,14 @@ def _tree_lines(self) -> list[_TreeLine]:
     async def _on_idle(self, event: events.Idle) -> None:
         """Check tree needs a rebuild on idle."""
         # Property calls build if required
-        self._tree_lines
+        async with self.lock:
+            self._tree_lines
 
     def _build(self) -> None:
         """Builds the tree by traversing nodes, and creating tree lines."""
 
         TreeLine = _TreeLine
-        lines: list[_TreeLine] = []
+        lines: list[_TreeLine[TreeDataType]] = []
         add_line = lines.append
 
         root = self.root
@@ -989,7 +993,7 @@ def add_node(
         show_root = self.show_root
         get_label_width = self.get_label_width
 
-        def get_line_width(line: _TreeLine) -> int:
+        def get_line_width(line: _TreeLine[TreeDataType]) -> int:
             return get_label_width(line.node) + line._get_guide_width(
                 guide_depth, show_root
             )
@@ -1147,17 +1151,18 @@ def _toggle_node(self, node: TreeNode[TreeDataType]) -> None:
             node.expand()
 
     async def _on_click(self, event: events.Click) -> None:
-        meta = event.style.meta
-        if "line" in meta:
-            cursor_line = meta["line"]
-            if meta.get("toggle", False):
-                node = self.get_node_at_line(cursor_line)
-                if node is not None:
-                    self._toggle_node(node)
+        async with self.lock:
+            meta = event.style.meta
+            if "line" in meta:
+                cursor_line = meta["line"]
+                if meta.get("toggle", False):
+                    node = self.get_node_at_line(cursor_line)
+                    if node is not None:
+                        self._toggle_node(node)
 
-            else:
-                self.cursor_line = cursor_line
-                await self.run_action("select_cursor")
+                else:
+                    self.cursor_line = cursor_line
+                    await self.run_action("select_cursor")
 
     def notify_style_update(self) -> None:
         self._invalidate()


Test patch:
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -16740,6 +16740,166 @@
   
   '''
 # ---
+# name: test_directory_tree_reloading
+  '''
+  <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
+      <!-- Generated with Rich https://www.textualize.io -->
+      <style>
+  
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Regular"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+          font-style: normal;
+          font-weight: 400;
+      }
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Bold"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+          font-style: bold;
+          font-weight: 700;
+      }
+  
+      .terminal-1907613470-matrix {
+          font-family: Fira Code, monospace;
+          font-size: 20px;
+          line-height: 24.4px;
+          font-variant-east-asian: full-width;
+      }
+  
+      .terminal-1907613470-title {
+          font-size: 18px;
+          font-weight: bold;
+          font-family: arial;
+      }
+  
+      .terminal-1907613470-r1 { fill: #e2e3e3 }
+  .terminal-1907613470-r2 { fill: #e2e3e3;font-weight: bold }
+  .terminal-1907613470-r3 { fill: #c5c8c6 }
+  .terminal-1907613470-r4 { fill: #008139 }
+  .terminal-1907613470-r5 { fill: #211505;font-weight: bold }
+  .terminal-1907613470-r6 { fill: #fea62b;font-weight: bold }
+  .terminal-1907613470-r7 { fill: #e2e3e3;font-style: italic; }
+      </style>
+  
+      <defs>
+      <clipPath id="terminal-1907613470-clip-terminal">
+        <rect x="0" y="0" width="975.0" height="584.5999999999999" />
+      </clipPath>
+      <clipPath id="terminal-1907613470-line-0">
+      <rect x="0" y="1.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-1">
+      <rect x="0" y="25.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-2">
+      <rect x="0" y="50.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-3">
+      <rect x="0" y="74.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-4">
+      <rect x="0" y="99.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-5">
+      <rect x="0" y="123.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-6">
+      <rect x="0" y="147.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-7">
+      <rect x="0" y="172.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-8">
+      <rect x="0" y="196.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-9">
+      <rect x="0" y="221.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-10">
+      <rect x="0" y="245.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-11">
+      <rect x="0" y="269.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-12">
+      <rect x="0" y="294.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-13">
+      <rect x="0" y="318.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-14">
+      <rect x="0" y="343.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-15">
+      <rect x="0" y="367.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-16">
+      <rect x="0" y="391.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-17">
+      <rect x="0" y="416.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-18">
+      <rect x="0" y="440.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-19">
+      <rect x="0" y="465.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-20">
+      <rect x="0" y="489.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-21">
+      <rect x="0" y="513.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1907613470-line-22">
+      <rect x="0" y="538.3" width="976" height="24.65"/>
+              </clipPath>
+      </defs>
+  
+      <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-1907613470-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">DirectoryTreeReloadApp</text>
+              <g transform="translate(26,22)">
+              <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+              <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+              <circle cx="44" cy="0" r="7" fill="#28c840"/>
+              </g>
+          
+      <g transform="translate(9, 41)" clip-path="url(#terminal-1907613470-clip-terminal)">
+      <rect fill="#24292f" x="0" y="1.5" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="36.6" y="1.5" width="366" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="402.6" y="1.5" width="573.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="25.9" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="48.8" y="25.9" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="85.4" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="109.8" y="25.9" width="866.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="50.3" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="48.8" y="50.3" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="50.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#fea62b" x="134.2" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="50.3" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="74.7" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="48.8" y="74.7" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="74.7" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="74.7" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="183" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="207.4" y="74.7" width="768.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="99.1" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="48.8" y="99.1" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="99.1" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="99.1" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="195.2" y="99.1" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="231.8" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="256.2" y="99.1" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="305" y="99.1" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="123.5" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="48.8" y="123.5" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="123.5" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="123.5" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="195.2" y="123.5" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="231.8" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="256.2" y="123.5" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="305" y="123.5" width="671" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="147.9" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="48.8" y="147.9" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="147.9" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="147.9" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="183" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="207.4" y="147.9" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="256.2" y="147.9" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="172.3" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="48.8" y="172.3" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="172.3" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="172.3" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="183" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="207.4" y="172.3" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="256.2" y="172.3" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="196.7" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="48.8" y="196.7" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="196.7" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="134.2" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="196.7" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="207.4" y="196.7" width="768.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="221.1" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="48.8" y="221.1" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="97.6" y="221.1" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="134.2" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="221.1" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="207.4" y="221.1" width="768.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="245.5" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="48.8" y="245.5" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="85.4" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="109.8" y="245.5" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="245.5" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="269.9" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="48.8" y="269.9" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="85.4" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="109.8" y="269.9" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="269.9" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="0" y="562.7" width="976" height="24.65" shape-rendering="crispEdges"/>
+      <g class="terminal-1907613470-matrix">
+      <text class="terminal-1907613470-r1" x="0" y="20" textLength="24.4" clip-path="url(#terminal-1907613470-line-0)">📂&#160;</text><text class="terminal-1907613470-r2" x="36.6" y="20" textLength="366" clip-path="url(#terminal-1907613470-line-0)">test_directory_tree_reloading0</text><text class="terminal-1907613470-r3" x="976" y="20" textLength="12.2" clip-path="url(#terminal-1907613470-line-0)">
+  </text><text class="terminal-1907613470-r4" x="0" y="44.4" textLength="48.8" clip-path="url(#terminal-1907613470-line-1)">├──&#160;</text><text class="terminal-1907613470-r1" x="48.8" y="44.4" textLength="24.4" clip-path="url(#terminal-1907613470-line-1)">📂&#160;</text><text class="terminal-1907613470-r2" x="85.4" y="44.4" textLength="24.4" clip-path="url(#terminal-1907613470-line-1)">b1</text><text class="terminal-1907613470-r3" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-1907613470-line-1)">
+  </text><text class="terminal-1907613470-r4" x="0" y="68.8" textLength="48.8" clip-path="url(#terminal-1907613470-line-2)">│&#160;&#160;&#160;</text><text class="terminal-1907613470-r4" x="48.8" y="68.8" textLength="48.8" clip-path="url(#terminal-1907613470-line-2)">├──&#160;</text><text class="terminal-1907613470-r1" x="97.6" y="68.8" textLength="24.4" clip-path="url(#terminal-1907613470-line-2)">📂&#160;</text><text class="terminal-1907613470-r5" x="134.2" y="68.8" textLength="24.4" clip-path="url(#terminal-1907613470-line-2)">c1</text><text class="terminal-1907613470-r3" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-1907613470-line-2)">
+  </text><text class="terminal-1907613470-r4" x="0" y="93.2" textLength="48.8" clip-path="url(#terminal-1907613470-line-3)">│&#160;&#160;&#160;</text><text class="terminal-1907613470-r4" x="48.8" y="93.2" textLength="48.8" clip-path="url(#terminal-1907613470-line-3)">│&#160;&#160;&#160;</text><text class="terminal-1907613470-r6" x="97.6" y="93.2" textLength="48.8" clip-path="url(#terminal-1907613470-line-3)">┣━━&#160;</text><text class="terminal-1907613470-r1" x="146.4" y="93.2" textLength="24.4" clip-path="url(#terminal-1907613470-line-3)">📂&#160;</text><text class="terminal-1907613470-r2" x="183" y="93.2" textLength="24.4" clip-path="url(#terminal-1907613470-line-3)">d1</text><text class="terminal-1907613470-r3" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-1907613470-line-3)">
+  </text><text class="terminal-1907613470-r4" x="0" y="117.6" textLength="48.8" clip-path="url(#terminal-1907613470-line-4)">│&#160;&#160;&#160;</text><text class="terminal-1907613470-r4" x="48.8" y="117.6" textLength="48.8" clip-path="url(#terminal-1907613470-line-4)">│&#160;&#160;&#160;</text><text class="terminal-1907613470-r6" x="97.6" y="117.6" textLength="48.8" clip-path="url(#terminal-1907613470-line-4)">┃&#160;&#160;&#160;</text><text class="terminal-1907613470-r6" x="146.4" y="117.6" textLength="48.8" clip-path="url(#terminal-1907613470-line-4)">┣━━&#160;</text><text class="terminal-1907613470-r1" x="195.2" y="117.6" textLength="24.4" clip-path="url(#terminal-1907613470-line-4)">📄&#160;</text><text class="terminal-1907613470-r1" x="231.8" y="117.6" textLength="24.4" clip-path="url(#terminal-1907613470-line-4)">f1</text><text class="terminal-1907613470-r7" x="256.2" y="117.6" textLength="48.8" clip-path="url(#terminal-1907613470-line-4)">.txt</text><text class="terminal-1907613470-r3" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-1907613470-line-4)">
+  </text><text class="terminal-1907613470-r4" x="0" y="142" textLength="48.8" clip-path="url(#terminal-1907613470-line-5)">│&#160;&#160;&#160;</text><text class="terminal-1907613470-r4" x="48.8" y="142" textLength="48.8" clip-path="url(#terminal-1907613470-line-5)">│&#160;&#160;&#160;</text><text class="terminal-1907613470-r6" x="97.6" y="142" textLength="48.8" clip-path="url(#terminal-1907613470-line-5)">┃&#160;&#160;&#160;</text><text class="terminal-1907613470-r6" x="146.4" y="142" textLength="48.8" clip-path="url(#terminal-1907613470-line-5)">┗━━&#160;</text><text class="terminal-1907613470-r1" x="195.2" y="142" textLength="24.4" clip-path="url(#terminal-1907613470-line-5)">📄&#160;</text><text class="terminal-1907613470-r1" x="231.8" y="142" textLength="24.4" clip-path="url(#terminal-1907613470-line-5)">f2</text><text class="terminal-1907613470-r7" x="256.2" y="142" textLength="48.8" clip-path="url(#terminal-1907613470-line-5)">.txt</text><text class="terminal-1907613470-r3" x="976" y="142" textLength="12.2" clip-path="url(#terminal-1907613470-line-5)">
+  </text><text class="terminal-1907613470-r4" x="0" y="166.4" textLength="48.8" clip-path="url(#terminal-1907613470-line-6)">│&#160;&#160;&#160;</text><text class="terminal-1907613470-r4" x="48.8" y="166.4" textLength="48.8" clip-path="url(#terminal-1907613470-line-6)">│&#160;&#160;&#160;</text><text class="terminal-1907613470-r6" x="97.6" y="166.4" textLength="48.8" clip-path="url(#terminal-1907613470-line-6)">┣━━&#160;</text><text class="terminal-1907613470-r1" x="146.4" y="166.4" textLength="24.4" clip-path="url(#terminal-1907613470-line-6)">📄&#160;</text><text class="terminal-1907613470-r1" x="183" y="166.4" textLength="24.4" clip-path="url(#terminal-1907613470-line-6)">f1</text><text class="terminal-1907613470-r7" x="207.4" y="166.4" textLength="48.8" clip-path="url(#terminal-1907613470-line-6)">.txt</text><text class="terminal-1907613470-r3" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-1907613470-line-6)">
+  </text><text class="terminal-1907613470-r4" x="0" y="190.8" textLength="48.8" clip-path="url(#terminal-1907613470-line-7)">│&#160;&#160;&#160;</text><text class="terminal-1907613470-r4" x="48.8" y="190.8" textLength="48.8" clip-path="url(#terminal-1907613470-line-7)">│&#160;&#160;&#160;</text><text class="terminal-1907613470-r6" x="97.6" y="190.8" textLength="48.8" clip-path="url(#terminal-1907613470-line-7)">┗━━&#160;</text><text class="terminal-1907613470-r1" x="146.4" y="190.8" textLength="24.4" clip-path="url(#terminal-1907613470-line-7)">📄&#160;</text><text class="terminal-1907613470-r1" x="183" y="190.8" textLength="24.4" clip-path="url(#terminal-1907613470-line-7)">f2</text><text class="terminal-1907613470-r7" x="207.4" y="190.8" textLength="48.8" clip-path="url(#terminal-1907613470-line-7)">.txt</text><text class="terminal-1907613470-r3" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-1907613470-line-7)">
+  </text><text class="terminal-1907613470-r4" x="0" y="215.2" textLength="48.8" clip-path="url(#terminal-1907613470-line-8)">│&#160;&#160;&#160;</text><text class="terminal-1907613470-r4" x="48.8" y="215.2" textLength="48.8" clip-path="url(#terminal-1907613470-line-8)">├──&#160;</text><text class="terminal-1907613470-r1" x="97.6" y="215.2" textLength="24.4" clip-path="url(#terminal-1907613470-line-8)">📄&#160;</text><text class="terminal-1907613470-r1" x="134.2" y="215.2" textLength="24.4" clip-path="url(#terminal-1907613470-line-8)">f1</text><text class="terminal-1907613470-r7" x="158.6" y="215.2" textLength="48.8" clip-path="url(#terminal-1907613470-line-8)">.txt</text><text class="terminal-1907613470-r3" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-1907613470-line-8)">
+  </text><text class="terminal-1907613470-r4" x="0" y="239.6" textLength="48.8" clip-path="url(#terminal-1907613470-line-9)">│&#160;&#160;&#160;</text><text class="terminal-1907613470-r4" x="48.8" y="239.6" textLength="48.8" clip-path="url(#terminal-1907613470-line-9)">└──&#160;</text><text class="terminal-1907613470-r1" x="97.6" y="239.6" textLength="24.4" clip-path="url(#terminal-1907613470-line-9)">📄&#160;</text><text class="terminal-1907613470-r1" x="134.2" y="239.6" textLength="24.4" clip-path="url(#terminal-1907613470-line-9)">f2</text><text class="terminal-1907613470-r7" x="158.6" y="239.6" textLength="48.8" clip-path="url(#terminal-1907613470-line-9)">.txt</text><text class="terminal-1907613470-r3" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-1907613470-line-9)">
+  </text><text class="terminal-1907613470-r4" x="0" y="264" textLength="48.8" clip-path="url(#terminal-1907613470-line-10)">├──&#160;</text><text class="terminal-1907613470-r1" x="48.8" y="264" textLength="24.4" clip-path="url(#terminal-1907613470-line-10)">📄&#160;</text><text class="terminal-1907613470-r1" x="85.4" y="264" textLength="24.4" clip-path="url(#terminal-1907613470-line-10)">f1</text><text class="terminal-1907613470-r7" x="109.8" y="264" textLength="48.8" clip-path="url(#terminal-1907613470-line-10)">.txt</text><text class="terminal-1907613470-r3" x="976" y="264" textLength="12.2" clip-path="url(#terminal-1907613470-line-10)">
+  </text><text class="terminal-1907613470-r4" x="0" y="288.4" textLength="48.8" clip-path="url(#terminal-1907613470-line-11)">└──&#160;</text><text class="terminal-1907613470-r1" x="48.8" y="288.4" textLength="24.4" clip-path="url(#terminal-1907613470-line-11)">📄&#160;</text><text class="terminal-1907613470-r1" x="85.4" y="288.4" textLength="24.4" clip-path="url(#terminal-1907613470-line-11)">f2</text><text class="terminal-1907613470-r7" x="109.8" y="288.4" textLength="48.8" clip-path="url(#terminal-1907613470-line-11)">.txt</text><text class="terminal-1907613470-r3" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-1907613470-line-11)">
+  </text><text class="terminal-1907613470-r3" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-1907613470-line-12)">
+  </text><text class="terminal-1907613470-r3" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-1907613470-line-13)">
+  </text><text class="terminal-1907613470-r3" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-1907613470-line-14)">
+  </text><text class="terminal-1907613470-r3" x="976" y="386" textLength="12.2" clip-path="url(#terminal-1907613470-line-15)">
+  </text><text class="terminal-1907613470-r3" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-1907613470-line-16)">
+  </text><text class="terminal-1907613470-r3" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-1907613470-line-17)">
+  </text><text class="terminal-1907613470-r3" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-1907613470-line-18)">
+  </text><text class="terminal-1907613470-r3" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-1907613470-line-19)">
+  </text><text class="terminal-1907613470-r3" x="976" y="508" textLength="12.2" clip-path="url(#terminal-1907613470-line-20)">
+  </text><text class="terminal-1907613470-r3" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-1907613470-line-21)">
+  </text><text class="terminal-1907613470-r3" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-1907613470-line-22)">
+  </text>
+      </g>
+      </g>
+  </svg>
+  
+  '''
+# ---
 # name: test_disabled_widgets
   '''
   <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
diff --git a/tests/snapshot_tests/snapshot_apps/directory_tree_reload.py b/tests/snapshot_tests/snapshot_apps/directory_tree_reload.py
new file mode 100644
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/directory_tree_reload.py
@@ -0,0 +1,62 @@
+from pathlib import Path
+
+from textual.app import App, ComposeResult
+from textual.widgets import DirectoryTree
+
+
+class DirectoryTreeReloadApp(App[None]):
+    BINDINGS = [
+        ("r", "reload"),
+        ("e", "expand"),
+        ("d", "delete"),
+    ]
+
+    async def setup(self, path_root: Path) -> None:
+        self.path_root = path_root
+
+        structure = [
+            "f1.txt",
+            "f2.txt",
+            "b1/f1.txt",
+            "b1/f2.txt",
+            "b2/f1.txt",
+            "b2/f2.txt",
+            "b1/c1/f1.txt",
+            "b1/c1/f2.txt",
+            "b1/c2/f1.txt",
+            "b1/c2/f2.txt",
+            "b1/c1/d1/f1.txt",
+            "b1/c1/d1/f2.txt",
+            "b1/c1/d2/f1.txt",
+            "b1/c1/d2/f2.txt",
+        ]
+        for file in structure:
+            path = path_root / Path(file)
+            path.parent.mkdir(parents=True, exist_ok=True)
+            path.touch(exist_ok=True)
+
+        await self.mount(DirectoryTree(self.path_root))
+
+    async def action_reload(self) -> None:
+        dt = self.query_one(DirectoryTree)
+        await dt.reload()
+
+    def action_expand(self) -> None:
+        self.query_one(DirectoryTree).root.expand_all()
+
+    def action_delete(self) -> None:
+        self.rmdir(self.path_root / Path("b1/c1/d2"))
+        self.rmdir(self.path_root / Path("b1/c2"))
+        self.rmdir(self.path_root / Path("b2"))
+
+    def rmdir(self, path: Path) -> None:
+        for file in path.iterdir():
+            if file.is_file():
+                file.unlink()
+            elif file.is_dir():
+                self.rmdir(file)
+        path.rmdir()
+
+
+if __name__ == "__main__":
+    DirectoryTreeReloadApp(Path("playground")).run()
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -403,6 +403,19 @@ def test_collapsible_custom_symbol(snap_compare):
     assert snap_compare(WIDGET_EXAMPLES_DIR / "collapsible_custom_symbol.py")
 
 
+def test_directory_tree_reloading(snap_compare, tmp_path):
+    async def run_before(pilot):
+        await pilot.app.setup(tmp_path)
+        await pilot.press(
+            "e", "e", "down", "down", "down", "down", "e", "down", "d", "r"
+        )
+
+    assert snap_compare(
+        SNAPSHOT_APPS_DIR / "directory_tree_reload.py",
+        run_before=run_before,
+    )
+
+
 # --- CSS properties ---
 # We have a canonical example for each CSS property that is shown in their docs.
 # If any of these change, something has likely broken, so snapshot each of them.
diff --git a/tests/tree/test_directory_tree.py b/tests/tree/test_directory_tree.py
--- a/tests/tree/test_directory_tree.py
+++ b/tests/tree/test_directory_tree.py
@@ -1,6 +1,9 @@
 from __future__ import annotations
 
+from pathlib import Path
+
 from rich.text import Text
+
 from textual import on
 from textual.app import App, ComposeResult
 from textual.widgets import DirectoryTree
@@ -25,7 +28,7 @@ def record(
         self.messages.append(event.__class__.__name__)
 
 
-async def test_directory_tree_file_selected_message(tmp_path) -> None:
+async def test_directory_tree_file_selected_message(tmp_path: Path) -> None:
     """Selecting a file should result in a file selected message being emitted."""
 
     FILE_NAME = "hello.txt"
@@ -48,7 +51,7 @@ async def test_directory_tree_file_selected_message(tmp_path) -> None:
         assert pilot.app.messages == ["FileSelected"]
 
 
-async def test_directory_tree_directory_selected_message(tmp_path) -> None:
+async def test_directory_tree_directory_selected_message(tmp_path: Path) -> None:
     """Selecting a directory should result in a directory selected message being emitted."""
 
     SUBDIR = "subdir"
@@ -79,7 +82,7 @@ async def test_directory_tree_directory_selected_message(tmp_path) -> None:
         assert pilot.app.messages == ["DirectorySelected", "DirectorySelected"]
 
 
-async def test_directory_tree_reload_node(tmp_path) -> None:
+async def test_directory_tree_reload_node(tmp_path: Path) -> None:
     """Reloading a node of a directory tree should display newly created file inside the directory."""
 
     RELOADED_DIRECTORY = "parentdir"
@@ -123,7 +126,7 @@ async def test_directory_tree_reload_node(tmp_path) -> None:
         ]
 
 
-async def test_directory_tree_reload_other_node(tmp_path) -> None:
+async def test_directory_tree_reload_other_node(tmp_path: Path) -> None:
     """Reloading a node of a directory tree should not reload content of other directory."""
 
     RELOADED_DIRECTORY = "parentdir"
@@ -172,3 +175,29 @@ async def test_directory_tree_reload_other_node(tmp_path) -> None:
         # After reloading one node, the new file under the other one does not show up
         assert len(unaffected_node.children) == 1
         assert unaffected_node.children[0].label == Text(NOT_RELOADED_FILE3_NAME)
+
+
+async def test_directory_tree_reloading_preserves_state(tmp_path: Path) -> None:
+    """Regression test for https://github.com/Textualize/textual/issues/4122.
+
+    Ensures `clear_node` does clear the node specified.
+    """
+    ROOT = "root"
+    structure = [
+        ROOT,
+        "root/file1.txt",
+        "root/file2.txt",
+    ]
+
+    for path in structure:
+        if path.endswith(".txt"):
+            (tmp_path / path).touch()
+        else:
+            (tmp_path / path).mkdir()
+
+    app = DirectoryTreeApp(tmp_path / ROOT)
+    async with app.run_test() as pilot:
+        directory_tree = app.query_one(DirectoryTree)
+        directory_tree.clear_node(directory_tree.root)
+        await pilot.pause()
+        assert not directory_tree.root.children