Skip to content

fix(react): message-scroller - prevent scroll clamp jitter while an anchored turn streams#11183

Open
devnomic wants to merge 1 commit into
shadcn-ui:mainfrom
devnomic:fix/message-scroller-anchored-clamp-jitter
Open

fix(react): message-scroller - prevent scroll clamp jitter while an anchored turn streams#11183
devnomic wants to merge 1 commit into
shadcn-ui:mainfrom
devnomic:fix/message-scroller-anchored-clamp-jitter

Conversation

@devnomic

Copy link
Copy Markdown
Contributor

The problem

While a reply streams below an anchored turn (defaultScrollPosition="last-anchor" / a newly appended scrollAnchor item), the pinned turn visibly jitters: it shifts down a few pixels and snaps back, repeatedly.

The mechanism, confirmed with frame-by-frame instrumentation in a real chat app:

  1. When a turn is anchored, the tail spacer is sized for an exact fit, so the pinned scrollTop sits at the very bottom of the scroll range (scrollTop === scrollHeight - clientHeight).
  2. Streamed content transiently shrinks all the time: incomplete markdown reflows as it completes (a closing code fence, a list merging), and pending markers (a "Thinking" row) collapse when real content arrives.
  3. Any dip shrinks scrollHeight below the pinned scrollTop. The browser clamps scrollTop and the whole transcript shifts down.
  4. The resize handler re-anchors and scrolls back, but since fix(message-scroller): avoid ResizeObserver loop error by coalescing resize handling into rAF #11085 it is (correctly) coalesced onto requestAnimationFrame, so the clamped frame always paints first.

Captured trace of one wiggle (2px dip on a pending-marker swap):

scrollHeight 8654 -> 8652, scrollTop clamped 8004 -> 8002 (anchor moved 80 -> 82)
+75ms: content grows, reanchor restores scrollTop 8004 (anchor back to 80)

The fix

Make it impossible for a dip to clamp, instead of correcting after it does:

  • Slack (ANCHORED_TAIL_SLACK = 128): an anchored placement pads the tail spacer beyond the exact fit, so the pinned position is never at max scroll. The slack is blank space below the fold, invisible at the pinned position. A turn placed with no spacer (it already fills the viewport) stays unpadded: it has natural slack below the fold, and a padded spacer would read as "placed with spacer room" to the autoScroll handoff.
  • Grow-or-hold: while the turn stays anchored, re-anchoring never shrinks the spacer (max(padded, current)), keeping scrollHeight monotonic for the whole stream.
  • relaxTailSpacer: leaving the anchor hold via user scroll intent trims the spacer back to the exact fit, so the reader cannot scroll into blank space the stream no longer needs. The held height is always >= the exact one, so the trim itself never clamps.
  • The autoScroll handoff in handleResize now keys off the exact remaining height (recomputed via getTailSpacerHeight) instead of the styled spacer hitting 0, which a held spacer never does. Same handoff timing as before.

Tests

Two new tests: pads and holds the anchored tail spacer so a transient dip cannot clamp the pinned turn and trims the held spacer slack on user scroll intent. Full message-scroller suite passes (62/62).

Independent of #11182 (different code paths; no conflicts).

Related

One footgun worth knowing about that this PR does not touch: putting content-visibility: auto + contain-intrinsic-size on scroller items breaks anchored placement, because a freshly inserted item is measured at its intrinsic-size placeholder before first paint, so the anchor scroll target and spacer are computed from a wrong layout. Consumers should keep those styles off the last turn's items.

@vercel

vercel Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Someone is attempting to deploy a commit to the shadcn-pro Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions

Copy link
Copy Markdown
Contributor

Can you sign the commits please? See https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits. Thank you.

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