mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-07 14:08:47 +00:00
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:
5
.changeset/message-scroller-autoscroll-anchor-hold.md
Normal file
5
.changeset/message-scroller-autoscroll-anchor-hold.md
Normal 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.
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user