From aaaa7bfab86df9b449cc048a5152b6622369d197 Mon Sep 17 00:00:00 2001 From: David Langley Date: Tue, 6 Oct 2026 12:48:11 +0100 Subject: [PATCH 1/6] 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/6] 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) => { From c2b291cb3f3ccb62a9eae36816ccd8874950850f Mon Sep 17 00:00:00 2001 From: David Langley Date: Mon, 9 Nov 2026 09:43:33 +0000 Subject: [PATCH 3/6] fix(virtual-core): skip the end pin for the first rect report of a new scroll element Swapping the scroll element carried the pin over: cleanup() keeps scrollRect and scrollOffset, and the new element reports its rect before its offset observer is set up, so a shorter new element was compared with the old one and scrolled by the difference. The first report for each element has nothing to compare against, so skip it. --- packages/virtual-core/src/index.ts | 8 +++- .../virtual-core/tests/anchor-end.test.ts | 39 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 2da74ce81..2ebd0b2e4 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1116,6 +1116,10 @@ export class Virtualizer< this.observer.observe(cached) }) + // The first report for a new element has nothing to compare against: + // `scrollRect` and `scrollOffset` still describe the previous element, + // and its offset observer is not set up yet. + let isFirstRect = true this.unsubs.push( this.options.observeElementRect(this, (rect) => { // With anchorTo: 'end', keep an end-pinned viewport pinned when the @@ -1123,7 +1127,9 @@ export class Virtualizer< // 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 prevSize = + !isFirstRect && this.scrollRect !== null ? this.getSize() : null + isFirstRect = false const wasAtEnd = prevSize !== null && this.options.anchorTo === 'end' && diff --git a/packages/virtual-core/tests/anchor-end.test.ts b/packages/virtual-core/tests/anchor-end.test.ts index c6b422c9f..986fd2ee0 100644 --- a/packages/virtual-core/tests/anchor-end.test.ts +++ b/packages/virtual-core/tests/anchor-end.test.ts @@ -796,3 +796,42 @@ test('anchorTo:end reaches the end when the scroll element shrinks while a clamp const [offset, { adjustments }] = scrollToFn.mock.calls.at(-1)! expect(offset + (adjustments ?? 0)).toBe(420) }) + +test('anchorTo:end does not carry an end pin over to a new, shorter scroll element', () => { + // `cleanup` keeps scrollRect and scrollOffset, and the new element's rect + // is reported before its offset, so the first report has nothing to + // compare against. + const messages = Array.from({ length: 10 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer, scrollToFn } = createChatVirtualizer({ + messages, + offset: 300, + }) + + const shorter = { + scrollTop: 0, + scrollLeft: 0, + scrollHeight: 500, + scrollWidth: 1000, + clientHeight: 150, + clientWidth: 400, + offsetHeight: 150, + } as unknown as HTMLDivElement + virtualizer.setOptions({ + ...virtualizer.options, + getScrollElement: () => shorter, + observeElementRect: (_instance, cb) => { + cb({ width: 400, height: 150 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + return () => {} + }, + }) + scrollToFn.mockClear() + virtualizer._willUpdate() + + expect( + scrollToFn.mock.calls.filter(([, opts]) => opts?.adjustments), + ).toHaveLength(0) +}) From 9dabb08bd3c54dc9b32dd874030b33a216178bf1 Mon Sep 17 00:00:00 2001 From: David Langley Date: Mon, 9 Nov 2026 09:43:35 +0000 Subject: [PATCH 4/6] fix(virtual-core): don't pull a reader scrolling up back to the end when the scroll element shrinks A shrink while the user scrolls up but is still within scrollEndThreshold scrolled them back down. With a window scroller on iOS that is every scroll-up gesture near the end: the URL bar reappears and innerHeight shrinks. Scrolling up means leaving the end, so skip the pin then. --- docs/chat.md | 2 +- packages/virtual-core/src/index.ts | 4 ++++ packages/virtual-core/tests/anchor-end.test.ts | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/chat.md b/docs/chat.md index b1c2f23e6..d921a210a 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. The same applies when the scroll element shrinks, for example when the window is resized or the app changes its height. +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, unless the user is scrolling up at the time. This works with the normal dynamic measurement pattern: diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 2ebd0b2e4..caa61ec9d 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1130,8 +1130,12 @@ export class Virtualizer< const prevSize = !isFirstRect && this.scrollRect !== null ? this.getSize() : null isFirstRect = false + // A reader scrolling up is leaving the end: don't pull them back + // (e.g. the iOS toolbar reappearing on scroll-up shrinks a window + // scroller). const wasAtEnd = prevSize !== null && + this.scrollDirection !== 'backward' && this.options.anchorTo === 'end' && !isSmoothScroll(this.scrollState) && this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold diff --git a/packages/virtual-core/tests/anchor-end.test.ts b/packages/virtual-core/tests/anchor-end.test.ts index 986fd2ee0..a2f8d342b 100644 --- a/packages/virtual-core/tests/anchor-end.test.ts +++ b/packages/virtual-core/tests/anchor-end.test.ts @@ -835,3 +835,19 @@ test('anchorTo:end does not carry an end pin over to a new, shorter scroll eleme scrollToFn.mock.calls.filter(([, opts]) => opts?.adjustments), ).toHaveLength(0) }) + +test('anchorTo:end does not pull a reader scrolling up back to the end when the scroll element shrinks', () => { + // e.g. a window scroller on iOS: scrolling up near the end brings the URL + // bar back, which shrinks the viewport. Scrolling up means leaving the end. + const messages = Array.from({ length: 10 }, (_, i) => ({ id: `m-${i}` })) + const { scrollToFn, emitScroll, resizeViewport } = createChatVirtualizer({ + messages, + offset: 300, + threshold: 10, + }) + + emitScroll(295, true) + resizeViewport(150) + + expect(scrollToFn).not.toHaveBeenCalled() +}) From a225d15804b707a524389d0aa2b79edd0f36d484 Mon Sep 17 00:00:00 2001 From: David Langley Date: Mon, 9 Nov 2026 09:43:36 +0000 Subject: [PATCH 5/6] refactor(virtual-core): share the pinned-to-end check between resizeItem and the rect callback No behaviour change. --- packages/virtual-core/src/index.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index caa61ec9d..454b06969 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1136,9 +1136,7 @@ export class Virtualizer< const wasAtEnd = prevSize !== null && this.scrollDirection !== 'backward' && - this.options.anchorTo === 'end' && - !isSmoothScroll(this.scrollState) && - this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold + this.isPinnedToEnd() this.scrollRect = rect if (wasAtEnd && prevSize > this.getSize()) { // While a clamped write waits for the sizer to grow, the DOM end @@ -2109,10 +2107,7 @@ export class Virtualizer< const delta = size - itemSize if (delta !== 0) { - const wasAtEnd = - this.options.anchorTo === 'end' && - !isSmoothScroll(this.scrollState) && - this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold + const wasAtEnd = this.isPinnedToEnd() const prevTotalSize = wasAtEnd ? this._getLayoutTotalSize() : 0 // Default anchoring predicate (used unless the consumer supplies a // custom shouldAdjustScrollPositionOnItemSizeChange). @@ -2386,6 +2381,13 @@ export class Virtualizer< return target > maxAtWrite + 0.5 ? { target, maxAtWrite } : null } + // Whether an end-anchored viewport should follow size changes to stay at + // the end. Shared by item growth (`resizeItem`) and scroll element shrink. + private isPinnedToEnd = () => + this.options.anchorTo === 'end' && + !isSmoothScroll(this.scrollState) && + this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold + private getVirtualDistanceFromEnd = () => { // `_getLayoutTotalSize()` measures the sizer, which starts `scrollMargin` // into the scroll content, while `getScrollOffset()` is measured from the From db1f91b5926a59ee6b6c1fdaa4a528c7d226e409 Mon Sep 17 00:00:00 2001 From: David Langley Date: Mon, 9 Nov 2026 09:43:37 +0000 Subject: [PATCH 6/6] fix(virtual-core): leave a scroll element shrink before a command's read-back to the command Since #1324 resizeItem skips compensation while a scroll command has been written but not read back: scrollOffset is still the pre-command position, so "pinned to the end" is judged against where the viewport was, and the write would land over the command. Apply the same rule to a scroll element shrink. --- packages/virtual-core/src/index.ts | 9 ++++++++- packages/virtual-core/tests/anchor-end.test.ts | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 454b06969..ba50e474a 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1138,7 +1138,14 @@ export class Virtualizer< this.scrollDirection !== 'backward' && this.isPinnedToEnd() this.scrollRect = rect - if (wasAtEnd && prevSize > this.getSize()) { + // Before a command's read-back, leave the shrink to the command, as + // resizeItem does: reconcile retargets an index or end command from + // the new layout, and an offset command lands where it was written. + if ( + wasAtEnd && + prevSize > this.getSize() && + !this._awaitingCommandReadBack() + ) { // 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() diff --git a/packages/virtual-core/tests/anchor-end.test.ts b/packages/virtual-core/tests/anchor-end.test.ts index a2f8d342b..dc92f18e5 100644 --- a/packages/virtual-core/tests/anchor-end.test.ts +++ b/packages/virtual-core/tests/anchor-end.test.ts @@ -851,3 +851,21 @@ test('anchorTo:end does not pull a reader scrolling up back to the end when the expect(scrollToFn).not.toHaveBeenCalled() }) + +test('anchorTo:end leaves a shrink before a scroll command has been read back to the command', () => { + // Pinned at the end, a command away from it is written but not yet read + // back, so `scrollOffset` still says "at the end". As resizeItem does, leave + // it to the command: reconcile retargets an index or end command from the + // new layout, and an offset command lands where it was written. + const messages = Array.from({ length: 10 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer, scrollToFn, resizeViewport } = createChatVirtualizer({ + messages, + offset: 300, + }) + + virtualizer.scrollToIndex(0) + scrollToFn.mockClear() + resizeViewport(150) + + expect(scrollToFn).not.toHaveBeenCalled() +})