From bc6a315a9652576b00eb258bf7a5a1a51c156f37 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sun, 24 May 2026 14:45:38 -0600 Subject: [PATCH 1/2] feat: support end-anchored virtualizers --- .changeset/chat-reverse-virtualization.md | 9 + docs/api/virtualizer.md | 72 ++++++ docs/chat.md | 145 ++++++++++++ docs/config.json | 8 + docs/introduction.md | 2 + examples/react/chat/.gitignore | 2 + examples/react/chat/README.md | 13 ++ examples/react/chat/index.html | 12 + examples/react/chat/package.json | 22 ++ examples/react/chat/src/index.css | 103 +++++++++ examples/react/chat/src/main.tsx | 218 ++++++++++++++++++ examples/react/chat/tsconfig.json | 23 ++ examples/react/chat/vite.config.js | 6 + .../react-virtual/e2e/app/chat/index.html | 10 + packages/react-virtual/e2e/app/chat/main.tsx | 144 ++++++++++++ .../react-virtual/e2e/app/test/chat.spec.ts | 119 ++++++++++ packages/react-virtual/e2e/app/vite.config.ts | 1 + packages/react-virtual/playwright.config.ts | 2 +- packages/virtual-core/src/index.ts | 193 ++++++++++++++-- packages/virtual-core/tests/index.test.ts | 190 +++++++++++++++ pnpm-lock.yaml | 28 +++ 21 files changed, 1300 insertions(+), 22 deletions(-) create mode 100644 .changeset/chat-reverse-virtualization.md create mode 100644 docs/chat.md create mode 100644 examples/react/chat/.gitignore create mode 100644 examples/react/chat/README.md create mode 100644 examples/react/chat/index.html create mode 100644 examples/react/chat/package.json create mode 100644 examples/react/chat/src/index.css create mode 100644 examples/react/chat/src/main.tsx create mode 100644 examples/react/chat/tsconfig.json create mode 100644 examples/react/chat/vite.config.js create mode 100644 packages/react-virtual/e2e/app/chat/index.html create mode 100644 packages/react-virtual/e2e/app/chat/main.tsx create mode 100644 packages/react-virtual/e2e/app/test/chat.spec.ts diff --git a/.changeset/chat-reverse-virtualization.md b/.changeset/chat-reverse-virtualization.md new file mode 100644 index 000000000..cf8cad7cf --- /dev/null +++ b/.changeset/chat-reverse-virtualization.md @@ -0,0 +1,9 @@ +--- +'@tanstack/virtual-core': minor +--- + +Add end-anchored virtualization support for chat, logs, and reverse feeds. + +New `anchorTo: 'end'` mode keeps the current visible item stable when older items are prepended, while preserving the existing start-anchored behavior by default. It also keeps an end-pinned viewport pinned when the last item grows during streaming output. + +Add `followOnAppend` so new items scroll into view only when the viewport was already at the end, plus `scrollEndThreshold`, `scrollToEnd()`, `getDistanceFromEnd()`, and `isAtEnd()` helpers for chat-style integrations. diff --git a/docs/api/virtualizer.md b/docs/api/virtualizer.md index a7af4d632..d884804bb 100644 --- a/docs/api/virtualizer.md +++ b/docs/api/virtualizer.md @@ -245,6 +245,44 @@ Controls when lane assignments are cached in a masonry layout. - `'estimate'` (default): lane assignments are cached immediately based on `estimateSize`. This keeps items from jumping between lanes, but assignments may be suboptimal when the estimate is inaccurate. - `'measured'`: lane caching is deferred until items are measured via `measureElement`, so assignments reflect actual measured sizes. After the initial measurement, lanes are cached and remain stable. +### `anchorTo` + +```tsx +anchorTo?: 'start' | 'end' +``` + +**Default:** `'start'` + +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. + +For prepend stability, use a stable `getItemKey` based on each item's persistent id. Index keys cannot distinguish prepends from appends after items shift. + +### `followOnAppend` + +```tsx +followOnAppend?: boolean | 'auto' | 'smooth' | 'instant' +``` + +**Default:** `false` + +When used with `anchorTo: 'end'`, controls whether the virtualizer scrolls to the end after new items are appended. The follow only happens if the viewport was already at the end before the append; users who have scrolled up to read history are not pulled down. + +Passing `true` is equivalent to `'auto'`. Passing a scroll behavior uses that behavior for the follow. + +This option does not follow prepends. It only follows appended output, and only when the viewport was already within `scrollEndThreshold` of the end before the append. + +### `scrollEndThreshold` + +```tsx +scrollEndThreshold?: number +``` + +**Default:** `1` + +The pixel threshold used by `isAtEnd()` and `followOnAppend` to decide whether the viewport is close enough to the end to count as pinned. + ### `isScrollingResetDelay` ```tsx @@ -389,6 +427,40 @@ scrollBy: ( Scrolls the virtualizer by the specified number of pixels relative to the current scroll position. +### `scrollToEnd` + +```tsx +scrollToEnd: ( + options?: { + behavior?: 'auto' | 'smooth' | 'instant' + } +) => void +``` + +Scrolls the virtualizer to the end of the content. For vertical lists this is the bottom; for horizontal lists this is the right edge. + +This is useful for "Jump to latest" controls in chat and log views. + +### `getDistanceFromEnd` + +```tsx +getDistanceFromEnd: () => number +``` + +Returns the current pixel distance from the end of the virtualized content. + +For a vertical list, this is the distance from the bottom. + +### `isAtEnd` + +```tsx +isAtEnd: (threshold?: number) => boolean +``` + +Returns whether the viewport is within `threshold` pixels of the end. If no threshold is provided, `scrollEndThreshold` is used. + +Use this to decide whether to show "Jump to latest" UI or whether incoming output should be treated as pinned. + ### `getTotalSize` ```tsx diff --git a/docs/chat.md b/docs/chat.md new file mode 100644 index 000000000..7eb6bdf6f --- /dev/null +++ b/docs/chat.md @@ -0,0 +1,145 @@ +--- +title: Chat +--- + +Chat, AI streams, logs, and other reverse feeds have a different scrolling contract than a standard top-anchored list. New output usually appears at the end, older history is prepended at the start, and the viewport should only follow new output when the user is already reading the latest item. + +TanStack Virtual supports this with end anchoring: + +```tsx +const virtualizer = useVirtualizer({ + count: messages.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 72, + getItemKey: (index) => messages[index]!.id, + anchorTo: 'end', + followOnAppend: true, + scrollEndThreshold: 80, + overscan: 6, +}) +``` + +See the full [React chat example](framework/react/examples/chat). + +## Behaviors + +### Start at the latest message + +Use `scrollToEnd()` once the scroll element is mounted. + +```tsx +React.useLayoutEffect(() => { + virtualizer.scrollToEnd() +}, [virtualizer]) +``` + +For server-rendered or restored screens, you can also use `initialOffset` and `initialMeasurementsCache`, but most chat screens start by imperatively scrolling to the latest item after mount. + +### Keep older-history prepends stable + +When the user scrolls near the top, load older messages and prepend them to the array. With `anchorTo: 'end'`, TanStack Virtual captures the visible item before the data changes, finds the same keyed item after the prepend, and adjusts the scroll offset so the message stays in the same visual position. + +```tsx +setMessages((current) => [...olderMessages, ...current]) +``` + +Stable keys are required for this to work: + +```tsx +getItemKey: (index) => messages[index]!.id +``` + +Do not use index keys for chat history. After a prepend, every existing message shifts to a new index, so index keys cannot identify the same message across the update. + +### Follow appended output only when pinned + +Set `followOnAppend` to keep the viewport pinned to the end when a new message arrives and the user was already at the end. + +```tsx +followOnAppend: true +``` + +If the user has scrolled up to read history, appended messages do not pull them away. `scrollEndThreshold` controls how close to the end counts as pinned. + +```tsx +scrollEndThreshold: 80 +``` + +Use a scroll behavior when you want the follow to animate: + +```tsx +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. + +This works with the normal dynamic measurement pattern: + +```tsx +{virtualizer.getVirtualItems().map((virtualItem) => ( +
+ +
+))} +``` + +## Recommended Pattern + +Use a normal scroll container and normal item order. You do not need `flex-direction: column-reverse`, inverted transforms, or manual `scrollTop += delta` prepend compensation. + +```tsx +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => ( +
+ +
+ ))} +
+
+``` + +## Production Checklist + +- Use stable message ids with `getItemKey`. +- Give the scroll element a fixed height and `overflow: auto`. +- Call `measureElement` for dynamic message heights. +- Use `anchorTo: 'end'` for prepend stability and streaming bottom growth. +- Use `followOnAppend` when new output should follow only from the latest position. +- Use `isAtEnd()` to show "Jump to latest" UI when the user is reading history. +- Keep network loading state outside the virtualizer; prepend or append data normally. + +## API Reference + +- [`anchorTo`](api/virtualizer#anchorto) +- [`followOnAppend`](api/virtualizer#followonappend) +- [`scrollEndThreshold`](api/virtualizer#scrollendthreshold) +- [`scrollToEnd`](api/virtualizer#scrolltoend) +- [`getDistanceFromEnd`](api/virtualizer#getdistancefromend) +- [`isAtEnd`](api/virtualizer#isatend) diff --git a/docs/config.json b/docs/config.json index e65ba6ad2..872da3bf6 100644 --- a/docs/config.json +++ b/docs/config.json @@ -57,6 +57,10 @@ } ] }, + { + "label": "Guides", + "children": [{ "label": "Chat", "to": "chat" }] + }, { "label": "Core APIs", "children": [ @@ -136,6 +140,10 @@ "to": "framework/react/examples/infinite-scroll", "label": "Infinite Scroll" }, + { + "to": "framework/react/examples/chat", + "label": "Chat" + }, { "to": "framework/react/examples/smooth-scroll", "label": "Smooth Scroll" diff --git a/docs/introduction.md b/docs/introduction.md index 76e0535ff..653f80fab 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -8,6 +8,8 @@ TanStack Virtual is a headless UI utility for virtualizing long lists of element At the heart of TanStack Virtual is the `Virtualizer`. Virtualizers can be oriented on either the vertical (default) or horizontal axes which makes it possible to achieve vertical, horizontal and even grid-like virtualization by combining the two axis configurations together. +For chat, AI streams, logs, and other reverse feeds, see the [Chat guide](chat). + Here is just a quick example of what it looks like to virtualize a long list within a div using TanStack Virtual in React: ```tsx diff --git a/examples/react/chat/.gitignore b/examples/react/chat/.gitignore new file mode 100644 index 000000000..f06235c46 --- /dev/null +++ b/examples/react/chat/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/examples/react/chat/README.md b/examples/react/chat/README.md new file mode 100644 index 000000000..fc23edc9a --- /dev/null +++ b/examples/react/chat/README.md @@ -0,0 +1,13 @@ +# TanStack Virtual React Chat Example + +Demonstrates end-anchored virtualization for chat-style UIs: + +- starts at the latest message +- keeps the visible message stable when older history is prepended +- follows appended output only when the viewport is already at the latest message +- keeps streaming bottom output pinned as the last row grows + +```bash +npm install +npm run dev +``` diff --git a/examples/react/chat/index.html b/examples/react/chat/index.html new file mode 100644 index 000000000..9a24b4657 --- /dev/null +++ b/examples/react/chat/index.html @@ -0,0 +1,12 @@ + + + + + + TanStack Virtual Chat Example + + +
+ + + diff --git a/examples/react/chat/package.json b/examples/react/chat/package.json new file mode 100644 index 000000000..e839ab29c --- /dev/null +++ b/examples/react/chat/package.json @@ -0,0 +1,22 @@ +{ + "name": "tanstack-react-virtual-example-chat", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "serve": "vite preview" + }, + "dependencies": { + "@tanstack/react-virtual": "^3.13.25", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.5.2", + "typescript": "5.6.3", + "vite": "^6.4.2" + } +} diff --git a/examples/react/chat/src/index.css b/examples/react/chat/src/index.css new file mode 100644 index 000000000..1923a19b5 --- /dev/null +++ b/examples/react/chat/src/index.css @@ -0,0 +1,103 @@ +* { + box-sizing: border-box; +} + +html { + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + color: #171717; + background: #f6f8fa; +} + +body { + margin: 0; +} + +button { + border: 1px solid #c6d0da; + border-radius: 6px; + background: #ffffff; + color: #171717; + cursor: pointer; + font: inherit; + font-size: 13px; + padding: 7px 10px; +} + +button:hover { + background: #eef3f7; +} + +.App { + height: 100vh; + display: grid; + grid-template-rows: auto 1fr; +} + +.Toolbar { + align-items: center; + background: #ffffff; + border-bottom: 1px solid #d9e0e6; + display: flex; + gap: 8px; + justify-content: space-between; + padding: 10px 12px; +} + +.ToolbarGroup { + display: flex; + gap: 8px; +} + +.Status { + color: #5c6670; + font-size: 13px; +} + +.Shell { + display: grid; + min-height: 0; + overflow: hidden; + place-items: stretch; +} + +.Messages { + min-height: 0; + overflow: auto; + width: 100%; +} + +.MessageRow { + padding: 6px 12px; +} + +.Bubble { + border: 1px solid #d7dee5; + border-radius: 8px; + line-height: 1.45; + max-width: min(720px, 88vw); + padding: 10px 12px; + white-space: pre-wrap; +} + +.Bubble-user { + background: #e6f3ff; + margin-left: auto; +} + +.Bubble-assistant { + background: #ffffff; + margin-right: auto; +} + +.Meta { + color: #637081; + font-size: 12px; + margin-bottom: 4px; +} diff --git a/examples/react/chat/src/main.tsx b/examples/react/chat/src/main.tsx new file mode 100644 index 000000000..dc6c3cdc4 --- /dev/null +++ b/examples/react/chat/src/main.tsx @@ -0,0 +1,218 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { useVirtualizer } from '@tanstack/react-virtual' +import './index.css' + +type Message = { + id: string + author: 'user' | 'assistant' + text: string +} + +const replies = [ + 'I can break that into the smallest next step and keep the current viewport pinned while this answer grows.', + 'Older messages are loaded above the viewport. The visible row keeps the same screen position after the prepend.', + 'When the thread is not at the bottom, new output waits below without pulling the reader away from history.', +] + +const makeMessage = (index: number): Message => ({ + id: `message-${index}`, + author: index % 4 === 0 ? 'user' : 'assistant', + text: + index % 4 === 0 + ? `Can you check item ${index}?` + : `Message ${index}: ${replies[Math.abs(index) % replies.length]}`, +}) + +const initialMessages = Array.from({ length: 45 }, (_, index) => + makeMessage(index), +) + +function App() { + const parentRef = React.useRef(null) + const firstMessageIndexRef = React.useRef(0) + const nextMessageIndexRef = React.useRef(initialMessages.length) + const streamTimerRef = React.useRef(null) + const loadingHistoryRef = React.useRef(false) + const [messages, setMessages] = React.useState(initialMessages) + const [loadingHistory, setLoadingHistory] = React.useState(false) + const [didInitialScroll, setDidInitialScroll] = React.useState(false) + const [autoHistoryEnabled, setAutoHistoryEnabled] = React.useState(false) + + const virtualizer = useVirtualizer({ + count: messages.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 74, + getItemKey: (index) => messages[index]!.id, + anchorTo: 'end', + followOnAppend: true, + scrollEndThreshold: 80, + overscan: 6, + }) + + const virtualItems = virtualizer.getVirtualItems() + + const prependHistory = React.useCallback(() => { + if (loadingHistoryRef.current || firstMessageIndexRef.current <= -90) { + return + } + + loadingHistoryRef.current = true + setLoadingHistory(true) + window.setTimeout(() => { + const start = firstMessageIndexRef.current - 12 + firstMessageIndexRef.current = start + setMessages((current) => [ + ...Array.from({ length: 12 }, (_, offset) => + makeMessage(start + offset), + ), + ...current, + ]) + loadingHistoryRef.current = false + setLoadingHistory(false) + }, 180) + }, []) + + const appendMessage = React.useCallback(() => { + const next = nextMessageIndexRef.current + nextMessageIndexRef.current += 1 + setMessages((current) => [...current, makeMessage(next)]) + }, []) + + const streamReply = React.useCallback(() => { + if (streamTimerRef.current !== null) return + + const id = `stream-${Date.now()}` + const chunks = [ + 'Thinking through the failure mode.', + ' The list should follow only when it was already pinned.', + ' Prepends should keep the reader anchored to the same message.', + ' Streaming output should grow without drifting off the bottom.', + ] + let chunkIndex = 0 + + setMessages((current) => [ + ...current, + { + id, + author: 'assistant', + text: '', + }, + ]) + + streamTimerRef.current = window.setInterval(() => { + setMessages((current) => + current.map((message) => + message.id === id + ? { + ...message, + text: chunks.slice(0, chunkIndex + 1).join(''), + } + : message, + ), + ) + + chunkIndex += 1 + if (chunkIndex === chunks.length && streamTimerRef.current !== null) { + window.clearInterval(streamTimerRef.current) + streamTimerRef.current = null + } + }, 280) + }, []) + + React.useLayoutEffect(() => { + if (didInitialScroll) return + virtualizer.scrollToEnd() + setDidInitialScroll(true) + + const id = window.setTimeout(() => { + setAutoHistoryEnabled(true) + }, 250) + + return () => window.clearTimeout(id) + }, [didInitialScroll, virtualizer]) + + React.useEffect(() => { + return () => { + if (streamTimerRef.current !== null) { + window.clearInterval(streamTimerRef.current) + } + } + }, []) + + return ( +
+
+
+ + + + +
+
+ {loadingHistory + ? 'Loading history' + : virtualizer.isAtEnd(80) + ? 'At latest' + : 'Reading history'} +
+
+ +
+
{ + if (!autoHistoryEnabled || virtualizer.isAtEnd(80)) return + if (event.currentTarget.scrollTop < 120) { + prependHistory() + } + }} + > +
+ {virtualItems.map((virtualItem) => { + const message = messages[virtualItem.index]! + + return ( +
+
+
{message.author}
+ {message.text || '...'} +
+
+ ) + })} +
+
+
+
+ ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/examples/react/chat/tsconfig.json b/examples/react/chat/tsconfig.json new file mode 100644 index 000000000..86f2d05f0 --- /dev/null +++ b/examples/react/chat/tsconfig.json @@ -0,0 +1,23 @@ +{ + "composite": true, + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/examples/react/chat/vite.config.js b/examples/react/chat/vite.config.js new file mode 100644 index 000000000..9ffcc6757 --- /dev/null +++ b/examples/react/chat/vite.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/react-virtual/e2e/app/chat/index.html b/packages/react-virtual/e2e/app/chat/index.html new file mode 100644 index 000000000..56f418f61 --- /dev/null +++ b/packages/react-virtual/e2e/app/chat/index.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/packages/react-virtual/e2e/app/chat/main.tsx b/packages/react-virtual/e2e/app/chat/main.tsx new file mode 100644 index 000000000..c9dbbfe6f --- /dev/null +++ b/packages/react-virtual/e2e/app/chat/main.tsx @@ -0,0 +1,144 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { useVirtualizer } from '@tanstack/react-virtual' + +type Message = { + id: string + text: string + height: number +} + +const makeMessage = (index: number): Message => ({ + id: `m-${index}`, + text: `Message ${index}`, + height: 50, +}) + +const initialMessages = Array.from({ length: 30 }, (_, index) => + makeMessage(index), +) + +function App() { + const [messages, setMessages] = React.useState(initialMessages) + const [didInitialScroll, setDidInitialScroll] = React.useState(false) + const parentRef = React.useRef(null) + const firstMessageIndexRef = React.useRef(0) + const nextMessageIndexRef = React.useRef(initialMessages.length) + + const virtualizer = useVirtualizer({ + count: messages.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, + getItemKey: (index) => messages[index]!.id, + anchorTo: 'end', + followOnAppend: true, + scrollEndThreshold: 4, + overscan: 4, + }) + + React.useLayoutEffect(() => { + if (didInitialScroll) return + virtualizer.scrollToEnd() + setDidInitialScroll(true) + }, [didInitialScroll, virtualizer]) + + return ( +
+ + + + + +
+
+ {virtualizer.getVirtualItems().map((item) => { + const message = messages[item.index]! + + return ( +
+
+ {message.text} +
+
+ ) + })} +
+
+
+ ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/packages/react-virtual/e2e/app/test/chat.spec.ts b/packages/react-virtual/e2e/app/test/chat.spec.ts new file mode 100644 index 000000000..40533f8ec --- /dev/null +++ b/packages/react-virtual/e2e/app/test/chat.spec.ts @@ -0,0 +1,119 @@ +import { expect, test } from '@playwright/test' +import type { Page } from '@playwright/test' + +async function waitForEnd(page: Page) { + await expect + .poll(async () => + page.evaluate(() => { + const container = document.querySelector('#scroll-container') + if (!container) throw new Error('Container not found') + return Math.abs( + container.scrollHeight - container.scrollTop - container.clientHeight, + ) + }), + ) + .toBeLessThan(1.01) +} + +async function firstVisibleMessage(page: Page) { + return page.evaluate(() => { + const container = document.querySelector('#scroll-container') + if (!container) throw new Error('Container not found') + + const containerRect = container.getBoundingClientRect() + const items = Array.from( + container.querySelectorAll('[data-message-id]'), + ) + + const item = items.find( + (node) => node.getBoundingClientRect().bottom > containerRect.top + 1, + ) + + if (!item) throw new Error('No visible message found') + + return { + id: item.dataset.messageId, + top: item.getBoundingClientRect().top - containerRect.top, + scrollTop: container.scrollTop, + } + }) +} + +test('chat mode keeps visible messages stable when history is prepended', async ({ + page, +}) => { + await page.goto('/chat/') + await waitForEnd(page) + + await page.evaluate(() => { + const container = document.querySelector('#scroll-container') + if (!container) throw new Error('Container not found') + container.scrollTop = 350 + }) + await page.waitForTimeout(100) + + const before = await firstVisibleMessage(page) + + await page.click('#prepend') + await page.waitForTimeout(100) + + const after = await firstVisibleMessage(page) + + expect(after.id).toBe(before.id) + expect(Math.abs(after.top - before.top)).toBeLessThan(1.01) + expect(after.scrollTop - before.scrollTop).toBeGreaterThan(249) +}) + +test('chat mode does not follow appended messages while reading history', async ({ + page, +}) => { + await page.goto('/chat/') + await waitForEnd(page) + + await page.evaluate(() => { + const container = document.querySelector('#scroll-container') + if (!container) throw new Error('Container not found') + container.scrollTop = 350 + }) + await page.waitForTimeout(100) + + const before = await page.evaluate(() => { + const container = document.querySelector('#scroll-container') + if (!container) throw new Error('Container not found') + return container.scrollTop + }) + + await page.click('#append') + await page.waitForTimeout(100) + + const after = await page.evaluate(() => { + const container = document.querySelector('#scroll-container') + if (!container) throw new Error('Container not found') + return container.scrollTop + }) + + expect(Math.abs(after - before)).toBeLessThan(1.01) + await expect(page.locator('[data-testid="message-m-30"]')).not.toBeVisible() +}) + +test('chat mode follows appended messages from the end', async ({ page }) => { + await page.goto('/chat/') + await waitForEnd(page) + + await page.click('#append') + await waitForEnd(page) + + await expect(page.locator('[data-testid="message-m-30"]')).toBeVisible() +}) + +test('chat mode keeps streaming bottom message pinned as it grows', async ({ + page, +}) => { + await page.goto('/chat/') + await waitForEnd(page) + + await page.click('#grow-last') + await waitForEnd(page) + + await expect(page.locator('[data-testid="message-m-29"]')).toBeVisible() +}) diff --git a/packages/react-virtual/e2e/app/vite.config.ts b/packages/react-virtual/e2e/app/vite.config.ts index 64183e497..964267f31 100644 --- a/packages/react-virtual/e2e/app/vite.config.ts +++ b/packages/react-virtual/e2e/app/vite.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ rollupOptions: { input: { scroll: path.resolve(__dirname, 'scroll/index.html'), + chat: path.resolve(__dirname, 'chat/index.html'), 'measure-element': path.resolve( __dirname, 'measure-element/index.html', diff --git a/packages/react-virtual/playwright.config.ts b/packages/react-virtual/playwright.config.ts index ccd92d03f..562860138 100644 --- a/packages/react-virtual/playwright.config.ts +++ b/packages/react-virtual/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from '@playwright/test' -const PORT = 5173 +const PORT = Number(process.env.VITE_SERVER_PORT ?? 5173) const baseURL = `http://localhost:${PORT}` export default defineConfig({ diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index a1e9dcc78..8186772be 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -33,6 +33,10 @@ type ScrollAlignment = 'start' | 'center' | 'end' | 'auto' type ScrollBehavior = 'auto' | 'smooth' | 'instant' +type ScrollAnchor = 'start' | 'end' + +type FollowOnAppend = boolean | ScrollBehavior + export interface ScrollToOptions { align?: ScrollAlignment behavior?: ScrollBehavior @@ -42,6 +46,8 @@ type ScrollToOffsetOptions = ScrollToOptions type ScrollToIndexOptions = ScrollToOptions +type ScrollToEndOptions = Pick + export interface Range { startIndex: number endIndex: number @@ -328,6 +334,9 @@ export interface VirtualizerOptions< indexAttribute?: string initialMeasurementsCache?: Array lanes?: number + anchorTo?: ScrollAnchor + followOnAppend?: FollowOnAppend + scrollEndThreshold?: number isScrollingResetDelay?: number useScrollendEvent?: boolean enabled?: boolean @@ -352,6 +361,12 @@ type ScrollState = { stableFrames: number } +type PendingScrollAnchor = [ + key: Key | null, + offset: number, + followOnAppend: ScrollBehavior | null, +] + export class Virtualizer< TScrollElement extends Element | Window, TItemElement extends Element, @@ -374,6 +389,7 @@ export class Virtualizer< private prevLanes: number | undefined = undefined private lanesChangedFlag = false private lanesSettling = false + private pendingScrollAnchor: PendingScrollAnchor | null = null scrollRect: Rect | null = null scrollOffset: number | null = null scrollDirection: ScrollDirection | null = null @@ -498,6 +514,9 @@ export class Virtualizer< indexAttribute: 'data-index', initialMeasurementsCache: [], lanes: 1, + anchorTo: 'start', + followOnAppend: false, + scrollEndThreshold: 1, isScrollingResetDelay: 150, enabled: true, isRtl: false, @@ -511,13 +530,101 @@ export class Virtualizer< if (v !== undefined) (merged as any)[key] = v } + const prevOptions = this.options as + | Required> + | undefined + let anchor: [Key, number] | null = null + let followOnAppend: ScrollBehavior | null = null + + if ( + prevOptions !== undefined && + prevOptions.enabled && + merged.enabled && + merged.anchorTo === 'end' && + this.scrollElement !== null + ) { + const prevCount = prevOptions.count + const nextCount = merged.count + const measurements = this.getMeasurements() + const prevFirstKey = + prevCount > 0 + ? (measurements[0]?.key ?? prevOptions.getItemKey(0)) + : null + const prevLastKey = + prevCount > 0 + ? (measurements[prevCount - 1]?.key ?? + prevOptions.getItemKey(prevCount - 1)) + : null + const didCountChange = nextCount !== prevCount + const didEdgeKeysChange = + didCountChange || + (prevCount > 0 && + nextCount > 0 && + (merged.getItemKey(0) !== prevFirstKey || + merged.getItemKey(nextCount - 1) !== prevLastKey)) + + if (didEdgeKeysChange) { + const item = + prevCount > 0 + ? (this.getVirtualItemForOffset(this.getScrollOffset()) ?? + measurements[0]) + : null + + if (item) { + anchor = [item.key, this.getScrollOffset() - item.start] + } + + const behavior = + merged.followOnAppend === true + ? 'auto' + : merged.followOnAppend || null + + if ( + behavior && + nextCount > prevCount && + this.isAtEnd(prevOptions.scrollEndThreshold) && + (prevCount === 0 || merged.getItemKey(nextCount - 1) !== prevLastKey) + ) { + followOnAppend = behavior + } + } + } + this.options = merged + + if (anchor || followOnAppend) { + this.pendingScrollAnchor = [ + anchor?.[0] ?? null, + anchor?.[1] ?? 0, + followOnAppend, + ] + } } private notify = (sync: boolean) => { this.options.onChange?.(this, sync) } + private applyScrollAdjustment(delta: number, behavior?: ScrollBehavior) { + if (delta === 0) return + + if (process.env.NODE_ENV !== 'production' && this.options.debug) { + console.info('correction', delta) + } + + if ( + isIOSWebKit() && + (this.isScrolling || this._iosTouching || this._iosJustTouchEnded) + ) { + this._iosDeferredAdjustment += delta + } else { + this._scrollToOffset(this.getScrollOffset(), { + adjustments: (this.scrollAdjustments += delta), + behavior, + }) + } + } + private maybeNotify = memo( () => { this.calculateRange() @@ -686,6 +793,34 @@ export class Virtualizer< behavior: undefined, }) } + + const anchor = this.pendingScrollAnchor + this.pendingScrollAnchor = null + + if (anchor && this.scrollElement && this.options.enabled) { + const [key, offset, followOnAppend] = anchor + + if (key !== null) { + const { count, getItemKey } = this.options + let index = 0 + while (index < count && getItemKey(index) !== key) { + index++ + } + + const item = index < count ? this.getMeasurements()[index] : undefined + if (item) { + const delta = item.start + offset - this.getScrollOffset() + + if (!approxEqual(delta, 0)) { + this.applyScrollAdjustment(delta) + } + } + } + + if (followOnAppend) { + this.scrollToEnd({ behavior: followOnAppend }) + } + } } // Apply any accumulated iOS-deferred scroll adjustment, but only when we're @@ -1284,7 +1419,12 @@ export class Virtualizer< const delta = size - itemSize if (delta !== 0) { - if ( + const wasAtEnd = + this.options.anchorTo === 'end' && + this.scrollState?.behavior !== 'smooth' && + this.isAtEnd() + const prevTotalSize = wasAtEnd ? this.getTotalSize() : 0 + const shouldAdjustScroll = this.scrollState?.behavior !== 'smooth' && (this.shouldAdjustScrollPositionOnItemSizeChange !== undefined ? this.shouldAdjustScrollPositionOnItemSizeChange( @@ -1309,26 +1449,6 @@ export class Virtualizer< // behavior can pass shouldAdjustScrollPositionOnItemSizeChange. itemStart < this.getScrollOffset() + this.scrollAdjustments && this.scrollDirection !== 'backward') - ) { - if (process.env.NODE_ENV !== 'production' && this.options.debug) { - console.info('correction', delta) - } - // On iOS WebKit, writing scrollTop while a finger is on screen or - // momentum-scroll is running cancels the in-flight scroll. Defer - // the adjustment until iOS is fully settled — flushed by either - // the scroll callback or the touchend grace-timer. - if ( - isIOSWebKit() && - (this.isScrolling || this._iosTouching || this._iosJustTouchEnded) - ) { - this._iosDeferredAdjustment += delta - } else { - this._scrollToOffset(this.getScrollOffset(), { - adjustments: (this.scrollAdjustments += delta), - behavior: undefined, - }) - } - } if (this.pendingMin === null || index < this.pendingMin) { this.pendingMin = index @@ -1336,6 +1456,12 @@ export class Virtualizer< this.itemSizeCache.set(key, size) this.itemSizeCacheVersion++ + if (wasAtEnd) { + this.applyScrollAdjustment(this.getTotalSize() - prevTotalSize) + } else if (shouldAdjustScroll) { + this.applyScrollAdjustment(delta) + } + this.notify(false) } } @@ -1398,6 +1524,17 @@ export class Virtualizer< } } + getDistanceFromEnd = () => { + return Math.max( + this.getTotalSize() - this.getSize() - this.getScrollOffset(), + 0, + ) + } + + isAtEnd = (threshold = this.options.scrollEndThreshold) => { + return this.getDistanceFromEnd() <= threshold + } + getOffsetForAlignment = ( toOffset: number, align: ScrollAlignment, @@ -1533,6 +1670,20 @@ export class Virtualizer< this.scheduleScrollReconcile() } + scrollToEnd = ({ behavior = 'auto' }: ScrollToEndOptions = {}) => { + if (this.options.count > 0) { + this.scrollToIndex(this.options.count - 1, { + align: 'end', + behavior, + }) + return + } + + this.scrollToOffset(Math.max(this.getTotalSize() - this.getSize(), 0), { + behavior, + }) + } + getTotalSize = () => { const measurements = this.getMeasurements() diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index d49db8aa4..c75c4391f 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -2172,6 +2172,196 @@ test('scroll-up jank: idle (scrollDirection=null) still applies adjustment', () expect(scrollToFn).toHaveBeenCalled() }) +// ─── end anchoring / chat-style reverse virtualization ────────────────────── + +function createChatVirtualizer({ + messages, + offset, + viewportSize = 200, + itemSize = 50, + followOnAppend = false, + threshold = 1, +}: { + messages: Array<{ id: string }> + offset: number + viewportSize?: number + itemSize?: number + followOnAppend?: boolean | 'auto' | 'smooth' | 'instant' + threshold?: number +}) { + let currentMessages = messages + const scrollToFn = vi.fn() + const scrollElement = { + scrollTop: offset, + scrollLeft: 0, + scrollHeight: messages.length * itemSize, + scrollWidth: 1000, + clientHeight: viewportSize, + clientWidth: 400, + offsetHeight: viewportSize, + ownerDocument: { + defaultView: { + requestAnimationFrame: (_cb: FrameRequestCallback) => { + return 1 + }, + cancelAnimationFrame: vi.fn(), + performance: { now: () => Date.now() }, + ResizeObserver: vi.fn(function () { + return { + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + } + }), + }, + }, + } as unknown as HTMLDivElement + + const makeOptions = () => { + const messagesSnapshot = currentMessages + + return { + count: messagesSnapshot.length, + estimateSize: () => itemSize, + getItemKey: (index: number) => messagesSnapshot[index]!.id, + getScrollElement: () => scrollElement, + scrollToFn, + observeElementRect: ( + _instance: any, + cb: (rect: { width: number; height: number }) => void, + ) => { + cb({ width: 400, height: viewportSize }) + return () => {} + }, + observeElementOffset: ( + _instance: any, + cb: (offset: number, isScrolling: boolean) => void, + ) => { + cb(scrollElement.scrollTop, false) + return () => {} + }, + anchorTo: 'end' as const, + followOnAppend, + scrollEndThreshold: threshold, + } + } + + const virtualizer = new Virtualizer(makeOptions()) + virtualizer._willUpdate() + virtualizer['getMeasurements']() + scrollToFn.mockClear() + + return { + virtualizer, + scrollElement, + scrollToFn, + setMessages(nextMessages: Array<{ id: string }>) { + currentMessages = nextMessages + ;(scrollElement as any).scrollHeight = nextMessages.length * itemSize + virtualizer.setOptions(makeOptions()) + virtualizer._willUpdate() + }, + } +} + +test('anchorTo:end keeps visible content stable when older items are prepended', () => { + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const { setMessages, scrollToFn } = createChatVirtualizer({ + messages, + offset: 100, + }) + + setMessages([{ id: 'm--2' }, { id: 'm--1' }, ...messages]) + + expect(scrollToFn).toHaveBeenCalledTimes(1) + const [offset, options] = scrollToFn.mock.calls[0]! + expect(offset).toBe(100) + expect(options.adjustments).toBe(100) +}) + +test('anchorTo:end does not yank a scrolled-up user when items append', () => { + const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) + const { setMessages, scrollToFn } = createChatVirtualizer({ + messages, + offset: 100, + followOnAppend: true, + }) + + setMessages([...messages, { id: 'm-8' }]) + + expect(scrollToFn).not.toHaveBeenCalled() +}) + +test('followOnAppend keeps an end-pinned user at the end when items append', () => { + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const { setMessages, scrollToFn } = createChatVirtualizer({ + messages, + offset: 50, + followOnAppend: true, + }) + + setMessages([...messages, { id: 'm-5' }]) + + expect(scrollToFn).toHaveBeenCalledTimes(1) + const [offset, options] = scrollToFn.mock.calls[0]! + expect(offset).toBe(100) + expect(options.behavior).toBe('auto') +}) + +test('followOnAppend accepts smooth behavior', () => { + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const { setMessages, scrollToFn } = createChatVirtualizer({ + messages, + offset: 50, + followOnAppend: 'smooth', + }) + + setMessages([...messages, { id: 'm-5' }]) + + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![1].behavior).toBe('smooth') +}) + +test('anchorTo:end keeps a pinned streaming message pinned as it grows', () => { + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer, scrollToFn } = createChatVirtualizer({ + messages, + offset: 50, + }) + + virtualizer.resizeItem(4, 120) + + expect(scrollToFn).toHaveBeenCalledTimes(1) + const [offset, options] = scrollToFn.mock.calls[0]! + expect(offset).toBe(50) + expect(options.adjustments).toBe(70) +}) + +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({ + messages, + offset: 50, + }) + + virtualizer.resizeItem(7, 120) + + expect(scrollToFn).not.toHaveBeenCalled() +}) + +test('isAtEnd and getDistanceFromEnd use virtualized total size', () => { + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer } = createChatVirtualizer({ + messages, + offset: 40, + threshold: 15, + }) + + expect(virtualizer.getDistanceFromEnd()).toBe(10) + expect(virtualizer.isAtEnd()).toBe(true) + expect(virtualizer.isAtEnd(5)).toBe(false) +}) + test('takeSnapshot: returns measured items only, restorable via initialMeasurementsCache', () => { const v1 = new Virtualizer({ count: 20, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 415ad7695..76e63323b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -645,6 +645,34 @@ importers: specifier: ^6.4.2 version: 6.4.2(@types/node@24.9.2)(jiti@2.6.1)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.8.1) + examples/react/chat: + dependencies: + '@tanstack/react-virtual': + specifier: ^3.13.25 + version: link:../../../packages/react-virtual + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.23 + version: 18.3.26 + '@types/react-dom': + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.26) + '@vitejs/plugin-react': + specifier: ^4.5.2 + version: 4.7.0(vite@6.4.2(@types/node@24.9.2)(jiti@2.6.1)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.8.1)) + typescript: + specifier: 5.6.3 + version: 5.6.3 + vite: + specifier: ^6.4.2 + version: 6.4.2(@types/node@24.9.2)(jiti@2.6.1)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.8.1) + examples/react/dynamic: dependencies: '@faker-js/faker': From 2a80e004f33a814922768b22283eb8965a1fead5 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 25 May 2026 10:47:10 -0600 Subject: [PATCH 2/2] fix chat anchoring review nits --- examples/react/chat/src/main.tsx | 8 +- packages/react-virtual/e2e/app/chat/main.tsx | 4 +- .../react-virtual/e2e/app/test/chat.spec.ts | 85 ++++++++++++++----- packages/virtual-core/src/index.ts | 8 +- packages/virtual-core/tests/index.test.ts | 7 +- 5 files changed, 80 insertions(+), 32 deletions(-) diff --git a/examples/react/chat/src/main.tsx b/examples/react/chat/src/main.tsx index dc6c3cdc4..2f4d9a171 100644 --- a/examples/react/chat/src/main.tsx +++ b/examples/react/chat/src/main.tsx @@ -1,5 +1,5 @@ import React from 'react' -import ReactDOM from 'react-dom/client' +import { createRoot } from 'react-dom/client' import { useVirtualizer } from '@tanstack/react-virtual' import './index.css' @@ -124,13 +124,15 @@ function App() { if (didInitialScroll) return virtualizer.scrollToEnd() setDidInitialScroll(true) + }, [didInitialScroll, virtualizer]) + React.useEffect(() => { const id = window.setTimeout(() => { setAutoHistoryEnabled(true) }, 250) return () => window.clearTimeout(id) - }, [didInitialScroll, virtualizer]) + }, []) React.useEffect(() => { return () => { @@ -215,4 +217,4 @@ function App() { ) } -ReactDOM.createRoot(document.getElementById('root')!).render() +createRoot(document.getElementById('root')!).render() diff --git a/packages/react-virtual/e2e/app/chat/main.tsx b/packages/react-virtual/e2e/app/chat/main.tsx index c9dbbfe6f..497095812 100644 --- a/packages/react-virtual/e2e/app/chat/main.tsx +++ b/packages/react-virtual/e2e/app/chat/main.tsx @@ -1,5 +1,5 @@ import React from 'react' -import ReactDOM from 'react-dom/client' +import { createRoot } from 'react-dom/client' import { useVirtualizer } from '@tanstack/react-virtual' type Message = { @@ -141,4 +141,4 @@ function App() { ) } -ReactDOM.createRoot(document.getElementById('root')!).render() +createRoot(document.getElementById('root')!).render() diff --git a/packages/react-virtual/e2e/app/test/chat.spec.ts b/packages/react-virtual/e2e/app/test/chat.spec.ts index 40533f8ec..b35b3333b 100644 --- a/packages/react-virtual/e2e/app/test/chat.spec.ts +++ b/packages/react-virtual/e2e/app/test/chat.spec.ts @@ -15,7 +15,7 @@ async function waitForEnd(page: Page) { .toBeLessThan(1.01) } -async function firstVisibleMessage(page: Page) { +async function maybeFirstVisibleMessage(page: Page) { return page.evaluate(() => { const container = document.querySelector('#scroll-container') if (!container) throw new Error('Container not found') @@ -25,11 +25,15 @@ async function firstVisibleMessage(page: Page) { container.querySelectorAll('[data-message-id]'), ) - const item = items.find( - (node) => node.getBoundingClientRect().bottom > containerRect.top + 1, - ) + const item = items.find((node) => { + const rect = node.getBoundingClientRect() + return ( + rect.bottom > containerRect.top + 1 && + rect.top < containerRect.bottom - 1 + ) + }) - if (!item) throw new Error('No visible message found') + if (!item) return null return { id: item.dataset.messageId, @@ -39,6 +43,33 @@ async function firstVisibleMessage(page: Page) { }) } +async function firstVisibleMessage(page: Page) { + const item = await maybeFirstVisibleMessage(page) + if (!item) throw new Error('No visible message found') + return item +} + +async function getScrollState(page: Page) { + return page.evaluate(() => { + const container = document.querySelector('#scroll-container') + if (!container) throw new Error('Container not found') + + return { + scrollTop: container.scrollTop, + scrollHeight: container.scrollHeight, + } + }) +} + +async function waitForFirstVisibleAtOffset(page: Page, scrollTop: number) { + await expect + .poll(async () => { + const item = await maybeFirstVisibleMessage(page) + return item?.scrollTop + }) + .toBe(scrollTop) +} + test('chat mode keeps visible messages stable when history is prepended', async ({ page, }) => { @@ -50,12 +81,22 @@ test('chat mode keeps visible messages stable when history is prepended', async if (!container) throw new Error('Container not found') container.scrollTop = 350 }) - await page.waitForTimeout(100) + await waitForFirstVisibleAtOffset(page, 350) const before = await firstVisibleMessage(page) await page.click('#prepend') - await page.waitForTimeout(100) + await expect + .poll(async () => { + const after = await maybeFirstVisibleMessage(page) + return ( + after !== null && + after.id === before.id && + Math.abs(after.top - before.top) < 1.01 && + after.scrollTop - before.scrollTop > 249 + ) + }) + .toBe(true) const after = await firstVisibleMessage(page) @@ -75,24 +116,24 @@ test('chat mode does not follow appended messages while reading history', async if (!container) throw new Error('Container not found') container.scrollTop = 350 }) - await page.waitForTimeout(100) + await waitForFirstVisibleAtOffset(page, 350) - const before = await page.evaluate(() => { - const container = document.querySelector('#scroll-container') - if (!container) throw new Error('Container not found') - return container.scrollTop - }) + const before = await getScrollState(page) await page.click('#append') - await page.waitForTimeout(100) - - const after = await page.evaluate(() => { - const container = document.querySelector('#scroll-container') - if (!container) throw new Error('Container not found') - return container.scrollTop - }) - - expect(Math.abs(after - before)).toBeLessThan(1.01) + await expect + .poll(async () => { + const after = await getScrollState(page) + return ( + after.scrollHeight > before.scrollHeight && + Math.abs(after.scrollTop - before.scrollTop) < 1.01 + ) + }) + .toBe(true) + + const after = await getScrollState(page) + + expect(Math.abs(after.scrollTop - before.scrollTop)).toBeLessThan(1.01) await expect(page.locator('[data-testid="message-m-30"]')).not.toBeVisible() }) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 8186772be..7890df33b 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1422,7 +1422,7 @@ export class Virtualizer< const wasAtEnd = this.options.anchorTo === 'end' && this.scrollState?.behavior !== 'smooth' && - this.isAtEnd() + this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold const prevTotalSize = wasAtEnd ? this.getTotalSize() : 0 const shouldAdjustScroll = this.scrollState?.behavior !== 'smooth' && @@ -1524,13 +1524,17 @@ export class Virtualizer< } } - getDistanceFromEnd = () => { + private getVirtualDistanceFromEnd = () => { return Math.max( this.getTotalSize() - this.getSize() - this.getScrollOffset(), 0, ) } + getDistanceFromEnd = () => { + return Math.max(this.getMaxScrollOffset() - this.getScrollOffset(), 0) + } + isAtEnd = (threshold = this.options.scrollEndThreshold) => { return this.getDistanceFromEnd() <= threshold } diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index c75c4391f..e57119a10 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -2257,8 +2257,8 @@ function createChatVirtualizer({ scrollToFn, setMessages(nextMessages: Array<{ id: string }>) { currentMessages = nextMessages - ;(scrollElement as any).scrollHeight = nextMessages.length * itemSize virtualizer.setOptions(makeOptions()) + ;(scrollElement as any).scrollHeight = nextMessages.length * itemSize virtualizer._willUpdate() }, } @@ -2324,11 +2324,12 @@ test('followOnAppend accepts smooth behavior', () => { test('anchorTo:end keeps a pinned streaming message pinned as it grows', () => { const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) - const { virtualizer, scrollToFn } = createChatVirtualizer({ + const { virtualizer, scrollElement, scrollToFn } = createChatVirtualizer({ messages, offset: 50, }) + ;(scrollElement as any).scrollHeight = 320 virtualizer.resizeItem(4, 120) expect(scrollToFn).toHaveBeenCalledTimes(1) @@ -2349,7 +2350,7 @@ test('anchorTo:end does not follow streaming growth when user is away from end', expect(scrollToFn).not.toHaveBeenCalled() }) -test('isAtEnd and getDistanceFromEnd use virtualized total size', () => { +test('isAtEnd and getDistanceFromEnd use the scroll element max offset', () => { const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) const { virtualizer } = createChatVirtualizer({ messages,