From aaaa7bfab86df9b449cc048a5152b6622369d197 Mon Sep 17 00:00:00 2001 From: David Langley Date: Tue, 6 Oct 2026 12:48:11 +0100 Subject: [PATCH 1/2] fix(virtual-core): keep an end-pinned viewport pinned when the scroll element shrinks With `anchorTo: 'end'`, resizeItem keeps the end in view when an item grows, but nothing did the same when the scroll element itself shrinks (the window resizing, or the app changing the element's height). The browser keeps `scrollTop`, so the last items dropped below the fold. Rows sized from the viewport (e.g. capped with `vh`) made it worse: they shrink in the same frame, and were then compensated as rows above a viewport no longer at the end. The scroll element's rect callback now applies the same rule as resizeItem: if the viewport was pinned to the end against its previous size and the element shrank along the scroll axis, scroll by the shrink. Like resizeItem it keeps any distance from the end that was within `scrollEndThreshold`, and it never scrolls past the element's real end, so a border on the element or content shorter than the viewport cannot overshoot. Growth and cross-axis changes are left alone. --- .../end-anchor-scroll-element-resize.md | 5 ++ docs/api/virtualizer.md | 2 +- docs/chat.md | 2 +- packages/react-virtual/e2e/app/chat/main.tsx | 22 +++++- .../react-virtual/e2e/app/test/chat.spec.ts | 36 +++++++++ packages/virtual-core/src/index.ts | 16 ++++ packages/virtual-core/tests/index.test.ts | 78 +++++++++++++++++++ 7 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 .changeset/end-anchor-scroll-element-resize.md diff --git a/.changeset/end-anchor-scroll-element-resize.md b/.changeset/end-anchor-scroll-element-resize.md new file mode 100644 index 000000000..ae953c4ae --- /dev/null +++ b/.changeset/end-anchor-scroll-element-resize.md @@ -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. diff --git a/docs/api/virtualizer.md b/docs/api/virtualizer.md index fcbc4cf5a..07c39b7d0 100644 --- a/docs/api/virtualizer.md +++ b/docs/api/virtualizer.md @@ -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. diff --git a/docs/chat.md b/docs/chat.md index 7eb6bdf6f..ccf7665b5 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -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: diff --git a/packages/react-virtual/e2e/app/chat/main.tsx b/packages/react-virtual/e2e/app/chat/main.tsx index e30fe0d75..172736e2b 100644 --- a/packages/react-virtual/e2e/app/chat/main.tsx +++ b/packages/react-virtual/e2e/app/chat/main.tsx @@ -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(null) const firstMessageIndexRef = React.useRef(0) const nextMessageIndexRef = React.useRef(initialMessages.length) @@ -91,12 +92,31 @@ function App() { + +
+ page.evaluate( + () => document.querySelector('#scroll-container')?.clientHeight, + ), + ) + .toBe(height) +} + async function maybeFirstVisibleMessage(page: Page) { return page.evaluate(() => { const container = document.querySelector('#scroll-container') @@ -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, diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 38b7e426c..eb03d4103 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -890,7 +890,23 @@ 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 + const wasAtEnd = + prevSize !== null && + this.options.anchorTo === 'end' && + this.scrollState?.behavior !== 'smooth' && + this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold this.scrollRect = rect + if (wasAtEnd && prevSize > this.getSize()) { + this.applyScrollAdjustment( + Math.min(prevSize - this.getSize(), this.getDistanceFromEnd()), + ) + } this.maybeNotify() }), ) diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index a6ce4021d..25301ff5c 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -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, @@ -2970,6 +2971,7 @@ function createChatVirtualizer({ _instance: any, cb: (rect: { width: number; height: number }) => void, ) => { + rectCb = cb cb({ width: 400, height: viewportSize }) return () => {} }, @@ -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 }) + }, } } @@ -3265,6 +3273,76 @@ 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.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({ From df83a48b6a01c0789b93ebcec244376a92ee9711 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 7 Oct 2026 13:34:46 +0100 Subject: [PATCH 2/2] fix(virtual-core): apply the whole shrink while a clamped end write is pending If an end-pinned item grew before the sizer did, its compensation write is clamped and waits to be retried once the sizer grows. A scroll element shrink in that window was capped at the DOM distance to the end, which is stale until the sizer commits, so the shrink was dropped and the retry landed short of the new end. Apply the whole shrink while a clamped write is pending; the retry lands it. --- packages/virtual-core/src/index.ts | 7 ++++++- packages/virtual-core/tests/index.test.ts | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index eb03d4103..16f085d37 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -903,8 +903,13 @@ export class Virtualizer< this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold 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( - Math.min(prevSize - this.getSize(), this.getDistanceFromEnd()), + this._clampedAdjustment !== null + ? shrink + : Math.min(shrink, this.getDistanceFromEnd()), ) } this.maybeNotify() diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 25301ff5c..dbf5efc64 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -3318,6 +3318,22 @@ test('anchorTo:end keeps the distance from the end when pinned within the thresh 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) => {