Repository: Textualize/textual

Issue:
focus-within pseudo-class: caching-induced rendering issues 
## Context

My application uses Collapsible widgets that change the color of their left border when focused:

```css
AbstractStep:focus, AbstractStep:focus-within {
    border-left: thick $accent-lighten-3;
}
```

## Observed behaviour

Since Textual v0.84.0, more specifically since commit `9e0b04712 snapshot test`, focusing these widgets triggers rendering issues:

![moulti-diff-looking-for-textual-bug](https://github.com/user-attachments/assets/36de7846-3bed-4cb0-843f-60e2f4883731)

## Workaround

A workaround is to reinsert `"focus-within"` inside the `EXPENSIVE_PSEUDO_CLASSES` set in `css/stylesheet.py`, thus disabling pseudo-classes caching, which is presumably the root cause here.

## MRE

### Code

```python
"""
MRE for https://github.com/Textualize/textual/issues/5184
Run with Textual >= 0.84.0 or post-9e0b04712 ("snapshot test").
Click the placeholders or just hit Tab to trigger rendering issues.
"""
from textual.app import App
from textual.containers import VerticalScroll
from textual.widgets import Placeholder, Collapsible

class FocusablePlaceHolder(Placeholder, can_focus=True):
	pass

class MRE(App):
	CSS = """
	Collapsible, PlaceHolder { border-left: blank; }
	FocusablePlaceHolder:focus, Collapsible:focus-within, Collapsible:focus { border-left: thick $accent-lighten-3; }
	Placeholder { height: 5; }
	"""
	def compose(self):
		with VerticalScroll():
			for i in range(10):
				with Collapsible(title=f'{i}', collapsed=False):
					yield FocusablePlaceHolder()

if __name__ == '__main__':
	app = MRE()
	app.run()
```

### Screenshot

On the left: expected behaviour with Textual 0.83.0: no rendering issues
On the right: observed behaviour with Textual 0.84.0: rendering issues

![textual-issue-5184-mre-screenshot](https://github.com/user-attachments/assets/9be9ec19-a88e-40f5-9e5f-1575a009b208)

Hints from issue comments:
@github-actions: We found the following entry in the [FAQ](https://textual.textualize.io/FAQ/) which you may find helpful:


- [Does Textual support images?](https://textual.textualize.io/FAQ/#does-textual-support-images)

Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)@github-actions: Don't forget to [star](https://github.com/Textualize/textual) the repository!

Follow [@textualizeio](https://twitter.com/textualizeio) for Textual updates.

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 82cce7c0d9..dc87232b2a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/)
 and this project adheres to [Semantic Versioning](http://semver.org/).
 
+## Unreleased
+
+- Fixed broken focus-within https://github.com/Textualize/textual/pull/5190
+
 ## [0.85.1] - 2024-10-26
 
 ### Fixed
diff --git a/src/textual/constants.py b/src/textual/constants.py
index a9d7db7192..f4d1673888 100644
--- a/src/textual/constants.py
+++ b/src/textual/constants.py
@@ -27,24 +27,53 @@ def _get_environ_bool(name: str) -> bool:
     return has_environ
 
 
-def _get_environ_int(name: str, default: int) -> int:
+def _get_environ_int(name: str, default: int, minimum: int | None = None) -> int:
     """Retrieves an integer environment variable.
 
     Args:
         name: Name of environment variable.
         default: The value to use if the value is not set, or set to something other
             than a valid integer.
+        minimum: Optional minimum value.
 
     Returns:
         The integer associated with the environment variable if it's set to a valid int
             or the default value otherwise.
     """
     try:
-        return int(os.environ[name])
+        value = int(os.environ[name])
     except KeyError:
         return default
     except ValueError:
         return default
+    if minimum is not None:
+        return max(minimum, value)
+    return value
+
+
+def _get_environ_port(name: str, default: int) -> int:
+    """Get a port no. from an environment variable.
+
+    Note that there is no 'minimum' here, as ports are more like names than a scalar value.
+
+    Args:
+        name: Name of environment variable.
+        default: The value to use if the value is not set, or set to something other
+            than a valid port.
+
+    Returns:
+        An integer port number.
+
+    """
+    try:
+        value = int(os.environ[name])
+    except KeyError:
+        return default
+    except ValueError:
+        return default
+    if value < 0 or value > 65535:
+        return default
+    return value
 
 
 def _is_valid_animation_level(value: str) -> TypeGuard[AnimationLevel]:
@@ -89,11 +118,11 @@ def _get_textual_animations() -> AnimationLevel:
 DEVTOOLS_HOST: Final[str] = get_environ("TEXTUAL_DEVTOOLS_HOST", "127.0.0.1")
 """The host where textual console is running."""
 
-DEVTOOLS_PORT: Final[int] = _get_environ_int("TEXTUAL_DEVTOOLS_PORT", 8081)
+DEVTOOLS_PORT: Final[int] = _get_environ_port("TEXTUAL_DEVTOOLS_PORT", 8081)
 """Constant with the port that the devtools will connect to."""
 
-SCREENSHOT_DELAY: Final[int] = _get_environ_int("TEXTUAL_SCREENSHOT", -1)
-"""Seconds delay before taking screenshot."""
+SCREENSHOT_DELAY: Final[int] = _get_environ_int("TEXTUAL_SCREENSHOT", -1, minimum=-1)
+"""Seconds delay before taking screenshot, -1 for no screenshot."""
 
 SCREENSHOT_LOCATION: Final[str | None] = get_environ("TEXTUAL_SCREENSHOT_LOCATION")
 """The location where screenshots should be written."""
@@ -107,7 +136,7 @@ def _get_textual_animations() -> AnimationLevel:
 SHOW_RETURN: Final[bool] = _get_environ_bool("TEXTUAL_SHOW_RETURN")
 """Write the return value on exit."""
 
-MAX_FPS: Final[int] = _get_environ_int("TEXTUAL_FPS", 60)
+MAX_FPS: Final[int] = _get_environ_int("TEXTUAL_FPS", 60, minimum=1)
 """Maximum frames per second for updates."""
 
 COLOR_SYSTEM: Final[str | None] = get_environ("TEXTUAL_COLOR_SYSTEM", "auto")
@@ -116,10 +145,10 @@ def _get_textual_animations() -> AnimationLevel:
 TEXTUAL_ANIMATIONS: Final[AnimationLevel] = _get_textual_animations()
 """Determines whether animations run or not."""
 
-ESCAPE_DELAY: Final[float] = _get_environ_int("ESCDELAY", 100) / 1000.0
+ESCAPE_DELAY: Final[float] = _get_environ_int("ESCDELAY", 100, minimum=1) / 1000.0
 """The delay (in seconds) before reporting an escape key (not used if the extend key protocol is available)."""
 
-SLOW_THRESHOLD: int = _get_environ_int("TEXTUAL_SLOW_THRESHOLD", 500)
+SLOW_THRESHOLD: int = _get_environ_int("TEXTUAL_SLOW_THRESHOLD", 500, minimum=100)
 """The time threshold (in milliseconds) after which a warning is logged 
 if message processing exceeds this duration.
 """
diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py
index b366efe603..dd321a2fa4 100644
--- a/src/textual/css/stylesheet.py
+++ b/src/textual/css/stylesheet.py
@@ -5,7 +5,7 @@
 from itertools import chain
 from operator import itemgetter
 from pathlib import Path, PurePath
-from typing import Iterable, NamedTuple, Sequence, cast
+from typing import Final, Iterable, NamedTuple, Sequence, cast
 
 import rich.repr
 from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
@@ -433,13 +433,14 @@ def _check_rule(
             if _check_selectors(selector_set.selectors, css_path_nodes):
                 yield selector_set.specificity
 
-    # pseudo classes which iterate over many nodes
-    # these have the potential to be slow, and shouldn't be used in a cache key
-    EXPENSIVE_PSEUDO_CLASSES = {
+    # pseudo classes which iterate over multiple nodes
+    # These shouldn't be used in a cache key
+    _EXCLUDE_PSEUDO_CLASSES_FROM_CACHE: Final[set[str]] = {
         "first-of-type",
         "last-of_type",
         "odd",
         "even",
+        "focus-within",
     }
 
     def apply(
@@ -487,7 +488,7 @@ def apply(
         cache_key: tuple | None = None
 
         if cache is not None and all_pseudo_classes.isdisjoint(
-            self.EXPENSIVE_PSEUDO_CLASSES
+            self._EXCLUDE_PSEUDO_CLASSES_FROM_CACHE
         ):
             cache_key = (
                 node._parent,
diff --git a/src/textual/demo/home.py b/src/textual/demo/home.py
index 46d4d46de9..84c1cbdac8 100644
--- a/src/textual/demo/home.py
+++ b/src/textual/demo/home.py
@@ -45,7 +45,7 @@
 
 ## Reward repeated use
 Use the mouse to explore, but Textual apps are keyboard-centric and reward repeated use.
-An experience user can operate a Textual app far faster than their web / GUI counterparts.
+An experienced user can operate a Textual app far faster than their web / GUI counterparts.
 
 ## Command palette
 A builtin command palette with fuzzy searching puts powerful commands at your fingertips.
@@ -79,7 +79,7 @@
 
 ## Builtin widgets
 
-A large [library of builtin widgets](https://textual.textualize.io/widget_gallery/), and a growing ecosystem of third party widgets on pyPi
+A large [library of builtin widgets](https://textual.textualize.io/widget_gallery/), and a growing ecosystem of third party widgets on PyPI
 (this content is generated by the builtin [Markdown](https://textual.textualize.io/widget_gallery/#markdown) widget).
     
 ## Reactive variables
@@ -110,15 +110,15 @@
 
 ## As a Python library
 
-Textual apps make be pip installed, via tools such as `pipx` or `uvx`, and other package managers.
+Textual apps may be pip installed, via tools such as `pipx` or `uvx`, and other package managers.
 
 ## As a web application
 
-It takes two lines of code to [serve your Textual app](https://github.com/Textualize/textual-serve) as web application.
+It takes two lines of code to [serve your Textual app](https://github.com/Textualize/textual-serve) as a web application.
 
 ## Managed web application
 
-With [Textual web](https://github.com/Textualize/textual-serve) you can serve multiple Textual apps on the web,
+With [Textual web](https://github.com/Textualize/textual-web) you can serve multiple Textual apps on the web,
 with zero configuration. Even behind a firewall.
 """
 
diff --git a/src/textual/demo/widgets.py b/src/textual/demo/widgets.py
index 7b37a87e32..0bef76a581 100644
--- a/src/textual/demo/widgets.py
+++ b/src/textual/demo/widgets.py
@@ -8,6 +8,7 @@
 
 from textual import containers
 from textual.app import ComposeResult
+from textual.binding import Binding
 from textual.demo.data import COUNTRIES
 from textual.demo.page import PageScreen
 from textual.reactive import reactive, var
@@ -236,7 +237,7 @@ class ListViews(containers.VerticalGroup):
 ## List Views and Option Lists
 
 A List View turns any widget in to a user-navigable and selectable list.
-An Option List for a for field to present a list of strings to select from.
+An Option List for a field to present a list of strings to select from.
 
     """
 
@@ -272,7 +273,7 @@ class Logs(containers.VerticalGroup):
     LOGS_MD = """\
 ## Logs and Rich Logs
 
-A Log widget to efficiently display a scrolling view of text, with optional highlighted.
+A Log widget to efficiently display a scrolling view of text, with optional highlighting.
 And a RichLog widget to display Rich renderables.
 
 """
@@ -446,7 +447,7 @@ class WidgetsScreen(PageScreen):
     }
     """
 
-    BINDINGS = [("escape", "unfocus", "Unfocus any focused widget")]
+    BINDINGS = [Binding("escape", "unfocus", "Unfocus any focused widget", show=False)]
 
     def compose(self) -> ComposeResult:
         with containers.VerticalScroll() as container:
diff --git a/src/textual/dom.py b/src/textual/dom.py
index aa2fdb3983..78b921ef2c 100644
--- a/src/textual/dom.py
+++ b/src/textual/dom.py
@@ -40,9 +40,10 @@
 from textual.css.errors import DeclarationError, StyleValueError
 from textual.css.match import match
 from textual.css.parse import parse_declarations, parse_selectors
-from textual.css.query import NoMatches, TooManyMatches
+from textual.css.query import InvalidQueryFormat, NoMatches, TooManyMatches
 from textual.css.styles import RenderStyles, Styles
 from textual.css.tokenize import IDENTIFIER
+from textual.css.tokenizer import TokenError
 from textual.message_pump import MessagePump
 from textual.reactive import Reactive, ReactiveError, _Mutated, _watch
 from textual.timer import Timer
@@ -1441,7 +1442,12 @@ def query_one(
         else:
             query_selector = selector.__name__
 
-        selector_set = parse_selectors(query_selector)
+        try:
+            selector_set = parse_selectors(query_selector)
+        except TokenError:
+            raise InvalidQueryFormat(
+                f"Unable to parse {query_selector!r} as a query; check for syntax errors"
+            ) from None
 
         if all(selectors.is_simple for selectors in selector_set):
             cache_key = (self._nodes._updates, query_selector, expect_type)
@@ -1505,7 +1511,12 @@ def query_exactly_one(
         else:
             query_selector = selector.__name__
 
-        selector_set = parse_selectors(query_selector)
+        try:
+            selector_set = parse_selectors(query_selector)
+        except TokenError:
+            raise InvalidQueryFormat(
+                f"Unable to parse {query_selector!r} as a query; check for syntax errors"
+            ) from None
 
         if all(selectors.is_simple for selectors in selector_set):
             cache_key = (self._nodes._updates, query_selector, expect_type)
@@ -1581,9 +1592,9 @@ def set_class(self, add: bool, *class_names: str, update: bool = True) -> Self:
             Self.
         """
         if add:
-            self.add_class(*class_names, update=update)
+            self.add_class(*class_names, update=update and self.is_attached)
         else:
-            self.remove_class(*class_names, update=update)
+            self.remove_class(*class_names, update=update and self.is_attached)
         return self
 
     def set_classes(self, classes: str | Iterable[str]) -> Self:
diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py
index 02eeb9b4bd..6ac31ac8eb 100644
--- a/src/textual/message_pump.py
+++ b/src/textual/message_pump.py
@@ -237,7 +237,10 @@ def app(self) -> "App[object]":
     @property
     def is_attached(self) -> bool:
         """Is this node linked to the app through the DOM?"""
-        if self.app._exit:
+        try:
+            if self.app._exit:
+                return False
+        except NoActiveAppError:
             return False
         node: MessagePump | None = self
         while (node := node._parent) is not None:


Test patch:
diff --git a/tests/test_constants.py b/tests/test_constants.py
new file mode 100644
index 0000000000..9eb30bc02a
--- /dev/null
+++ b/tests/test_constants.py
@@ -0,0 +1,31 @@
+from textual.constants import _get_environ_bool, _get_environ_int, _get_environ_port
+
+
+def test_environ_int(monkeypatch):
+    """Check minimum is applied."""
+    monkeypatch.setenv("FOO", "-1")
+    assert _get_environ_int("FOO", 1, minimum=0) == 0
+    monkeypatch.setenv("FOO", "0")
+    assert _get_environ_int("FOO", 1, minimum=0) == 0
+    monkeypatch.setenv("FOO", "1")
+    assert _get_environ_int("FOO", 1, minimum=0) == 1
+
+
+def test_environ_bool(monkeypatch):
+    """Anything other than "1" is False."""
+    monkeypatch.setenv("BOOL", "1")
+    assert _get_environ_bool("BOOL") is True
+    monkeypatch.setenv("BOOL", "")
+    assert _get_environ_bool("BOOL") is False
+    monkeypatch.setenv("BOOL", "0")
+    assert _get_environ_bool("BOOL") is False
+
+
+def test_environ_port(monkeypatch):
+    """Valid ports are between 0 and 65536."""
+    monkeypatch.setenv("PORT", "-1")
+    assert _get_environ_port("PORT", 80) == 80
+    monkeypatch.setenv("PORT", "0")
+    assert _get_environ_port("PORT", 80) == 0
+    monkeypatch.setenv("PORT", "65536")
+    assert _get_environ_port("PORT", 80) == 80
diff --git a/tests/test_query.py b/tests/test_query.py
index 4030003866..1199f7d5f0 100644
--- a/tests/test_query.py
+++ b/tests/test_query.py
@@ -14,6 +14,22 @@
 from textual.widgets import Input, Label
 
 
+def test_query_errors():
+    app = App()
+
+    with pytest.raises(InvalidQueryFormat):
+        app.query_one("foo_bar")
+
+    with pytest.raises(InvalidQueryFormat):
+        app.query("foo_bar")
+
+    with pytest.raises(InvalidQueryFormat):
+        app.query("1")
+
+    with pytest.raises(InvalidQueryFormat):
+        app.query_one("1")
+
+
 def test_query():
     class View(Widget):
         pass