Repository: Textualize/textual

Issue:
Watching CSS for border changes renders misplaced content
When (un)setting the border while watching for live CSS changes, the content of the label gets misplaced.

https://user-images.githubusercontent.com/5621605/225134232-c680b9dc-c157-4725-946f-e550425c02fc.mov

The app and CSS follow.

(This is copy & pasted from an example for the border title and subtitle, but the issue also occurs in the currently released version, and this app will run just fine despite the assignment to two meaningless attributes)

```py
from textual.app import App
from textual.containers import Grid, Container
from textual.widgets import Label


def make_label_container(
    text: str, id: str, border_title: str, border_subtitle: str
) -> Container:
    lbl = Label(text, id=id)
    lbl.border_title = border_title
    lbl.border_subtitle = border_subtitle
    return Container(lbl)


class BorderSubTitleAlignAll(App[None]):
    def compose(self):
        with Grid():
            yield make_label_container(
                "Just some label text here...", "lbl1", "[b]Title.[/b]", "[u]Subtitle."
            )
            yield make_label_container(
                "Just some label text here...", "lbl2", "[b]Title.[/b]", "[u]Subtitle."
            )
            yield make_label_container(
                "Just some label text here...", "lbl3", "[b]Title.[/b]", "[u]Subtitle."
            )
            yield make_label_container(
                "Just some label text here...", "lbl4", "[b]Title.[/b]", "[u]Subtitle."
            )
            yield make_label_container(
                "Just some label text here...", "lbl5", "[b]Title.[/b]", "[u]Subtitle."
            )
            yield make_label_container(
                "Just some label text here...", "lbl6", "[b]Title.[/b]", "[u]Subtitle."
            )
            yield make_label_container(
                "Just some label text here...", "lbl7", "[b]Title.[/b]", "[u]Subtitle."
            )
            yield make_label_container(
                "Just some label text here...", "lbl8", "[b]Title.[/b]", "[u]Subtitle."
            )
            yield make_label_container(
                "Just some label text here...", "lbl9", "[b]Title.[/b]", "[u]Subtitle."
            )


app = BorderSubTitleAlignAll(css_path="border_sub_title_align_all.css")
```

```sass
Grid {
    grid-size: 3 3;
    align: center middle;
}

Container {
    width: 100%;
    height: 100%;
    align: center middle;
}

#lbl2 {
    border: round $secondary;
}

```
Watching CSS for border changes renders misplaced content
When (un)setting the border while watching for live CSS changes, the content of the label gets misplaced.

https://user-images.githubusercontent.com/5621605/225134232-c680b9dc-c157-4725-946f-e550425c02fc.mov

The app and CSS follow.

(This is copy & pasted from an example for the border title and subtitle, but the issue also occurs in the currently released version, and this app will run just fine despite the assignment to two meaningless attributes)

```py
from textual.app import App
from textual.containers import Grid, Container
from textual.widgets import Label


def make_label_container(
    text: str, id: str, border_title: str, border_subtitle: str
) -> Container:
    lbl = Label(text, id=id)
    lbl.border_title = border_title
    lbl.border_subtitle = border_subtitle
    return Container(lbl)


class BorderSubTitleAlignAll(App[None]):
    def compose(self):
        with Grid():
            yield make_label_container(
                "Just some label text here...", "lbl1", "[b]Title.[/b]", "[u]Subtitle."
            )
            yield make_label_container(
                "Just some label text here...", "lbl2", "[b]Title.[/b]", "[u]Subtitle."
            )
            yield make_label_container(
                "Just some label text here...", "lbl3", "[b]Title.[/b]", "[u]Subtitle."
            )
            yield make_label_container(
                "Just some label text here...", "lbl4", "[b]Title.[/b]", "[u]Subtitle."
            )
            yield make_label_container(
                "Just some label text here...", "lbl5", "[b]Title.[/b]", "[u]Subtitle."
            )
            yield make_label_container(
                "Just some label text here...", "lbl6", "[b]Title.[/b]", "[u]Subtitle."
            )
            yield make_label_container(
                "Just some label text here...", "lbl7", "[b]Title.[/b]", "[u]Subtitle."
            )
            yield make_label_container(
                "Just some label text here...", "lbl8", "[b]Title.[/b]", "[u]Subtitle."
            )
            yield make_label_container(
                "Just some label text here...", "lbl9", "[b]Title.[/b]", "[u]Subtitle."
            )


app = BorderSubTitleAlignAll(css_path="border_sub_title_align_all.css")
```

