Skip to content

Fix comment overlay cloning block ancestors on repaint - #20

Merged
tnsardesai merged 1 commit into
mainfrom
hypeship/fix-overlay-callout-clone
Jul 29, 2026
Merged

Fix comment overlay cloning block ancestors on repaint#20
tnsardesai merged 1 commit into
mainfrom
hypeship/fix-overlay-callout-clone

Conversation

@tnsardesai

@tnsardesai tnsardesai commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Problem

In the sandboxed doc overlay, a highlight whose range straddles an element boundary (e.g. one that starts inside a .callout and ends in the next block) makes empty block clones pile up in the DOM — one per repaint. Because paint() runs on resize and on every jh:anchors / jh:reactions message, the stray blocks accumulate over time and render as empty grey bars that keep growing above the content.

Root cause

paint() wrapped each highlight segment with a single span via Range.extractContents() + insertNode(). When the segment's range spans an element boundary, extractContents() clones the partially-selected ancestor into the fragment (standard DOM behavior — cloning the parent tags needed to keep the fragment valid). clearHighlights() only unwraps our own [data-jh-seg] spans, so those cloned empty blocks survive every clear and re-clone on the next paint.

Fix

Wrap one text node at a time (sliceNodes) instead of one span across the whole segment range. A per-node range can only ever split its own text node — it never partially-selects an element, so no ancestor is cloned and the paint is fully reversible by clearHighlights(). A segment spanning several nodes becomes several adjacent spans sharing the same data-cover (identical styling); the chip, position, and focus code already treat segEls as a set, so this is transparent to them. This also subsumes the old start-bias workaround (locateStart), since we never wrap a block element and a highlight at a block's leading edge can no longer collapse into an unpainted inline span.

Net: -43 / +36 in one file, and three now-dead helpers (locate, locateStart, mkRange) are removed.

Verification

Existing suite: 162 passed. tsc --noEmit clean.

Behavioral before/after in a real headless Chrome — same doc (a .callout header whose highlight straddles into the next block), 8 repaints:

overlay callouts after 8 repaints highlight painted
before 2 → 10 (accumulating) yes
after 1 → 1 (stable) yes (2 spans)

The fix stops the accumulation while still painting the highlight across the block boundary.

🤖 Generated with Claude Code


Note

Medium Risk
Changes core DOM painting for all comment/reaction highlights in the overlay iframe; behavior is localized to one file but affects every repaint path.

Overview
Fixes accumulating empty block elements (e.g. grey bars above content) when comment/reaction highlights are repainted in the sandboxed doc overlay.

paint() no longer wraps each segment with one data-jh-seg span over a multi-node Range. It uses sliceNodes to apply **extractContents / insertNode only within a single text node at a time. That avoids Range.extractContents() cloning straddled ancestors (callouts, paragraphs) into the wrapper—clones that clearHighlights() could not remove and that stacked on every resize or jh:anchors / jh:reactions repaint.

A logical segment that crosses text nodes becomes several adjacent spans with the same data-cover and depth styling; chip, position, and focus logic already treat segEls as a set. locate, locateStart, and mkRange are removed; the old start-bias workaround is unnecessary because block elements are never wrapped.

Reviewed by Cursor Bugbot for commit 224c05e. Bugbot is set up for automated code reviews on this repo. Configure here.

The overlay wrapped each highlight segment with a single span via
Range.extractContents()/insertNode(). When a segment's range straddled an
element boundary (a highlight starting inside a callout and ending in the next
block), extractContents cloned the partially-selected ancestor into the
wrapper. clearHighlights() only unwraps our own [data-jh-seg] spans, so the
cloned empty blocks survived and accumulated one per repaint — and paint runs
on resize and on every anchors/reactions message, so the stray blocks (empty
callouts rendering as growing grey bars) piled up over time.

Wrap one text node at a time instead: a per-node range can only split its own
text node, never partially-select an element, so no ancestor is cloned and the
paint is fully reversible by clearHighlights. A segment spanning several nodes
becomes several adjacent spans sharing the same cover; the chip/position/focus
code already treats segEls as a set. This also subsumes the old start-bias
workaround, since we never wrap a block element and a highlight at a block's
leading edge can no longer vanish.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
justhtml Ready Ready Preview, Comment Jul 29, 2026 1:21am

@tnsardesai
tnsardesai marked this pull request as ready for review July 29, 2026 01:23
@tnsardesai
tnsardesai merged commit 3b8478b into main Jul 29, 2026
3 checks passed
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.

1 participant