Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/end-anchor-scroll-element-resize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/virtual-core': patch
---

With `anchorTo: 'end'`, keep an end-pinned viewport pinned when the scroll element shrinks, for example when the window is resized or the app changes its height. Previously the browser kept `scrollTop`, leaving the last items below the fold.
2 changes: 1 addition & 1 deletion docs/api/virtualizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ anchorTo?: 'start' | 'end'

Controls which side of the scrollable content should be treated as the stable anchor when list data changes. The default `'start'` preserves TanStack Virtual's existing top/left anchored behavior.

Set `anchorTo: 'end'` for chat, logs, and reverse/inverted feeds. In end-anchored mode, the virtualizer keeps the current visible item stable when older items are prepended, and keeps an end-pinned viewport pinned when the last item grows during streaming output. See the [Chat guide](../chat) for the full pattern.
Set `anchorTo: 'end'` for chat, logs, and reverse/inverted feeds. In end-anchored mode, the virtualizer keeps the current visible item stable when older items are prepended, and keeps an end-pinned viewport pinned when the last item grows during streaming output or the scroll element shrinks. See the [Chat guide](../chat) for the full pattern.

For prepend stability, use a stable `getItemKey` based on each item's persistent id. Index keys cannot distinguish prepends from appends after items shift.

Expand Down
2 changes: 1 addition & 1 deletion docs/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ followOnAppend: 'smooth'

### Keep streaming output pinned

Streaming chat responses usually grow the last item many times. In end-anchored mode, if the viewport is pinned to the end before the measured size changes, the virtualizer adjusts by the size delta and keeps the bottom stuck to the latest output.
Streaming chat responses usually grow the last item many times. In end-anchored mode, if the viewport is pinned to the end before the measured size changes, the virtualizer adjusts by the size delta and keeps the bottom stuck to the latest output. The same applies when the scroll element shrinks, for example when the window is resized or the app changes its height.

This works with the normal dynamic measurement pattern:

