Skip to content

fix(reading-mode): nest the task card inside the header so virtualisation cannot delete it - #2300

Merged
callumalpass merged 2 commits into
callumalpass:mainfrom
nelsonlove:fix/2255-reading-mode-card-reinjection
Sep 13, 2026
Merged

fix(reading-mode): nest the task card inside the header so virtualisation cannot delete it#2300
callumalpass merged 2 commits into
callumalpass:mainfrom
nelsonlove:fix/2255-reading-mode-card-reinjection

Conversation

@nelsonlove

Copy link
Copy Markdown
Contributor

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-sizer that 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-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, and shoves the page down by its own height.

Measured on 4.12.5 by wrapping the scroller's scrollTop setter and capturing a stack on every write. One caller dominates:

e.updateVirtualDisplay  <-  e.onScroll

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:

note card occupied height correction step
A 579px +578 / +579
B 690px +690 / +698

The corrections arrived every 218-262ms, which is DEFAULT_SCROLL_QUIET_PERIOD_MS plus a frame. The quiet period did not stop the cycle; it set its tempo.

The change

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.

Issue #2255 raised this route and found it insufficient on its own, which was correct: .mod-header.mod-ui is itself sections[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:

  • The observer skips re-injection while the header is not rendered. That detach is Obsidian's own virtualisation and must be left alone rather than fought. The card is restored when the header comes back, which is exactly when it can be seen.
  • Injection anchors on the properties block, not 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.

Tests

tests/unit/editor/MarkdownWidgetInsertion.test.ts pins the mechanism rather than the implementation, by simulating a render pass:

  • a widget placed between sections is deleted by it
  • a widget nested in the header survives it
  • with the header detached, the old insertion reference resolves to the first rendered section - the trap this fixes
  • with the header present but the properties block not yet rendered, injection is refused rather than landing above it

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

  • The card is absent while the reader is scrolled past the top of the note. It is inside the header, and Obsidian detaches that section. This is invisible in practice - the card lives at the top, so it cannot be seen from further down - but it is a real behaviour change from "always in the DOM".
  • Requiring the properties block means a task note that renders in reading mode with no .metadata-container at 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.
  • The :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.

Nelson Love and others added 2 commits September 8, 2026 03:48
…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
@callumalpass

Copy link
Copy Markdown
Owner

Thanks for the fix and detailed explanation. Merged into main—much appreciated!

pull Bot pushed a commit to subhash011/tasknotes that referenced this pull request Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Reading mode - the task card is deleted and re-injected on every render pass, making the note jump while scrolling

2 participants