From dd6ce77cf1606eee2839368a97cc35417f763232 Mon Sep 17 00:00:00 2001 From: shadcn Date: Tue, 7 Jul 2026 10:04:10 +0400 Subject: [PATCH] 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 --- ...message-scroller-autoscroll-anchor-hold.md | 5 +++ .../message-scroller.test.tsx | 34 +++++++++++++++++++ .../use-message-scroller-controller.ts | 8 +++-- 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 .changeset/message-scroller-autoscroll-anchor-hold.md diff --git a/.changeset/message-scroller-autoscroll-anchor-hold.md b/.changeset/message-scroller-autoscroll-anchor-hold.md new file mode 100644 index 0000000000..bf5e60d494 --- /dev/null +++ b/.changeset/message-scroller-autoscroll-anchor-hold.md @@ -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. diff --git a/packages/react/src/message-scroller/message-scroller.test.tsx b/packages/react/src/message-scroller/message-scroller.test.tsx index 0410012dcc..29c201666a 100644 --- a/packages/react/src/message-scroller/message-scroller.test.tsx +++ b/packages/react/src/message-scroller/message-scroller.test.tsx @@ -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: [], diff --git a/packages/react/src/message-scroller/use-message-scroller-controller.ts b/packages/react/src/message-scroller/use-message-scroller-controller.ts index 33b2ec88ae..79de6133a7 100644 --- a/packages/react/src/message-scroller/use-message-scroller-controller.ts +++ b/packages/react/src/message-scroller/use-message-scroller-controller.ts @@ -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 (