From a000994b2f09f41b35184ad0d1dd370be00c563c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?=
 <5621605+rodrigogiraoserrao@users.noreply.github.com>
Date: Tue, 21 Nov 2023 14:23:09 +0000
Subject: [PATCH] Compositor ignores non-mounted widgets.

This, in turn, ensures widgets are not rendered before they are mounted.
---
 CHANGELOG.md                |  1 +
 src/textual/_compositor.py  |  3 +++
 src/textual/app.py          |  1 +
 src/textual/message_pump.py |  9 +++++++++
 src/textual/widget.py       |  2 +-
 tests/test_mount.py         | 27 +++++++++++++++++++++++++++
 6 files changed, 42 insertions(+), 1 deletion(-)
 create mode 100644 tests/test_mount.py

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 980abbc52e..3e1da5c856 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Off-by-one in CSS error reporting https://github.com/Textualize/textual/issues/3625
 - Loading indicators and app notifications overlapped in the wrong order https://github.com/Textualize/textual/issues/3677
 - Widgets being loaded are disabled and have their scrolling explicitly disabled too https://github.com/Textualize/textual/issues/3677
+- Method render on a widget could be called before mounting said widget https://github.com/Textualize/textual/issues/2914
 
 ### Added
 
diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py
index ddfba87806..65bf98bd36 100644
--- a/src/textual/_compositor.py
+++ b/src/textual/_compositor.py
@@ -573,6 +573,9 @@ def add_widget(
                 visible: Whether the widget should be visible by default.
                     This may be overridden by the CSS rule `visibility`.
             """
+            if not widget._is_mounted:
+                return
+
             styles = widget.styles
             visibility = styles.get_rule("visibility")
             if visibility is not None:
diff --git a/src/textual/app.py b/src/textual/app.py
index 2ef5ad2cb0..630e7753ed 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -2202,6 +2202,7 @@ async def invoke_ready_callback() -> None:
                         self.check_idle()
                     finally:
                         self._mounted_event.set()
+                        self._is_mounted = True
 
                     Reactive._initialize_object(self)
 
diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py
index 33c80fa807..018b009e6c 100644
--- a/src/textual/message_pump.py
+++ b/src/textual/message_pump.py
@@ -122,6 +122,13 @@ def __init__(self, parent: MessagePump | None = None) -> None:
         self._last_idle: float = time()
         self._max_idle: float | None = None
         self._mounted_event = asyncio.Event()
+        self._is_mounted = False
+        """Having this explicit Boolean is an optimization.
+
+        The same information could be retrieved from `self._mounted_event.is_set()`, but
+        we need to access this frequently in the compositor and the attribute with the
+        explicit Boolean value is faster than the two lookups and the function call.
+        """
         self._next_callbacks: list[events.Callback] = []
         self._thread_id: int = threading.get_ident()
 
@@ -508,6 +515,7 @@ async def _pre_process(self) -> bool:
         finally:
             # This is critical, mount may be waiting
             self._mounted_event.set()
+            self._is_mounted = True
         return True
 
     def _post_mount(self):
@@ -547,6 +555,7 @@ async def _process_messages_loop(self) -> None:
                 raise
             except Exception as error:
                 self._mounted_event.set()
+                self._is_mounted = True
                 self.app._handle_exception(error)
                 break
             finally:
diff --git a/src/textual/widget.py b/src/textual/widget.py
index b351a60773..f786cc5970 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -396,7 +396,7 @@ def __init__(
     @property
     def is_mounted(self) -> bool:
         """Check if this widget is mounted."""
-        return self._mounted_event.is_set()
+        return self._is_mounted
 
     @property
     def siblings(self) -> list[Widget]:
diff --git a/tests/test_mount.py b/tests/test_mount.py
new file mode 100644
index 0000000000..23567d2d06
--- /dev/null
+++ b/tests/test_mount.py
@@ -0,0 +1,27 @@
+"""Regression test for https://github.com/Textualize/textual/issues/2914
+
+Make sure that calls to render only happen after a widget being mounted.
+"""
+
+import asyncio
+
+from textual.app import App
+from textual.widget import Widget
+
+
+class W(Widget):
+    def render(self):
+        return self.renderable
+
+    async def on_mount(self):
+        await asyncio.sleep(0.1)
+        self.renderable = "1234"
+
+
+async def test_render_only_after_mount():
+    """Regression test for https://github.com/Textualize/textual/issues/2914"""
+    app = App()
+    async with app.run_test() as pilot:
+        app.mount(W())
+        app.mount(W())
+        await pilot.pause()