fix(reading-mode): nest the task card inside the header so virtualisation cannot delete it - #2300
Merged
callumalpass merged 2 commits intoSep 13, 2026
Conversation
…tion cannot delete it Reading mode is virtualised, and every render pass ends with `sizerEl.setChildrenInPlace([pusherEl, ...shownSections])`, which deletes any direct child of `.markdown-preview-sizer` that Obsidian did not put there. The task card was injected between sections, so it was deleted on every pass and re-injected on the next frame. During a scroll that cycle ran continuously and dragged the reader down the note until it hit the bottom (callumalpass#2255). The scroll quiet period added in b29a29e defers re-injection but cannot stop this, because the problem is where the re-injection lands rather than how often it happens. Once the reader scrolls past the top of the note, Obsidian has detached `.mod-header.mod-ui` too, so `getMetadataOrHeaderInsertionReference` falls through to the preview pusher - whose next sibling is the first section that is *currently rendered*, not the first section of the note. The card is therefore inserted into the middle of the text being read, shoving the page down by its own height. Measured on two notes, each correction was exactly the card's occupied height (579px and 690px), and the corrections arrived every 218-262ms, which is the 200ms quiet period plus a frame. `setChildrenInPlace` only manages *direct* children, so nesting the card one level deeper leaves it untouched by the render pass. It is now placed inside the header, after the properties block. Two supporting changes make that safe: - The observer skips re-injection while the header is not rendered. Obsidian detaches the header with the card inside it when the reader scrolls past the top; that must be left alone rather than fought, and the card is restored when the header comes back. - Injection anchors on the properties block rather than the end of the header. Obsidian builds the header in stages, so appending during an early pass put the card above the properties, which then rendered underneath and shoved it down - a visible jolt on every note open. Nesting also corrects the height accounting noted in the issue: `measureSection` takes a section's height as the gap between its own `offsetTop` and its next sibling's, so a widget between sections is counted in neither. Inside the header it is counted in that section. Spacing is scoped to `.markdown-preview-view .mod-header` so Live Preview and canvas are unchanged. The gap below the card uses `--p-spacing`, the note's own gap between blocks. The `:has()` selector that trims the properties block's bottom margin needs Chromium 105+; where it does not apply the gap is simply larger, never broken. Note that requiring the properties block means a task note rendering with no `.metadata-container` at all would not receive a card.
`observeReadingModeWidgetMutations` is shared: TaskCardNoteDecorations and RelationshipsDecorations both use it. Requiring the header anchor unconditionally would have blocked the relationships widget from injecting whenever the header is detached - which is precisely where that widget lives, since it defaults to the bottom of the note. The guard is now `requireHeaderAnchor`, off by default, set only by the task card, which is the widget that nests inside the header. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SBWuKe71PRSJaN2SM4xUFJ
Owner
|
Thanks for the fix and detailed explanation. Merged into main—much appreciated! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2255.
The problem
Reading mode is virtualised, and every render pass ends with
sizerEl.setChildrenInPlace([pusherEl, ...shownSections]), which deletes any direct child of.markdown-preview-sizerthat Obsidian did not put there. The task card is injected between sections, so it is deleted on every pass and re-injected on the next frame. During a scroll that cycle runs continuously and drags the reader down the note until it hits the bottom.The scroll quiet period added in b29a29e defers re-injection, but it cannot stop this, because the problem is where the re-injection lands rather than how often it happens. Once the reader has scrolled past the top of the note, Obsidian has detached
.mod-header.mod-uitoo, sogetMetadataOrHeaderInsertionReferencefalls through to the preview pusher - whose next sibling is the first section that is currently rendered, not the first section of the note. The card is therefore inserted into the middle of the text being read, and shoves the page down by its own height.Measured on 4.12.5 by wrapping the scroller's
scrollTopsetter and capturing a stack on every write. One caller dominates:and the corrections are a constant step that repeats to the bottom of the note. The step equals the card's own occupied height, on each note:
The corrections arrived every 218-262ms, which is
DEFAULT_SCROLL_QUIET_PERIOD_MSplus a frame. The quiet period did not stop the cycle; it set its tempo.The change
setChildrenInPlaceonly manages direct children, so nesting the card one level deeper leaves it untouched by the render pass. It is now placed inside the header, after the properties block.Issue #2255 raised this route and found it insufficient on its own, which was correct:
.mod-header.mod-uiis itselfsections[0], so Obsidian detaches it - with the card inside - when the reader scrolls past the top, and the observer then re-injects as a direct child and restarts the cycle. Two supporting changes close that:Nesting also corrects the height accounting noted in the issue:
measureSectiontakes a section's height as the gap between its ownoffsetTopand its next sibling's, so a widget between sections is counted in neither. Inside the header it is counted in that section.Tests
tests/unit/editor/MarkdownWidgetInsertion.test.tspins the mechanism rather than the implementation, by simulating a render pass:33 tests pass across all 6 editor suites.
Verification
Built and run against a real vault on 4.12.5. The runaway is gone, the card holds its position under scrolling, and it renders once in its final place with no jolt.
Trade-offs, stated plainly
.metadata-containerat all would not receive a card. If that case exists, a fallback to the end of the header once the header is known to be complete would cover it.:has()selector that trims the properties block's bottom margin needs Chromium 105+. Where it does not apply, the gap is simply larger; nothing breaks.Spacing is scoped to
.markdown-preview-view .mod-header, so Live Preview and canvas are unchanged. The gap below the card uses--p-spacing, the note's own gap between blocks.Happy to reshape any of this - the placement question in particular is yours to call.