Markdown.update can leave a detached MarkdownParagraph in the compositor and crash MouseDown selection
Describe the bug
A mouse-down event may crash Textual while a Markdown widget is being updated.
During Markdown.update(), an old MarkdownParagraph can become detached from the DOM while the compositor still returns that widget from get_widget_and_offset_at().
Screen._forward_event() then calculates:
container = content_widget.parent For the detached paragraph, container is None. The selection code subsequently accesses container.region, raising:
AttributeError: 'NoneType' object has no attribute 'region'This was originally observed from a real mouse click in a running Textual application. I then reproduced it independently using only Textual, without any application-specific code.
Minimal reproducible example
import asyncio
from textual import events
from textual.app import App, ComposeResult
from textual.widgets import Markdown
class ReproApp(App[None]):
def compose(self) -> ComposeResult:
yield Markdown(
"# Before\n\nClick target paragraph.",
id="document",
)
async def reproduce() -> None:
app = ReproApp()
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause(0.1)
document = app.query_one("#document", Markdown)
old_paragraph = document.query("MarkdownParagraph").first()
x = old_paragraph.region.x + 1
y = old_paragraph.region.y
update = document.update(
"\n\n".join(
f"replacement paragraph {index}"
for index in range(201)
)
)
try:
for step in range(1000):
await asyncio.sleep(0)
hit_widget, hit_offset = (
app.screen.get_widget_and_offset_at(x, y)
)
if (
old_paragraph.parent is None
and hit_widget is old_paragraph
):
print(f"detached at step: {step}")
print(
"compositor returned detached widget:",
hit_widget is old_paragraph,
)
print("hit offset:", hit_offset)
app.screen._forward_event(
events.MouseDown(
None,
x,
y,
0,
0,
1,
False,
False,
False,
)
)
return
raise RuntimeError("Race window was not observed")
finally:
await update
asyncio.run(reproduce())On my machine this observes the detached widget after three event-loop iterations and raises:
detached at step: 3
compositor returned detached widget: True
hit offset: Offset(x=0, y=0)
AttributeError: 'NoneType' object has no attribute 'region' The direct _forward_event() call makes the already-observed race window deterministic. A real terminal mouse event enters the same method through App.on_event().
Expected behavior
A mouse event during a Markdown.update() should either:
- use an attached widget from the current composition; or
- ignore a stale compositor hit safely.
It should not terminate the application.
Actual behavior
The compositor temporarily returns a detached MarkdownParagraph. Its parent is None, but Screen._forward_event() dereferences container.region while initializing SelectState.
Environment
- Textual: 8.2.8
- Rich: 15.0.0
- Python: 3.11.15
- OS: macOS 26.4
- Architecture: arm64
Additional analysis
The relevant code is in Screen._forward_event():
content_widget = select_widget
container = (
content_widget
if isinstance(content_widget, Screen)
else content_widget.parent
)The current implementation assumes that the widget returned by the compositor is still attached and therefore has a parent.
Potential fixes include:
- filtering detached widgets in
Screen.get_widget_and_offset_at(); or - checking that
containeris notNonebefore creatingSelectStart.
I verified locally that ignoring a hit when widget.is_attached is false prevents the crash while preserving normal Textual text selection.
The current main-branch implementation appears to contain the same unguarded dereference:
https://github.com/Textualize/textual/blob/main/src/textual/screen.py
Source: Textualize/textual