#6643·textual

Markdown.update can leave a detached MarkdownParagraph in the compositor and crash MouseDown selection

Author: abs1uCreated Jul 13, 2026Updated Sep 8, 2026

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:

python
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

python
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():

python
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:

  1. filtering detached widgets in Screen.get_widget_and_offset_at(); or
  2. checking that container is not None before creating SelectStart.

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