```sass
Grid {
    grid-size: 3 3;
    align: center middle;
}

Container {
    width: 100%;
    height: 100%;
    align: center middle;
}

#lbl2 {
    border: round $secondary;
}

```


Discussion:
Need to check this against main.
> Need to check this against main.

`main` has this issue.
Could be caching related, or an attribute is doing a simple refresh where it should be doing a layout.
Watching for changes in the `padding` while there is a border present also renders poorly.
The padding is updated, but not the border.
I used a debugger to trace through the code when CSS reloads and when setting a border property directly and I haven't yet understood how come the programmatic assignment works, which only triggers the style property `__set__` method and a layout refresh, whereas watching for the CSS changes does so much more: it clears caches, sets widgets as dirty, repaints the whole screen, etc, and yet it fails to render the label correctly... 🤔 

Smaller repro app:

```py
# myapp.py
from textual.app import App, ComposeResult
from textual.widgets import Label


class MyApp(App[None]):
    def compose(self) -> ComposeResult:
        yield Label("oi")

    def key_a(self) -> None:
        self.query_one(Label).styles.border = ("round", "blue")

    def key_r(self) -> None:
        self.query_one(Label).styles.border = None


app = MyApp(css_path="myapp.css", watch_css=True)

if __name__ == "__main__":
    app.run()
```

```sass
/* myapp.css */
Label {
    padding: 2;
    background: red;
}
```
Nevermind, found it! 🚀 
Need to check this against main.
> Need to check this against main.

`main` has this issue.
Could be caching related, or an attribute is doing a simple refresh where it should be doing a layout.
Watching for changes in the `padding` while there is a border present also renders poorly.
The padding is updated, but not the border.
I used a debugger to trace through the code when CSS reloads and when setting a border property directly and I haven't yet understood how come the programmatic assignment works, which only triggers the style property `__set__` method and a layout refresh, whereas watching for the CSS changes does so much more: it clears caches, sets widgets as dirty, repaints the whole screen, etc, and yet it fails to render the label correctly... 🤔 

Smaller repro app:

```py
# myapp.py
from textual.app import App, ComposeResult
from textual.widgets import Label


class MyApp(App[None]):
    def compose(self) -> ComposeResult:
        yield Label("oi")

    def key_a(self) -> None:
        self.query_one(Label).styles.border = ("round", "blue")

    def key_r(self) -> None:
        self.query_one(Label).styles.border = None


app = MyApp(css_path="myapp.css", watch_css=True)

if __name__ == "__main__":
    app.run()
```

```sass
/* myapp.css */
Label {
    padding: 2;
    background: red;
}
```
Nevermind, found it! 🚀 

Resolving patch:
diff --git a/src/textual/_doc.py b/src/textual/_doc.py
--- a/src/textual/_doc.py
+++ b/src/textual/_doc.py
@@ -1,10 +1,11 @@
 from __future__ import annotations
 
 import hashlib
+import inspect
 import os
 import shlex
 from pathlib import Path
-from typing import Iterable, cast
+from typing import Awaitable, Callable, Iterable, cast
 
 from textual._import_app import import_app
 from textual.app import App
@@ -54,6 +55,7 @@ def take_svg_screenshot(
     press: Iterable[str] = (),
     title: str | None = None,
     terminal_size: tuple[int, int] = (80, 24),
+    run_before: Callable[[Pilot], Awaitable[None] | None] | None = None,
 ) -> str:
     """
 
@@ -63,11 +65,13 @@ def take_svg_screenshot(
         press: Key presses to run before taking screenshot. "_" is a short pause.
         title: The terminal title in the output image.
         terminal_size: A pair of integers (rows, columns), representing terminal size.
+        run_before: An arbitrary callable that runs arbitrary code before taking the
+            screenshot. Use this to simulate complex user interactions with the app
+            that cannot be simulated by key presses.
 
     Returns:
         An SVG string, showing the content of the terminal window at the time
             the screenshot was taken.
-
     """
 
     if app is None:
@@ -90,7 +94,7 @@ def get_cache_key(app: App) -> str:
         cache_key = f"{hash.hexdigest()}.svg"
         return cache_key
 
-    if app_path is not None:
+    if app_path is not None and run_before is None:
         screenshot_cache = Path(SCREENSHOT_CACHE)
         screenshot_cache.mkdir(exist_ok=True)
 
@@ -100,6 +104,10 @@ def get_cache_key(app: App) -> str:
 
     async def auto_pilot(pilot: Pilot) -> None:
         app = pilot.app
+        if run_before is not None:
+            result = run_before(pilot)
+            if inspect.isawaitable(result):
+                await result
         await pilot.press(*press)
         await pilot.wait_for_scheduled_animations()
         await pilot.pause()
