From fc992ab00a15166311b79bd7580736cf01e8cc1a Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 25 May 2026 10:53:30 -0600 Subject: [PATCH 1/2] feat: support end-anchored virtualizers (#1173) * feat: support end-anchored virtualizers * fix chat anchoring review nits --- .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 | 220 ++++++++++++++++++ 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 | 160 +++++++++++++ packages/react-virtual/e2e/app/vite.config.ts | 1 + packages/react-virtual/playwright.config.ts | 2 +- packages/virtual-core/src/index.ts | 197 ++++++++++++++-- packages/virtual-core/tests/index.test.ts | 191 +++++++++++++++ pnpm-lock.yaml | 28 +++ 21 files changed, 1348 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..2f4d9a171 --- /dev/null +++ b/examples/react/chat/src/main.tsx @@ -0,0 +1,220 @@ +import React from 'react' +import { createRoot } 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) + }, [didInitialScroll, virtualizer]) + + React.useEffect(() => { + const id = window.setTimeout(() => { + setAutoHistoryEnabled(true) + }, 250) + + return () => window.clearTimeout(id) + }, []) + + 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 || '...'} +
+
+ ) + })} +
+
+
+
+ ) +} + +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..497095812 --- /dev/null +++ b/packages/react-virtual/e2e/app/chat/main.tsx @@ -0,0 +1,144 @@ +import React from 'react' +import { createRoot } 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} +
+
+ ) + })} +
+
+
+ ) +} + +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..b35b3333b --- /dev/null +++ b/packages/react-virtual/e2e/app/test/chat.spec.ts @@ -0,0 +1,160 @@ +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 maybeFirstVisibleMessage(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) => { + const rect = node.getBoundingClientRect() + return ( + rect.bottom > containerRect.top + 1 && + rect.top < containerRect.bottom - 1 + ) + }) + + if (!item) return null + + return { + id: item.dataset.messageId, + top: item.getBoundingClientRect().top - containerRect.top, + scrollTop: container.scrollTop, + } + }) +} + +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, +}) => { + 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 waitForFirstVisibleAtOffset(page, 350) + + const before = await firstVisibleMessage(page) + + await page.click('#prepend') + 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) + + 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 waitForFirstVisibleAtOffset(page, 350) + + const before = await getScrollState(page) + + await page.click('#append') + 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() +}) + +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..7890df33b 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.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold + 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,21 @@ export class Virtualizer< } } + 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 + } + getOffsetForAlignment = ( toOffset: number, align: ScrollAlignment, @@ -1533,6 +1674,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..e57119a10 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -2172,6 +2172,197 @@ 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 + virtualizer.setOptions(makeOptions()) + ;(scrollElement as any).scrollHeight = nextMessages.length * itemSize + 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, scrollElement, scrollToFn } = createChatVirtualizer({ + messages, + offset: 50, + }) + + ;(scrollElement as any).scrollHeight = 320 + 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 the scroll element max offset', () => { + 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 693d915e0670cbba5c3e42d0d2a46f085dd7d44e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 10:58:11 -0600 Subject: [PATCH 2/2] ci: Version Packages (#1174) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/chat-reverse-virtualization.md | 9 --- benchmarks/CHANGELOG.md | 7 ++ benchmarks/package.json | 2 +- examples/angular/dynamic/package.json | 2 +- examples/angular/fixed/package.json | 2 +- examples/angular/infinite-scroll/package.json | 2 +- examples/angular/padding/package.json | 2 +- examples/angular/smooth-scroll/package.json | 2 +- examples/angular/sticky/package.json | 2 +- examples/angular/table/package.json | 2 +- examples/angular/variable/package.json | 2 +- examples/angular/window/package.json | 2 +- examples/lit/dynamic/package.json | 4 +- examples/lit/fixed/package.json | 4 +- examples/react/chat/package.json | 2 +- examples/react/dynamic/package.json | 2 +- examples/react/fixed/package.json | 2 +- examples/react/infinite-scroll/package.json | 2 +- examples/react/padding/package.json | 2 +- examples/react/scroll-padding/package.json | 2 +- examples/react/smooth-scroll/package.json | 2 +- examples/react/sticky/package.json | 2 +- examples/react/table/package.json | 2 +- examples/react/variable/package.json | 2 +- examples/react/window/package.json | 2 +- examples/svelte/dynamic/package.json | 2 +- examples/svelte/fixed/package.json | 2 +- examples/svelte/infinite-scroll/package.json | 2 +- examples/svelte/smooth-scroll/package.json | 2 +- examples/svelte/sticky/package.json | 2 +- examples/svelte/table/package.json | 2 +- examples/vue/dynamic/package.json | 2 +- examples/vue/fixed/package.json | 2 +- examples/vue/infinite-scroll/package.json | 2 +- examples/vue/padding/package.json | 2 +- examples/vue/scroll-padding/package.json | 2 +- examples/vue/smooth-scroll/package.json | 2 +- examples/vue/sticky/package.json | 2 +- examples/vue/table/package.json | 2 +- examples/vue/variable/package.json | 2 +- packages/angular-virtual/CHANGELOG.md | 7 ++ packages/angular-virtual/package.json | 2 +- packages/lit-virtual/CHANGELOG.md | 7 ++ packages/lit-virtual/package.json | 2 +- packages/react-virtual/CHANGELOG.md | 7 ++ packages/react-virtual/package.json | 2 +- packages/solid-virtual/CHANGELOG.md | 7 ++ packages/solid-virtual/package.json | 2 +- packages/svelte-virtual/CHANGELOG.md | 7 ++ packages/svelte-virtual/package.json | 2 +- packages/virtual-core/CHANGELOG.md | 10 +++ packages/virtual-core/package.json | 2 +- packages/vue-virtual/CHANGELOG.md | 7 ++ packages/vue-virtual/package.json | 2 +- pnpm-lock.yaml | 78 +++++++++---------- 55 files changed, 145 insertions(+), 95 deletions(-) delete mode 100644 .changeset/chat-reverse-virtualization.md diff --git a/.changeset/chat-reverse-virtualization.md b/.changeset/chat-reverse-virtualization.md deleted file mode 100644 index cf8cad7cf..000000000 --- a/.changeset/chat-reverse-virtualization.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@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/benchmarks/CHANGELOG.md b/benchmarks/CHANGELOG.md index 1a027e3fd..2c7733523 100644 --- a/benchmarks/CHANGELOG.md +++ b/benchmarks/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/virtual-benchmarks +## 0.0.2 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/react-virtual@3.13.26 + ## 0.0.1 ### Patch Changes diff --git a/benchmarks/package.json b/benchmarks/package.json index efc24d012..c900a764e 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -1,7 +1,7 @@ { "name": "@tanstack/virtual-benchmarks", "private": true, - "version": "0.0.1", + "version": "0.0.2", "type": "module", "scripts": { "dev": "vite", diff --git a/examples/angular/dynamic/package.json b/examples/angular/dynamic/package.json index 85db0193d..41e86f094 100644 --- a/examples/angular/dynamic/package.json +++ b/examples/angular/dynamic/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", "@faker-js/faker": "^8.4.1", - "@tanstack/angular-virtual": "^5.0.1", + "@tanstack/angular-virtual": "^5.0.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/fixed/package.json b/examples/angular/fixed/package.json index 3f0f0bb99..fc87e7b9a 100644 --- a/examples/angular/fixed/package.json +++ b/examples/angular/fixed/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", - "@tanstack/angular-virtual": "^5.0.1", + "@tanstack/angular-virtual": "^5.0.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/infinite-scroll/package.json b/examples/angular/infinite-scroll/package.json index e5b8b9b69..ee094cae1 100644 --- a/examples/angular/infinite-scroll/package.json +++ b/examples/angular/infinite-scroll/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", "@tanstack/angular-query-experimental": "5.80.7", - "@tanstack/angular-virtual": "^5.0.1", + "@tanstack/angular-virtual": "^5.0.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/padding/package.json b/examples/angular/padding/package.json index dd82276a9..53839b2ff 100644 --- a/examples/angular/padding/package.json +++ b/examples/angular/padding/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", - "@tanstack/angular-virtual": "^5.0.1", + "@tanstack/angular-virtual": "^5.0.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/smooth-scroll/package.json b/examples/angular/smooth-scroll/package.json index c03af4296..1539938dd 100644 --- a/examples/angular/smooth-scroll/package.json +++ b/examples/angular/smooth-scroll/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", - "@tanstack/angular-virtual": "^5.0.1", + "@tanstack/angular-virtual": "^5.0.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/sticky/package.json b/examples/angular/sticky/package.json index ac48a8fb5..0bea4142b 100644 --- a/examples/angular/sticky/package.json +++ b/examples/angular/sticky/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", "@faker-js/faker": "^8.4.1", - "@tanstack/angular-virtual": "^5.0.1", + "@tanstack/angular-virtual": "^5.0.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/table/package.json b/examples/angular/table/package.json index bcb63c2e4..89956c47c 100644 --- a/examples/angular/table/package.json +++ b/examples/angular/table/package.json @@ -19,7 +19,7 @@ "@angular/router": "^19.0.0", "@faker-js/faker": "^8.4.1", "@tanstack/angular-table": "8.21.3", - "@tanstack/angular-virtual": "^5.0.1", + "@tanstack/angular-virtual": "^5.0.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/variable/package.json b/examples/angular/variable/package.json index 75cd2c3d0..cadb1fdd9 100644 --- a/examples/angular/variable/package.json +++ b/examples/angular/variable/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", - "@tanstack/angular-virtual": "^5.0.1", + "@tanstack/angular-virtual": "^5.0.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/window/package.json b/examples/angular/window/package.json index 86f339baf..de71f556c 100644 --- a/examples/angular/window/package.json +++ b/examples/angular/window/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", - "@tanstack/angular-virtual": "^5.0.1", + "@tanstack/angular-virtual": "^5.0.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/lit/dynamic/package.json b/examples/lit/dynamic/package.json index 485af5b01..a96216714 100644 --- a/examples/lit/dynamic/package.json +++ b/examples/lit/dynamic/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/lit-virtual": "^3.13.26", - "@tanstack/virtual-core": "^3.15.0", + "@tanstack/lit-virtual": "^3.13.27", + "@tanstack/virtual-core": "^3.16.0", "lit": "^3.3.0" }, "devDependencies": { diff --git a/examples/lit/fixed/package.json b/examples/lit/fixed/package.json index ab3f85048..40f02887d 100644 --- a/examples/lit/fixed/package.json +++ b/examples/lit/fixed/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/lit-virtual": "^3.13.26", - "@tanstack/virtual-core": "^3.15.0", + "@tanstack/lit-virtual": "^3.13.27", + "@tanstack/virtual-core": "^3.16.0", "lit": "^3.3.0" }, "devDependencies": { diff --git a/examples/react/chat/package.json b/examples/react/chat/package.json index e839ab29c..b265f8785 100644 --- a/examples/react/chat/package.json +++ b/examples/react/chat/package.json @@ -8,7 +8,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.13.25", + "@tanstack/react-virtual": "^3.13.26", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/examples/react/dynamic/package.json b/examples/react/dynamic/package.json index 87ded4099..395d4b11e 100644 --- a/examples/react/dynamic/package.json +++ b/examples/react/dynamic/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/react-virtual": "^3.13.25", + "@tanstack/react-virtual": "^3.13.26", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/examples/react/fixed/package.json b/examples/react/fixed/package.json index af92e2f54..599d94093 100644 --- a/examples/react/fixed/package.json +++ b/examples/react/fixed/package.json @@ -8,7 +8,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.13.25", + "@tanstack/react-virtual": "^3.13.26", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/examples/react/infinite-scroll/package.json b/examples/react/infinite-scroll/package.json index 427a2111f..c1c4bfd82 100644 --- a/examples/react/infinite-scroll/package.json +++ b/examples/react/infinite-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.80.7", - "@tanstack/react-virtual": "^3.13.25", + "@tanstack/react-virtual": "^3.13.26", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/examples/react/padding/package.json b/examples/react/padding/package.json index 4a27b3dc3..2fd587992 100644 --- a/examples/react/padding/package.json +++ b/examples/react/padding/package.json @@ -9,7 +9,7 @@ "start": "vite" }, "dependencies": { - "@tanstack/react-virtual": "^3.13.25", + "@tanstack/react-virtual": "^3.13.26", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/examples/react/scroll-padding/package.json b/examples/react/scroll-padding/package.json index fcce013b7..e09bf1205 100644 --- a/examples/react/scroll-padding/package.json +++ b/examples/react/scroll-padding/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@react-hookz/web": "^25.1.1", - "@tanstack/react-virtual": "^3.13.25", + "@tanstack/react-virtual": "^3.13.26", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/examples/react/smooth-scroll/package.json b/examples/react/smooth-scroll/package.json index c56afcf20..acf85b83c 100644 --- a/examples/react/smooth-scroll/package.json +++ b/examples/react/smooth-scroll/package.json @@ -9,7 +9,7 @@ "start": "vite" }, "dependencies": { - "@tanstack/react-virtual": "^3.13.25", + "@tanstack/react-virtual": "^3.13.26", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/examples/react/sticky/package.json b/examples/react/sticky/package.json index 41e7267d4..ab1e47348 100644 --- a/examples/react/sticky/package.json +++ b/examples/react/sticky/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/react-virtual": "^3.13.25", + "@tanstack/react-virtual": "^3.13.26", "lodash": "^4.17.21", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/examples/react/table/package.json b/examples/react/table/package.json index 925d5e94e..72e8dce58 100644 --- a/examples/react/table/package.json +++ b/examples/react/table/package.json @@ -11,7 +11,7 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.13.25", + "@tanstack/react-virtual": "^3.13.26", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/examples/react/variable/package.json b/examples/react/variable/package.json index 46ba91a98..d70272229 100644 --- a/examples/react/variable/package.json +++ b/examples/react/variable/package.json @@ -9,7 +9,7 @@ "start": "vite" }, "dependencies": { - "@tanstack/react-virtual": "^3.13.25", + "@tanstack/react-virtual": "^3.13.26", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/examples/react/window/package.json b/examples/react/window/package.json index c1a78d43b..27a602e5d 100644 --- a/examples/react/window/package.json +++ b/examples/react/window/package.json @@ -8,7 +8,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.13.25", + "@tanstack/react-virtual": "^3.13.26", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/examples/svelte/dynamic/package.json b/examples/svelte/dynamic/package.json index ffe7ade48..a5a10cd24 100644 --- a/examples/svelte/dynamic/package.json +++ b/examples/svelte/dynamic/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/svelte-virtual": "^3.13.25" + "@tanstack/svelte-virtual": "^3.13.26" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/fixed/package.json b/examples/svelte/fixed/package.json index 06e644ce9..27fe362d9 100644 --- a/examples/svelte/fixed/package.json +++ b/examples/svelte/fixed/package.json @@ -9,7 +9,7 @@ "check": "svelte-check --tsconfig ./tsconfig.json" }, "dependencies": { - "@tanstack/svelte-virtual": "^3.13.25" + "@tanstack/svelte-virtual": "^3.13.26" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/infinite-scroll/package.json b/examples/svelte/infinite-scroll/package.json index 57130e71c..ecf02098a 100644 --- a/examples/svelte/infinite-scroll/package.json +++ b/examples/svelte/infinite-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@tanstack/svelte-query": "^5.80.7", - "@tanstack/svelte-virtual": "^3.13.25" + "@tanstack/svelte-virtual": "^3.13.26" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/smooth-scroll/package.json b/examples/svelte/smooth-scroll/package.json index d6766b75d..64487f0a2 100644 --- a/examples/svelte/smooth-scroll/package.json +++ b/examples/svelte/smooth-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/svelte-virtual": "^3.13.25" + "@tanstack/svelte-virtual": "^3.13.26" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/sticky/package.json b/examples/svelte/sticky/package.json index c82393be5..31e1682c2 100644 --- a/examples/svelte/sticky/package.json +++ b/examples/svelte/sticky/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/svelte-virtual": "^3.13.25", + "@tanstack/svelte-virtual": "^3.13.26", "lodash": "^4.17.21" }, "devDependencies": { diff --git a/examples/svelte/table/package.json b/examples/svelte/table/package.json index e13197b19..b84efcf14 100644 --- a/examples/svelte/table/package.json +++ b/examples/svelte/table/package.json @@ -11,7 +11,7 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@tanstack/svelte-table": "^8.21.3", - "@tanstack/svelte-virtual": "^3.13.25" + "@tanstack/svelte-virtual": "^3.13.26" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/vue/dynamic/package.json b/examples/vue/dynamic/package.json index cf72a3b1d..bc9cf1745 100644 --- a/examples/vue/dynamic/package.json +++ b/examples/vue/dynamic/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/vue-virtual": "^3.13.25", + "@tanstack/vue-virtual": "^3.13.26", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/fixed/package.json b/examples/vue/fixed/package.json index d0abefcd7..e15cafd26 100644 --- a/examples/vue/fixed/package.json +++ b/examples/vue/fixed/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.25", + "@tanstack/vue-virtual": "^3.13.26", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/infinite-scroll/package.json b/examples/vue/infinite-scroll/package.json index 8c1f5fb6d..7bdf63478 100644 --- a/examples/vue/infinite-scroll/package.json +++ b/examples/vue/infinite-scroll/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@tanstack/vue-query": "^5.80.7", - "@tanstack/vue-virtual": "^3.13.25", + "@tanstack/vue-virtual": "^3.13.26", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/padding/package.json b/examples/vue/padding/package.json index c55fe2ef3..9fb104144 100644 --- a/examples/vue/padding/package.json +++ b/examples/vue/padding/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.25", + "@tanstack/vue-virtual": "^3.13.26", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/scroll-padding/package.json b/examples/vue/scroll-padding/package.json index 585f8031d..2389fb6ca 100644 --- a/examples/vue/scroll-padding/package.json +++ b/examples/vue/scroll-padding/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.25", + "@tanstack/vue-virtual": "^3.13.26", "@vueuse/core": "^12.8.2", "vue": "^3.5.16" }, diff --git a/examples/vue/smooth-scroll/package.json b/examples/vue/smooth-scroll/package.json index d67a8bbf0..ff740cf55 100644 --- a/examples/vue/smooth-scroll/package.json +++ b/examples/vue/smooth-scroll/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.25", + "@tanstack/vue-virtual": "^3.13.26", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/sticky/package.json b/examples/vue/sticky/package.json index 3c0013d1a..418a074c4 100644 --- a/examples/vue/sticky/package.json +++ b/examples/vue/sticky/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/vue-virtual": "^3.13.25", + "@tanstack/vue-virtual": "^3.13.26", "lodash": "^4.17.21", "vue": "^3.5.16" }, diff --git a/examples/vue/table/package.json b/examples/vue/table/package.json index 0776776b1..c6d541b13 100644 --- a/examples/vue/table/package.json +++ b/examples/vue/table/package.json @@ -10,7 +10,7 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@tanstack/vue-table": "^8.21.3", - "@tanstack/vue-virtual": "^3.13.25", + "@tanstack/vue-virtual": "^3.13.26", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/variable/package.json b/examples/vue/variable/package.json index 4f5620029..384a4b495 100644 --- a/examples/vue/variable/package.json +++ b/examples/vue/variable/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.25", + "@tanstack/vue-virtual": "^3.13.26", "vue": "^3.5.16" }, "devDependencies": { diff --git a/packages/angular-virtual/CHANGELOG.md b/packages/angular-virtual/CHANGELOG.md index 6468d502e..4e96548e5 100644 --- a/packages/angular-virtual/CHANGELOG.md +++ b/packages/angular-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/angular-virtual +## 5.0.2 + +### Patch Changes + +- Updated dependencies [[`fc992ab`](https://github.com/TanStack/virtual/commit/fc992ab00a15166311b79bd7580736cf01e8cc1a)]: + - @tanstack/virtual-core@3.16.0 + ## 5.0.1 ### Patch Changes diff --git a/packages/angular-virtual/package.json b/packages/angular-virtual/package.json index 8273490e6..eac8041ab 100644 --- a/packages/angular-virtual/package.json +++ b/packages/angular-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/angular-virtual", - "version": "5.0.1", + "version": "5.0.2", "description": "Headless UI for virtualizing scrollable elements in Angular", "author": "Garrett Darnell", "license": "MIT", diff --git a/packages/lit-virtual/CHANGELOG.md b/packages/lit-virtual/CHANGELOG.md index 2a20701d5..af1dd3760 100644 --- a/packages/lit-virtual/CHANGELOG.md +++ b/packages/lit-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/lit-virtual +## 3.13.27 + +### Patch Changes + +- Updated dependencies [[`fc992ab`](https://github.com/TanStack/virtual/commit/fc992ab00a15166311b79bd7580736cf01e8cc1a)]: + - @tanstack/virtual-core@3.16.0 + ## 3.13.26 ### Patch Changes diff --git a/packages/lit-virtual/package.json b/packages/lit-virtual/package.json index 825e32902..9ef1d1171 100644 --- a/packages/lit-virtual/package.json +++ b/packages/lit-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/lit-virtual", - "version": "3.13.26", + "version": "3.13.27", "description": "Headless UI for virtualizing scrollable elements in Lit", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/react-virtual/CHANGELOG.md b/packages/react-virtual/CHANGELOG.md index 1fb8189c4..6371e7b65 100644 --- a/packages/react-virtual/CHANGELOG.md +++ b/packages/react-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/react-virtual +## 3.13.26 + +### Patch Changes + +- Updated dependencies [[`fc992ab`](https://github.com/TanStack/virtual/commit/fc992ab00a15166311b79bd7580736cf01e8cc1a)]: + - @tanstack/virtual-core@3.16.0 + ## 3.13.25 ### Patch Changes diff --git a/packages/react-virtual/package.json b/packages/react-virtual/package.json index 0e867e077..21411bbb5 100644 --- a/packages/react-virtual/package.json +++ b/packages/react-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/react-virtual", - "version": "3.13.25", + "version": "3.13.26", "description": "Headless UI for virtualizing scrollable elements in React", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/solid-virtual/CHANGELOG.md b/packages/solid-virtual/CHANGELOG.md index 866442c94..709e9deec 100644 --- a/packages/solid-virtual/CHANGELOG.md +++ b/packages/solid-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/solid-virtual +## 3.13.26 + +### Patch Changes + +- Updated dependencies [[`fc992ab`](https://github.com/TanStack/virtual/commit/fc992ab00a15166311b79bd7580736cf01e8cc1a)]: + - @tanstack/virtual-core@3.16.0 + ## 3.13.25 ### Patch Changes diff --git a/packages/solid-virtual/package.json b/packages/solid-virtual/package.json index 75b8351b1..14082986c 100644 --- a/packages/solid-virtual/package.json +++ b/packages/solid-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/solid-virtual", - "version": "3.13.25", + "version": "3.13.26", "description": "Headless UI for virtualizing scrollable elements in Solid", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/svelte-virtual/CHANGELOG.md b/packages/svelte-virtual/CHANGELOG.md index 7b05ae82c..f52bee0ce 100644 --- a/packages/svelte-virtual/CHANGELOG.md +++ b/packages/svelte-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/svelte-virtual +## 3.13.26 + +### Patch Changes + +- Updated dependencies [[`fc992ab`](https://github.com/TanStack/virtual/commit/fc992ab00a15166311b79bd7580736cf01e8cc1a)]: + - @tanstack/virtual-core@3.16.0 + ## 3.13.25 ### Patch Changes diff --git a/packages/svelte-virtual/package.json b/packages/svelte-virtual/package.json index 993bb06ea..3d74900aa 100644 --- a/packages/svelte-virtual/package.json +++ b/packages/svelte-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/svelte-virtual", - "version": "3.13.25", + "version": "3.13.26", "description": "Headless UI for virtualizing scrollable elements in Svelte", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/virtual-core/CHANGELOG.md b/packages/virtual-core/CHANGELOG.md index f812e332b..569a07333 100644 --- a/packages/virtual-core/CHANGELOG.md +++ b/packages/virtual-core/CHANGELOG.md @@ -1,5 +1,15 @@ # @tanstack/virtual-core +## 3.16.0 + +### Minor Changes + +- Add end-anchored virtualization support for chat, logs, and reverse feeds. ([#1173](https://github.com/TanStack/virtual/pull/1173)) + + 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. + ## 3.15.0 ### Minor Changes diff --git a/packages/virtual-core/package.json b/packages/virtual-core/package.json index 603a65c76..fc9f43425 100644 --- a/packages/virtual-core/package.json +++ b/packages/virtual-core/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/virtual-core", - "version": "3.15.0", + "version": "3.16.0", "description": "Headless UI for virtualizing scrollable elements in TS/JS + Frameworks", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/vue-virtual/CHANGELOG.md b/packages/vue-virtual/CHANGELOG.md index 5a0a8b227..452290766 100644 --- a/packages/vue-virtual/CHANGELOG.md +++ b/packages/vue-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/vue-virtual +## 3.13.26 + +### Patch Changes + +- Updated dependencies [[`fc992ab`](https://github.com/TanStack/virtual/commit/fc992ab00a15166311b79bd7580736cf01e8cc1a)]: + - @tanstack/virtual-core@3.16.0 + ## 3.13.25 ### Patch Changes diff --git a/packages/vue-virtual/package.json b/packages/vue-virtual/package.json index 060f17da9..d16e3d512 100644 --- a/packages/vue-virtual/package.json +++ b/packages/vue-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/vue-virtual", - "version": "3.13.25", + "version": "3.13.26", "description": "Headless UI for virtualizing scrollable elements in Vue", "author": "Tanner Linsley", "license": "MIT", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76e63323b..83a164d61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,7 +142,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/angular-virtual': - specifier: ^5.0.1 + specifier: ^5.0.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -194,7 +194,7 @@ importers: specifier: ^19.0.0 version: 19.2.20(@angular/common@19.2.20(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@19.2.20(@angular/animations@19.2.20(@angular/common@19.2.20(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@19.2.20(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^5.0.1 + specifier: ^5.0.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -249,7 +249,7 @@ importers: specifier: 5.80.7 version: 5.80.7(@angular/common@19.2.20(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1)) '@tanstack/angular-virtual': - specifier: ^5.0.1 + specifier: ^5.0.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -301,7 +301,7 @@ importers: specifier: ^19.0.0 version: 19.2.20(@angular/common@19.2.20(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@19.2.20(@angular/animations@19.2.20(@angular/common@19.2.20(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@19.2.20(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^5.0.1 + specifier: ^5.0.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -353,7 +353,7 @@ importers: specifier: ^19.0.0 version: 19.2.20(@angular/common@19.2.20(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@19.2.20(@angular/animations@19.2.20(@angular/common@19.2.20(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@19.2.20(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^5.0.1 + specifier: ^5.0.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -408,7 +408,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/angular-virtual': - specifier: ^5.0.1 + specifier: ^5.0.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -466,7 +466,7 @@ importers: specifier: 8.21.3 version: 8.21.3(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1)) '@tanstack/angular-virtual': - specifier: ^5.0.1 + specifier: ^5.0.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -518,7 +518,7 @@ importers: specifier: ^19.0.0 version: 19.2.20(@angular/common@19.2.20(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@19.2.20(@angular/animations@19.2.20(@angular/common@19.2.20(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@19.2.20(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^5.0.1 + specifier: ^5.0.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -570,7 +570,7 @@ importers: specifier: ^19.0.0 version: 19.2.20(@angular/common@19.2.20(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@19.2.20(@angular/animations@19.2.20(@angular/common@19.2.20(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@19.2.20(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.20(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^5.0.1 + specifier: ^5.0.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -601,10 +601,10 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/lit-virtual': - specifier: ^3.13.26 + specifier: ^3.13.27 version: link:../../../packages/lit-virtual '@tanstack/virtual-core': - specifier: ^3.15.0 + specifier: ^3.16.0 version: link:../../../packages/virtual-core lit: specifier: ^3.3.0 @@ -626,10 +626,10 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/lit-virtual': - specifier: ^3.13.26 + specifier: ^3.13.27 version: link:../../../packages/lit-virtual '@tanstack/virtual-core': - specifier: ^3.15.0 + specifier: ^3.16.0 version: link:../../../packages/virtual-core lit: specifier: ^3.3.0 @@ -648,7 +648,7 @@ importers: examples/react/chat: dependencies: '@tanstack/react-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/react-virtual react: specifier: ^18.3.1 @@ -679,7 +679,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/react-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/react-virtual react: specifier: ^18.3.1 @@ -710,7 +710,7 @@ importers: examples/react/fixed: dependencies: '@tanstack/react-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/react-virtual react: specifier: ^18.3.1 @@ -744,7 +744,7 @@ importers: specifier: ^5.80.7 version: 5.90.5(react@18.3.1) '@tanstack/react-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/react-virtual react: specifier: ^18.3.1 @@ -769,7 +769,7 @@ importers: examples/react/padding: dependencies: '@tanstack/react-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/react-virtual react: specifier: ^18.3.1 @@ -797,7 +797,7 @@ importers: specifier: ^25.1.1 version: 25.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/react-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/react-virtual react: specifier: ^18.3.1 @@ -822,7 +822,7 @@ importers: examples/react/smooth-scroll: dependencies: '@tanstack/react-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/react-virtual react: specifier: ^18.3.1 @@ -850,7 +850,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/react-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/react-virtual lodash: specifier: ^4.17.21 @@ -887,7 +887,7 @@ importers: specifier: ^8.21.3 version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/react-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/react-virtual react: specifier: ^18.3.1 @@ -912,7 +912,7 @@ importers: examples/react/variable: dependencies: '@tanstack/react-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/react-virtual react: specifier: ^18.3.1 @@ -937,7 +937,7 @@ importers: examples/react/window: dependencies: '@tanstack/react-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/react-virtual react: specifier: ^18.3.1 @@ -971,7 +971,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/svelte-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -999,7 +999,7 @@ importers: examples/svelte/fixed: dependencies: '@tanstack/svelte-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1030,7 +1030,7 @@ importers: specifier: ^5.80.7 version: 5.90.2(svelte@4.2.20) '@tanstack/svelte-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1061,7 +1061,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/svelte-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1092,7 +1092,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/svelte-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/svelte-virtual lodash: specifier: ^4.17.21 @@ -1129,7 +1129,7 @@ importers: specifier: ^8.21.3 version: 8.21.3(svelte@4.2.20) '@tanstack/svelte-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1160,7 +1160,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/vue-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1185,7 +1185,7 @@ importers: examples/vue/fixed: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1213,7 +1213,7 @@ importers: specifier: ^5.80.7 version: 5.90.5(vue@3.5.22(typescript@5.6.3)) '@tanstack/vue-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1238,7 +1238,7 @@ importers: examples/vue/padding: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1263,7 +1263,7 @@ importers: examples/vue/scroll-padding: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/vue-virtual '@vueuse/core': specifier: ^12.8.2 @@ -1291,7 +1291,7 @@ importers: examples/vue/smooth-scroll: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1319,7 +1319,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/vue-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/vue-virtual lodash: specifier: ^4.17.21 @@ -1356,7 +1356,7 @@ importers: specifier: ^8.21.3 version: 8.21.3(vue@3.5.22(typescript@5.6.3)) '@tanstack/vue-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1381,7 +1381,7 @@ importers: examples/vue/variable: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.25 + specifier: ^3.13.26 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16