-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Description
Description
When dragging the scrollbar in MarkdownViewer, the UI freezes for ~50–200ms
roughly every 1 to 2 seconds. Other scrollable widgets (DataTable, ListView,
Tree) are not affected.
Reproduction
from textual.app import App, ComposeResult
from textual.widgets import MarkdownViewer
MARKDOWN = "# Title\n\n" + ("Some paragraph text.\n\n" * 200)
class TestApp(App):
def compose(self) -> ComposeResult:
yield MarkdownViewer(MARKDOWN)
TestApp().run()Drag the scrollbar up and down. The stutter is clearly visible with a
200-paragraph document.
Adding gc.disable() at startup completely eliminates the stutter,
confirming the root cause is Python's cyclic garbage collector.
Root cause
MarkdownViewer creates a large number of MarkdownBlock child widgets
(one per block element). Each widget holds at least three reference cycles
through its Styles objects:
Widget → _css_styles (Styles) → Styles.node → Widget
Widget → _inline_styles (Styles) → Styles.node → Widget
Widget → styles (RenderStyles) → RenderStyles.node → Widget
These cycles are defined in dom.py:205–209 and css/styles.py:883, 1334.
A 200-block document produces 600+ tracked cycles. Python's gen2 GC
scans all tracked objects to find and break cycles. With this many objects
the scan takes 50–200ms, blocking the asyncio event loop and causing visible
stutters. The ~2s periodicity matches the default gen2 threshold
(700 × 10 × 10 net allocations).
Other widgets are unaffected because DataTable and Tree use the Line API
(no child widgets per row), and ListView has far fewer, simpler children.
Workaround
Monkey-patching Markdown.update to call gc.freeze() after the document
finishes mounting eliminates the stutter. gc.freeze() moves all currently
tracked objects to a permanent generation that the cyclic GC never scans.
import gc
from textual.widgets._markdown import Markdown
_orig_update = Markdown.update
def _patched_update(self, markdown: str):
gc.unfreeze() # allow previous document's widgets to be collected
result = _orig_update(self, markdown)
result._future.add_done_callback(lambda _: (gc.collect(), gc.freeze()))
return result
Markdown.update = _patched_update
def _patched_on_unmount(self):
gc.unfreeze()
gc.collect()
Markdown.on_unmount = _patched_on_unmountSuggested fix
The cycles exist because Styles and RenderStyles hold a strong reference
back to their owner widget via the node attribute. Converting node to a
weakref would break all cycles at the source and eliminate the GC pressure
entirely, without affecting behaviour (all existing if self.node is None
null-checks remain valid).