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 a5715fbc1..6b57be904 100644 --- a/docs/api/virtualizer.md +++ b/docs/api/virtualizer.md @@ -257,7 +257,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 47cbe12c2..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. +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/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 bd6e7cb23..ba50e474a 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1116,9 +1116,45 @@ 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 + // 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 = + !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.isPinnedToEnd() this.scrollRect = rect + // 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() + this.applyScrollAdjustment( + this._clampedAdjustment !== null + ? shrink + : Math.min(shrink, this.getDistanceFromEnd()), + ) + } this.maybeNotify() }), ) @@ -2078,10 +2114,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). @@ -2355,6 +2388,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 diff --git a/packages/virtual-core/tests/anchor-end.test.ts b/packages/virtual-core/tests/anchor-end.test.ts index 0a95bfb73..dc92f18e5 100644 --- a/packages/virtual-core/tests/anchor-end.test.ts +++ b/packages/virtual-core/tests/anchor-end.test.ts @@ -29,6 +29,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, @@ -70,6 +71,7 @@ function createChatVirtualizer({ _instance: any, cb: (rect: { width: number; height: number }) => void, ) => { + rectCb = cb cb({ width: 400, height: viewportSize }) return () => {} }, @@ -110,6 +112,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 }) + }, } } @@ -700,3 +708,164 @@ test('#1258: cleanup drops a pending clamped write', () => { expect(virtualizer['_clampedAdjustment']).toBeNull() }) + +// ─── scroll element shrink ────────────────────────────────────────────────── + +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 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('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) +}) + +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() +}) + +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() +})