fix(react): hold newly appended scroll anchors while autoScroll is enabled (#11100)

The tail spacer makes a freshly anchored turn read as "at the end", so
reconcileFollowMode re-armed following-bottom one frame after the anchor
placement and the first streamed chunk yanked the reader off the anchor.
Exclude the anchored-to-message hold from arming, matching the existing
settling-jump exclusion.


Claude-Session: https://claude.ai/code/session_0186fcQpHo1aLQC4wRpwKFFu

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
shadcn
2026-07-07 10:04:10 +04:00
committed by GitHub
parent 628e427231
commit dd6ce77cf1
3 changed files with 45 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
---
"@shadcn/react": patch
---
Fix MessageScroller abandoning a newly appended scrollAnchor placement when autoScroll is enabled, so the anchored turn holds at the reading line while the reply streams.

View File

@@ -459,6 +459,40 @@ describe("MessageScroller", () => {
})
})
it("holds a newly appended anchor at the reading line while the reply streams with autoScroll", async () => {
const rendered = await renderTestScroller({
autoScroll: true,
defaultScrollPosition: "end",
messages: [
{ id: "message-1", height: 80 },
{ id: "message-2", height: 80 },
{ id: "message-3", height: 80 },
],
})
expect(rendered.viewport().scrollTop).toBe(140)
await rendered.rerender(
[
{ id: "message-1", height: 80 },
{ id: "message-2", height: 80 },
{ id: "message-3", height: 80 },
{ id: "message-4", height: 40, scrollAnchor: true },
],
{ autoScroll: true }
)
expect(rendered.viewport().scrollTop).toBe(176)
expect(rendered.message("message-4").getBoundingClientRect().top).toBe(64)
// Streamed growth below the anchor must not yank the reader to the live
// edge; the anchored turn holds at the reading line.
rendered.message("message-4").dataset.testHeight = "160"
await triggerResize(rendered.content())
expect(rendered.message("message-4").getBoundingClientRect().top).toBe(64)
})
it("applies the default end target after async messages mount", async () => {
const rendered = await renderTestScroller({
messages: [],

View File

@@ -130,13 +130,17 @@ function useMessageScrollerController({
// Owns the one follow-bottom transition: arm at the bottom, release on any
// scroll away (including a scrollbar drag), suppressed during a programmatic
// scroll so the auto-scroll animation cannot release itself.
// scroll so the auto-scroll animation cannot release itself. Arming also
// skips the anchored-to-message hold: the tail spacer makes a freshly
// anchored turn read as "at the end", and re-arming there would let the
// first streamed chunk yank the reader off the anchor.
const reconcileFollowMode = React.useCallback(
(scrollable: MessageScrollerScrollable) => {
if (
autoScrollRef.current &&
!scrollable.end &&
modeRef.current !== "settling-jump"
modeRef.current !== "settling-jump" &&
modeRef.current !== "anchored-to-message"
) {
modeRef.current = "following-bottom"
} else if (