@@ -116,7 +124,7 @@ async def auto_pilot(pilot: Pilot) -> None:
         ),
     )
 
-    if app_path is not None:
+    if app_path is not None and run_before is None:
         screenshot_path.write_text(svg)
 
     assert svg is not None
diff --git a/src/textual/app.py b/src/textual/app.py
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -1081,7 +1081,6 @@ async def _on_css_change(self) -> None:
                 self.bell()
             else:
                 self.stylesheet = stylesheet
-                self.reset_styles()
                 self.stylesheet.update(self)
                 self.screen.refresh(layout=True)
 


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
@@ -1985,6 +1985,161 @@
   
   '''
 # ---
+# name: test_css_hot_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-3709538693-matrix {
+          font-family: Fira Code, monospace;
+          font-size: 20px;
+          line-height: 24.4px;
+          font-variant-east-asian: full-width;
+      }
+  
+      .terminal-3709538693-title {
+          font-size: 18px;
+          font-weight: bold;
+          font-family: arial;
+      }
+  
+      .terminal-3709538693-r1 { fill: #e1e1e1 }
+  .terminal-3709538693-r2 { fill: #c5c8c6 }
+      </style>
+  
+      <defs>
+      <clipPath id="terminal-3709538693-clip-terminal">
+        <rect x="0" y="0" width="975.0" height="584.5999999999999" />
+      </clipPath>
+      <clipPath id="terminal-3709538693-line-0">
+      <rect x="0" y="1.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-1">
+      <rect x="0" y="25.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-2">
+      <rect x="0" y="50.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-3">
+      <rect x="0" y="74.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-4">
+      <rect x="0" y="99.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-5">
+      <rect x="0" y="123.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-6">
+      <rect x="0" y="147.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-7">
+      <rect x="0" y="172.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-8">
+      <rect x="0" y="196.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-9">
+      <rect x="0" y="221.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-10">
+      <rect x="0" y="245.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-11">
+      <rect x="0" y="269.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-12">
+      <rect x="0" y="294.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-13">
+      <rect x="0" y="318.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-14">
+      <rect x="0" y="343.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-15">
+      <rect x="0" y="367.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-16">
+      <rect x="0" y="391.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-17">
+      <rect x="0" y="416.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-18">
+      <rect x="0" y="440.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-19">
+      <rect x="0" y="465.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-20">
+      <rect x="0" y="489.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-21">
+      <rect x="0" y="513.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-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-3709538693-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">HotReloadingApp</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-3709538693-clip-terminal)">
+      <rect fill="#1e1e1e" x="0" y="1.5" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="158.6" y="1.5" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="25.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="562.7" width="976" height="24.65" shape-rendering="crispEdges"/>
+      <g class="terminal-3709538693-matrix">
+      <text class="terminal-3709538693-r1" x="0" y="20" textLength="158.6" clip-path="url(#terminal-3709538693-line-0)">Hello,&#160;world!</text><text class="terminal-3709538693-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-3709538693-line-0)">
+  </text><text class="terminal-3709538693-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-3709538693-line-1)">
+  </text><text class="terminal-3709538693-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-3709538693-line-2)">
+  </text><text class="terminal-3709538693-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-3709538693-line-3)">
+  </text><text class="terminal-3709538693-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-3709538693-line-4)">
+  </text><text class="terminal-3709538693-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-3709538693-line-5)">
+  </text><text class="terminal-3709538693-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-3709538693-line-6)">
+  </text><text class="terminal-3709538693-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-3709538693-line-7)">
+  </text><text class="terminal-3709538693-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-3709538693-line-8)">
+  </text><text class="terminal-3709538693-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-3709538693-line-9)">
+  </text><text class="terminal-3709538693-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-3709538693-line-10)">
+  </text><text class="terminal-3709538693-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-3709538693-line-11)">
+  </text><text class="terminal-3709538693-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-3709538693-line-12)">
+  </text><text class="terminal-3709538693-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-3709538693-line-13)">
+  </text><text class="terminal-3709538693-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-3709538693-line-14)">
+  </text><text class="terminal-3709538693-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-3709538693-line-15)">
+  </text><text class="terminal-3709538693-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-3709538693-line-16)">
+  </text><text class="terminal-3709538693-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-3709538693-line-17)">
+  </text><text class="terminal-3709538693-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-3709538693-line-18)">
+  </text><text class="terminal-3709538693-r2" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-3709538693-line-19)">
+  </text><text class="terminal-3709538693-r2" x="976" y="508" textLength="12.2" clip-path="url(#terminal-3709538693-line-20)">
+  </text><text class="terminal-3709538693-r2" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-3709538693-line-21)">
+  </text><text class="terminal-3709538693-r2" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-3709538693-line-22)">
+  </text>
+      </g>
+      </g>
+  </svg>
+  
+  '''
+# ---
 # name: test_css_property[align.py]
   '''
   <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
diff --git a/tests/snapshot_tests/conftest.py b/tests/snapshot_tests/conftest.py
--- a/tests/snapshot_tests/conftest.py
+++ b/tests/snapshot_tests/conftest.py
@@ -7,7 +7,7 @@
 from operator import attrgetter
 from os import PathLike
 from pathlib import Path, PurePath
-from typing import Union, List, Optional, Callable, Iterable
+from typing import Awaitable, Union, List, Optional, Callable, Iterable
 
 import pytest
 from _pytest.config import ExitCode
@@ -21,6 +21,7 @@
 from textual._doc import take_svg_screenshot
 from textual._import_app import import_app
 from textual.app import App
+from textual.pilot import Pilot
 
 TEXTUAL_SNAPSHOT_SVG_KEY = pytest.StashKey[str]()
 TEXTUAL_ACTUAL_SVG_KEY = pytest.StashKey[str]()
@@ -42,6 +43,7 @@ def compare(
         app_path: str | PurePath,
         press: Iterable[str] = ("_",),
         terminal_size: tuple[int, int] = (80, 24),
+        run_before: Callable[[Pilot], Awaitable[None] | None] | None = None,
     ) -> bool:
         """
         Compare a current screenshot of the app running at app_path, with
@@ -54,9 +56,12 @@ def compare(
                 test this function is called from.
             press (Iterable[str]): Key presses to run before taking screenshot. "_" is a short pause.
             terminal_size (tuple[int, int]): A pair of integers (WIDTH, HEIGHT), representing terminal size.
+            run_before: An arbitrary callable that runs arbitrary code before taking the
+                screenshot. Use this to simulate complex user interactions with the app
+                that cannot be simulated by key presses.
 
         Returns:
-            bool: True if the screenshot matches the snapshot.
+            Whether the screenshot matches the snapshot.
         """
         node = request.node
         path = Path(app_path)
@@ -74,6 +79,7 @@ def compare(
             app=app,
             press=press,
             terminal_size=terminal_size,
+            run_before=run_before,
         )
         result = snapshot == actual_screenshot
 
diff --git a/tests/snapshot_tests/snapshot_apps/hot_reloading_app.css b/tests/snapshot_tests/snapshot_apps/hot_reloading_app.css
new file mode 100644
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/hot_reloading_app.css
@@ -0,0 +1 @@
+/* This file is purposefully empty. */
diff --git a/tests/snapshot_tests/snapshot_apps/hot_reloading_app.py b/tests/snapshot_tests/snapshot_apps/hot_reloading_app.py
new file mode 100644
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/hot_reloading_app.py
@@ -0,0 +1,31 @@
+from pathlib import Path
+
+from textual.app import App, ComposeResult
+from textual.containers import Container
+from textual.widgets import Label
+
+
+CSS_PATH = (Path(__file__) / "../hot_reloading_app.css").resolve()
+
+# Write some CSS to the file before the app loads.
+# Then, the test will clear all the CSS to see if the
+# hot reloading applies the changes correctly.
+CSS_PATH.write_text(
+    """
+Container {
+    align: center middle;
+}
+
+Label {
+    border: round $primary;
+    padding: 3;
+}
+"""
+)
+
+
+class HotReloadingApp(App[None]):
+    CSS_PATH = CSS_PATH
+
+    def compose(self) -> ComposeResult:
+        yield Container(Label("Hello, world!"))
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
@@ -342,6 +342,20 @@ def test_scrollbar_thumb_height(snap_compare):
     )
 
 
+def test_css_hot_reloading(snap_compare):
+    """Regression test for https://github.com/Textualize/textual/issues/2063."""
+
+    async def run_before(pilot):
+        css_file = pilot.app.CSS_PATH
+        with open(css_file, "w") as f:
+            f.write("/* This file is purposefully empty. */\n")  # Clear all the CSS.
+        await pilot.app._on_css_change()
+
+    assert snap_compare(
+        SNAPSHOT_APPS_DIR / "hot_reloading_app.py", run_before=run_before
+    )
+
+
 def test_layer_fix(snap_compare):
     # Check https://github.com/Textualize/textual/issues/1358
     assert snap_compare(SNAPSHOT_APPS_DIR / "layer_fix.py", press=["d"])