Expand Down
22 changes: 21 additions & 1 deletion packages/react-virtual/e2e/app/chat/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const initialMessages = Array.from({ length: 30 }, (_, index) =>
function App() {
const [messages, setMessages] = React.useState(initialMessages)
const [didInitialScroll, setDidInitialScroll] = React.useState(false)
const [viewportHeight, setViewportHeight] = React.useState(300)
const parentRef = React.useRef<HTMLDivElement>(null)
const firstMessageIndexRef = React.useRef(0)
const nextMessageIndexRef = React.useRef(initialMessages.length)
Expand Down Expand Up @@ -91,12 +92,31 @@ function App() {
<button id="scroll-to-end" onClick={() => virtualizer.scrollToEnd()}>
End
</button>
<button id="shrink-viewport" onClick={() => setViewportHeight(200)}>
Shrink viewport
</button>
<button
id="shrink-viewport-and-row"
onClick={() => {
// Like a row capped at a share of the window height on a window resize.
setViewportHeight(200)
setMessages((current) =>
current.map((message, index) =>
index === current.length - 8
? { ...message, height: 30 }
: message,
),
)
}}
>
Shrink viewport and row
</button>

<div
ref={parentRef}
id="scroll-container"
style={{
height: 300,
height: viewportHeight,
overflow: 'auto',
width: 420,
border: '1px solid #ddd',
Expand Down
36 changes: 36 additions & 0 deletions packages/react-virtual/e2e/app/test/chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ async function waitForEnd(page: Page) {
.toBeLessThan(1.01)
}

async function waitForViewportHeight(page: Page, height: number) {
await expect
.poll(async () =>
page.evaluate(
() => document.querySelector('#scroll-container')?.clientHeight,
),
)
.toBe(height)
}

async function maybeFirstVisibleMessage(page: Page) {
return page.evaluate(() => {
const container = document.querySelector('#scroll-container')
Expand Down Expand Up @@ -171,6 +181,32 @@ test('chat mode keeps streaming bottom message pinned as it grows with paddingEn
await expect(page.locator('[data-testid="message-m-29"]')).toBeVisible()
})

test('chat mode keeps the end in view when the scroll container shrinks', async ({
page,
}) => {
await page.goto('/chat/')
await waitForEnd(page)

await page.click('#shrink-viewport')
await waitForViewportHeight(page, 200)
await waitForEnd(page)

await expect(page.locator('[data-testid="message-m-29"]')).toBeVisible()
})

test('chat mode keeps the end in view when the scroll container and a row above it shrink together', async ({
page,
}) => {
await page.goto('/chat/')
await waitForEnd(page)

await page.click('#shrink-viewport-and-row')
await waitForViewportHeight(page, 200)
await waitForEnd(page)

await expect(page.locator('[data-testid="message-m-29"]')).toBeVisible()
})

// #1266 — adapted from PR #1265 by @tigerBeA. Direct DOM updates, no flushSync,
// and a row ABOVE the last one grows: the compensation write is clamped against
// the old scroll range, and with an unchanged range nothing re-renders afterwards,
Expand Down
21 changes: 21 additions & 0 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -890,7 +890,28 @@ export class Virtualizer<

this.unsubs.push(
this.options.observeElementRect(this, (rect) => {
// With anchorTo: 'end', keep an end-pinned viewport pinned when the
// scroll element shrinks, as resizeItem does when an item grows. The
// browser keeps scrollTop, so the end would otherwise drop below the
// fold. Judge "pinned" against the size before this change, and
// never scroll past the element's real end.
const prevSize = this.scrollRect !== null ? this.getSize() : null

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swapping the scroll element carries the pin over to the new element, cleanup() doesn't reset scrollRect or scrollOffset, and observeElementRect reports the new element's size right away, before observeElementOffset is set up.

So when getScrollElement() returns a new, shorter element, this compares the old element's size and offset, and writes scrollTo(300, { adjustments: 50 }) to the new one.

The first report for each element has nothing to compare against, so I'd skip it:

// The first report for a new element has nothing to compare against:
// `scrollRect` and `scrollOffset` still describe the previous element.
let isFirstRect = true
this.unsubs.push(
  this.options.observeElementRect(this, (rect) => {
    const prevSize =
      !isFirstRect && this.scrollRect !== null ? this.getSize() : null
    isFirstRect = false
    // ...

const wasAtEnd =
prevSize !== null &&
this.options.anchorTo === 'end' &&
this.scrollState?.behavior !== 'smooth' &&
this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold
Comment on lines +899 to +903

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A reader scrolling up gets pulled back to the end.

If the viewport shrinks while the user is scrolling up but still within scrollEndThreshold, this scrolls them back down. With a window scroller on iOS that happens on every scroll-up gesture near the end: the URL bar reappears, innerHeight shrinks, and the adjustment is deferred and applied after touch-end, pulling them down by the bar's height.

Scrolling up means they're leaving the end, so I'd skip it:

const wasAtEnd =
prevSize !== null &&
this.scrollDirection !== 'backward' &&
this.isPinnedToEnd()

I only added this to the rect path and left resizeItem as it is. Let me know if you see a case where this is wrong, or if you think resizeItem should get the same guard for consistency. If you keep it, the docs line could note that scrolling up opts out.

this.scrollRect = rect
if (wasAtEnd && prevSize > this.getSize()) {
// While a clamped write waits for the sizer to grow, the DOM end
// is stale; apply the whole shrink and let the retry land it.
const shrink = prevSize - this.getSize()
this.applyScrollAdjustment(
this._clampedAdjustment !== null
? shrink
: Math.min(shrink, this.getDistanceFromEnd()),
)
}
this.maybeNotify()
}),
)
Expand Down
94 changes: 94 additions & 0 deletions packages/virtual-core/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2930,6 +2930,7 @@ function createChatVirtualizer({
let currentMessages = messages
const scrollToFn = vi.fn()
let offsetCb: ((offset: number, isScrolling: boolean) => void) | null = null
let rectCb: ((rect: { width: number; height: number }) => void) | null = null
const scrollElement = {
scrollTop: offset,
scrollLeft: 0,
Expand Down Expand Up @@ -2970,6 +2971,7 @@ function createChatVirtualizer({
_instance: any,
cb: (rect: { width: number; height: number }) => void,
) => {
rectCb = cb
cb({ width: 400, height: viewportSize })
return () => {}
},
Expand Down Expand Up @@ -3009,6 +3011,12 @@ function createChatVirtualizer({
scrollElement.scrollTop = nextOffset
offsetCb?.(nextOffset, isScrolling)
},
// Simulate the ResizeObserver reporting a new scroll element size.
resizeViewport(height: number) {
;(scrollElement as any).clientHeight = height
;(scrollElement as any).offsetHeight = height
rectCb?.({ width: 400, height })
},
}
}

Expand Down Expand Up @@ -3265,6 +3273,92 @@ test('anchorTo:end stays pinned across consecutive resizes when the scrollTop wr
expect(scrollToFn.mock.calls[0]![1].adjustments).toBe(80)
})

test('anchorTo:end keeps an end-pinned viewport pinned when the scroll element shrinks', () => {
const messages = Array.from({ length: 10 }, (_, i) => ({ id: `m-${i}` }))
const { virtualizer, scrollToFn, resizeViewport } = createChatVirtualizer({
messages,
offset: 300,
})

resizeViewport(150)

expect(scrollToFn).toHaveBeenCalledTimes(1)
expect(scrollToFn.mock.calls[0]![0]).toBe(300)
expect(scrollToFn.mock.calls[0]![1].adjustments).toBe(50)
expect(virtualizer.getVirtualDistanceFromEnd()).toBe(0)
})

test('anchorTo:end leaves the scroll position alone when the scroll element grows or the user is away from end', () => {
const messages = Array.from({ length: 10 }, (_, i) => ({ id: `m-${i}` }))
const pinned = createChatVirtualizer({ messages, offset: 300 })
// The browser clamps scrollTop onto the new end itself.
pinned.resizeViewport(250)
expect(pinned.scrollToFn).not.toHaveBeenCalled()

const reading = createChatVirtualizer({ messages, offset: 100 })
reading.resizeViewport(150)
expect(reading.scrollToFn).not.toHaveBeenCalled()
})

test('anchorTo:end keeps the distance from the end when pinned within the threshold and ignores width changes', () => {
const messages = Array.from({ length: 10 }, (_, i) => ({ id: `m-${i}` }))
const { virtualizer, scrollToFn, resizeViewport } = createChatVirtualizer({
messages,
offset: 295,
threshold: 10,
})

// Same height: nothing to keep in view.
resizeViewport(200)
expect(scrollToFn).not.toHaveBeenCalled()

resizeViewport(150)
expect(scrollToFn).toHaveBeenCalledTimes(1)
expect(scrollToFn.mock.calls[0]![1].adjustments).toBe(50)
expect(virtualizer.getVirtualDistanceFromEnd()).toBe(5)
})

test('anchorTo:end reaches the end when the scroll element shrinks while a clamped write waits for the sizer', () => {
const messages = Array.from({ length: 10 }, (_, i) => ({ id: `m-${i}` }))
const { virtualizer, scrollElement, scrollToFn, resizeViewport } =
createChatVirtualizer({ messages, offset: 300 })

// The last item grows before the sizer does, so the end write is clamped.
virtualizer.resizeItem(9, 120)
resizeViewport(150)
// The sizer commits and the clamped write is retried.
;(scrollElement as any).scrollHeight = 570
virtualizer._willUpdate()

const [offset, { adjustments }] = scrollToFn.mock.calls.at(-1)!
expect(offset + (adjustments ?? 0)).toBe(420)
})

test.each(['scroll element first', 'item first'])(
'anchorTo:end stays pinned when an item above the viewport shrinks in the same frame as the scroll element (%s)',
(order) => {
// e.g. rows capped at a share of the window height, on a window resize.
const messages = Array.from({ length: 10 }, (_, i) => ({ id: `m-${i}` }))
const { virtualizer, scrollElement, resizeViewport } =
createChatVirtualizer({ messages, offset: 300 })
const shrinkItem = () => {
virtualizer.resizeItem(2, 30)
;(scrollElement as any).scrollHeight = 480
}

if (order === 'scroll element first') {
resizeViewport(150)
shrinkItem()
} else {
shrinkItem()
resizeViewport(150)
}

expect(virtualizer.getTotalSize()).toBe(480)
expect(virtualizer.scrollOffset).toBe(330)
},
)

test('anchorTo:end does not follow streaming growth when user is away from end', () => {
const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` }))
const { virtualizer, scrollToFn } = createChatVirtualizer({
Expand Down