From e7b105fd4e0f4ea8645da7c153cf067234489d65 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 7 Oct 2026 15:01:24 -0400 Subject: [PATCH 1/2] Validate attachment only when borrowing an actual held mutation box --- src/textual/_compositor.py | 2 +- src/textual/widget.py | 7 +++++- tests/test_mutation_box_admission.py | 37 ++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 tests/test_mutation_box_admission.py diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 2927259874..c25c191c8d 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -1664,7 +1664,7 @@ def _publication_cuts(self, exclusions: Iterable[Region]) -> list[list[int]]: def mutation_box(self, widget: Widget) -> BoxModel | None: """Borrow the committed root box; an unplaced mutation has no size yet.""" - if widget not in self._layout_geometry: + if widget not in self._layout_geometry or not widget.is_attached: return None geometry = self._layout_geometry[widget] size = Size() if geometry is None else geometry.region.size diff --git a/src/textual/widget.py b/src/textual/widget.py index e30322eab5..faac217786 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1945,7 +1945,12 @@ def _get_box_model( Returns: The size and margin for this widget. """ - if self.is_attached and (box := self.screen._compositor.mutation_box(self)) is not None: + try: + box = self.screen._compositor.mutation_box(self) + except NoScreen: + # Unmounted widgets still have ordinary native measurements. + box = None + if box is not None: # Parent layouts measure the same committed box as Compositor; # never descend through an actively changing child to size siblings. return box diff --git a/tests/test_mutation_box_admission.py b/tests/test_mutation_box_admission.py new file mode 100644 index 0000000000..57f953c1d2 --- /dev/null +++ b/tests/test_mutation_box_admission.py @@ -0,0 +1,37 @@ +from fractions import Fraction + +from textual.app import App +from textual.widgets import Static + + +class ObservedAttachment(Static): + attachment_reads = 0 + + @property + def is_attached(self) -> bool: + self.attachment_reads += 1 + return super().is_attached + + +async def test_ordinary_box_measurement_does_not_acquire_mutation_attachment(): + app = App() + async with app.run_test(size=(60, 20)) as pilot: + widget = ObservedAttachment("native content") + widget.styles.width = 20 + widget.styles.height = 3 + await app.mount(widget) + await pilot.pause() + widget.attachment_reads = 0 + boxes = [widget._get_box_model(app.size, app.size, Fraction(60), Fraction(20)) + for _ in range(20)] + assert widget.attachment_reads == 0 + assert all(box == boxes[0] for box in boxes) + assert (boxes[0].width, boxes[0].height) == (20, 3) + + # NoScreen is the original unmounted boundary, not a refusal to measure + # an ordinary authored box in this active application. + detached = ObservedAttachment("not mounted") + detached.styles.width = 7 + detached.styles.height = 2 + box = detached._get_box_model(app.size, app.size, Fraction(60), Fraction(20)) + assert (box.width, box.height) == (7, 2) From d1046fb21fdb8b9017cec835a6e1605dc4a4eb6e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 7 Oct 2026 15:07:06 -0400 Subject: [PATCH 2/2] Preserve attached screen refusal and record matched channel App acceptance --- .../mutation-box-admission-20261007/README.md | 45 +++++++++++++++++++ .../app-result.json | 11 +++++ src/textual/widget.py | 3 ++ tests/test_mutation_box_admission.py | 27 +++++++++++ 4 files changed, 86 insertions(+) create mode 100644 evidence/mutation-box-admission-20261007/README.md create mode 100644 evidence/mutation-box-admission-20261007/app-result.json diff --git a/evidence/mutation-box-admission-20261007/README.md b/evidence/mutation-box-admission-20261007/README.md new file mode 100644 index 0000000000..f1d5b04264 --- /dev/null +++ b/evidence/mutation-box-admission-20261007/README.md @@ -0,0 +1,45 @@ +# Mutation-box admission + +Ordinary Widget box measurements previously called is_attached before asking +Compositor whether the widget owns a held mutation box. The original retained +channel cProfile contains about28,000 box queries, nearly all taking that check. +Its wall/cumulative profile is not a reliable attribution of total UI CPU. + +The compositor already owns transient layout_geometry, filled only for the +acquired mutation roots during original arrangement and restored in finally. +mutation_box now checks membership first and attachment only for a candidate. +Widget still uses its actual screen descriptor. NoScreen permits ordinary +measurement only when is_attached is false; an attached source retains the +original NoScreen refusal. Other errors propagate. Custom attachment and screen +behavior stays dynamically dispatched; no snapshot or cached lifetime is added. + +Native arrangement, style/child epochs, full and visible maps, hit geometry, +clipping, mutation exclusions and held-box margins remain unchanged. This removes +unnecessary attachment traversal from ordinary measurement; it does not reduce +required scroll placement frames or certify all source invalidations redundant. + +Existing Package parsed250 production modules with zero omissions; mutation_box +has exactly one consumer in Widget._get_box_model. New ordinary/detached sizing +plus affected held-root geometry/callback publication controls:2PASS/.49s. +Final attached/custom NoScreen refusal control:1PASS/.36s. + +One original unprofiled channel-history driver run retained installed Toad ef421 +and Core d4b9, replacing only native source at e7b105fd4. Native includes ffcb82 +and PreparedTextArea97. Original readonly runtime: +/home/ts/wt/toad-sidebar-context-pointer-20261006/.artifacts/channel-history-delivery-20261007/runtime +Driver history_check.py from the same artifact; HISTORY_PROFILE=0 and new owned +output /home/ts/.cache/agent-scratch/native98-channel-history-20261007. + +160 authored initial private messages,20 incoming private messages,30 PageUp; +no provider, public input, package or installed pin change. Terminal0, empty +stderr,120 mounted rows/oldest sequence33, no App exception. p95 loop28.59ms, +maximum134.26ms,38 intervals over50ms. Matching unprofiled baseline p95 28.84ms, +maximum159.89ms,46 intervals over50ms. One pair establishes no reliable overall +improvement; meaningful pauses remain. Pilot global-idle waits remain included. +Original runtime_fixture owns teardown; controller joined and no driver/output +process reference remained. Exact child birth identities were not recorded. + +The subsequent final source change only restores the original attached NoScreen +refusal, exercised separately above. The normal mounted App path is unchanged; +no repeat App run was needed. Source-only/author-App evidence, not installed +physical acceptance. Parent owns durable delivery and affected live observation. diff --git a/evidence/mutation-box-admission-20261007/app-result.json b/evidence/mutation-box-admission-20261007/app-result.json new file mode 100644 index 0000000000..0467b36491 --- /dev/null +++ b/evidence/mutation-box-admission-20261007/app-result.json @@ -0,0 +1,11 @@ +{ + "main_loop_samples": 2504, + "median_ms": 5.9183514676988125, + "p95_ms": 28.594268020242453, + "max_ms": 134.25777899101377, + "gaps_above_50ms": 38, + "mounted_rows": 120, + "oldest_sequence": 33, + "app_error": null, + "profiling": false +} diff --git a/src/textual/widget.py b/src/textual/widget.py index faac217786..d01a5afe13 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1949,6 +1949,9 @@ def _get_box_model( box = self.screen._compositor.mutation_box(self) except NoScreen: # Unmounted widgets still have ordinary native measurements. + # An attached source with no screen retains the original refusal. + if self.is_attached: + raise box = None if box is not None: # Parent layouts measure the same committed box as Compositor; diff --git a/tests/test_mutation_box_admission.py b/tests/test_mutation_box_admission.py index 57f953c1d2..f28d6177aa 100644 --- a/tests/test_mutation_box_admission.py +++ b/tests/test_mutation_box_admission.py @@ -1,6 +1,9 @@ from fractions import Fraction +import pytest + from textual.app import App +from textual.widget import NoScreen from textual.widgets import Static @@ -35,3 +38,27 @@ async def test_ordinary_box_measurement_does_not_acquire_mutation_attachment(): detached.styles.height = 2 box = detached._get_box_model(app.size, app.size, Fraction(60), Fraction(20)) assert (box.width, box.height) == (7, 2) + + +class RefusedScreen(ObservedAttachment): + refuse_screen = False + + @property + def screen(self): + if self.refuse_screen: + raise NoScreen("original custom source refused its screen") + return super().screen + + +async def test_attached_custom_screen_refusal_is_not_unmounted_measurement(): + app = App() + async with app.run_test() as pilot: + widget = RefusedScreen("attached source") + await app.mount(widget) + await pilot.pause() + widget.refuse_screen = True + try: + with pytest.raises(NoScreen, match="original custom source"): + widget._get_box_model(app.size, app.size, Fraction(80), Fraction(24)) + finally: + widget.refuse_screen = False