diff --git a/.changeset/virtualizer-chat-options.md b/.changeset/virtualizer-chat-options.md new file mode 100644 index 0000000000..0dc97bb7c3 --- /dev/null +++ b/.changeset/virtualizer-chat-options.md @@ -0,0 +1,6 @@ +--- +"@zag-js/virtualizer": minor +--- + +Add chat-oriented list virtualizer options and helpers: `anchorTo`, `followOnAppend`, `scrollEndThreshold`, +`scrollToEnd()`, `getDistanceFromEnd()`, and `isAtEnd()`. diff --git a/e2e/virtualizer.e2e.ts b/e2e/virtualizer.e2e.ts index 1ab56fd252..0ee004639d 100644 --- a/e2e/virtualizer.e2e.ts +++ b/e2e/virtualizer.e2e.ts @@ -9,6 +9,7 @@ const virtualizerRoutes = [ "/virtualizer/scroll-padding", "/virtualizer/sticky", "/virtualizer/infinite-scroll", + "/virtualizer/chat", "/virtualizer/window", ] as const @@ -82,4 +83,117 @@ test.describe("virtualizer examples", () => { await I.checkAccessibility("main") }) } + + test("chat example follows appended messages only when pinned", async ({ page }) => { + await page.goto("/virtualizer/chat") + await page.waitForSelector("main", { state: "visible" }) + + const transcript = page.getByLabel("Chat transcript") + const distanceFromEnd = () => + transcript.evaluate((el) => { + const element = el as HTMLElement + return element.scrollHeight - element.clientHeight - element.scrollTop + }) + + await expect.poll(distanceFromEnd, { timeout: 5000 }).toBeLessThan(2) + + await page.getByRole("button", { name: "Append message" }).click() + await expect.poll(distanceFromEnd, { timeout: 5000 }).toBeLessThan(2) + + await transcript.evaluate((el) => { + const element = el as HTMLElement + element.scrollTop = 240 + element.dispatchEvent(new Event("scroll", { bubbles: true })) + }) + const readingHistoryOffset = await transcript.evaluate((el) => (el as HTMLElement).scrollTop) + expect(readingHistoryOffset).toBeGreaterThan(100) + + await page.getByRole("button", { name: "Append message" }).click() + await page.waitForTimeout(100) + await expect + .poll(() => transcript.evaluate((el) => (el as HTMLElement).scrollTop)) + .toBeLessThanOrEqual(readingHistoryOffset + 2) + + await page.getByRole("button", { name: "Jump to latest" }).click() + await expect.poll(distanceFromEnd, { timeout: 5000 }).toBeLessThan(2) + }) + + test("chat example auto-loads older messages without losing the visible item", async ({ page }) => { + await page.goto("/virtualizer/chat") + await page.waitForSelector("main", { state: "visible" }) + + const transcript = page.getByLabel("Chat transcript") + + await transcript.evaluate((el) => { + const element = el as HTMLElement + element.scrollTop = 0 + element.dispatchEvent(new Event("scroll", { bubbles: true })) + }) + + await expect(page.getByText("Loading...")).toBeVisible() + await expect(page.getByText("Messages: 48")).toBeVisible({ timeout: 5000 }) + await expect(transcript.getByText(/message 0\./)).toBeVisible() + await expect.poll(() => transcript.evaluate((el) => (el as HTMLElement).scrollTop)).toBeGreaterThan(100) + }) + + test("chat example load older button preserves a valid virtual window", async ({ page }) => { + await page.goto("/virtualizer/chat") + await page.waitForSelector("main", { state: "visible" }) + + const transcript = page.getByLabel("Chat transcript") + const distanceFromEnd = () => + transcript.evaluate((el) => { + const element = el as HTMLElement + return element.scrollHeight - element.clientHeight - element.scrollTop + }) + const visibleMessages = () => + transcript.evaluate((el) => { + const container = el as HTMLElement + const containerRect = container.getBoundingClientRect() + return Array.from(container.querySelectorAll("[data-index]")) + .filter((item) => { + const rect = item.getBoundingClientRect() + return rect.bottom > containerRect.top && rect.top < containerRect.bottom + }) + .map((item) => item.textContent?.trim() ?? "") + }) + + await expect + .poll(() => transcript.evaluate((el) => (el as HTMLElement).scrollTop), { timeout: 5000 }) + .toBeGreaterThan(0) + await expect.poll(distanceFromEnd, { timeout: 5000 }).toBeLessThan(2) + + await page.getByRole("button", { name: "Load older messages" }).click() + + await expect(page.getByText("Messages: 48")).toBeVisible({ timeout: 5000 }) + await expect.poll(distanceFromEnd, { timeout: 5000 }).toBeLessThan(2) + await expect + .poll(visibleMessages) + .toContainEqual( + "AssistantAssistant message 35. This message is intentionally longer so the example exercises dynamic row measurement and anchor correction.", + ) + }) + + test("chat example keeps streamed output pinned when at latest", async ({ page }) => { + await page.goto("/virtualizer/chat") + await page.waitForSelector("main", { state: "visible" }) + + const transcript = page.getByLabel("Chat transcript") + const distanceFromEnd = () => + transcript.evaluate((el) => { + const element = el as HTMLElement + return element.scrollHeight - element.clientHeight - element.scrollTop + }) + + await expect.poll(distanceFromEnd, { timeout: 5000 }).toBeLessThan(2) + + await page.getByRole("button", { name: "Stream reply" }).click() + await expect(page.getByRole("button", { name: "Streaming..." })).toBeDisabled() + await expect(page.getByText(/viewport remains pinned only when you are already at the latest message/)).toBeVisible( + { + timeout: 5000, + }, + ) + await expect.poll(distanceFromEnd, { timeout: 5000 }).toBeLessThan(2) + }) }) diff --git a/examples/next-ts/app/virtualizer/chat/page.tsx b/examples/next-ts/app/virtualizer/chat/page.tsx new file mode 100644 index 0000000000..9565b1e61e --- /dev/null +++ b/examples/next-ts/app/virtualizer/chat/page.tsx @@ -0,0 +1,345 @@ +"use client" + +import { type UIEvent, useCallback, useEffect, useRef, useState } from "react" +import { useListVirtualizer } from "@/hooks/use-virtualizer" + +type ChatMessage = { + id: string + author: "You" | "Assistant" + text: string +} + +const HISTORY_PAGE_SIZE = 12 +const SCROLL_END_THRESHOLD = 120 +const HISTORY_LOAD_THRESHOLD = 120 + +function makeMessage(index: number): ChatMessage { + const author = index % 4 === 0 ? "You" : "Assistant" + const detail = + index % 5 === 0 + ? "This message is intentionally longer so the example exercises dynamic row measurement and anchor correction." + : "Short update." + + return { + id: `message-${index}`, + author, + text: `${author} message ${index}. ${detail}`, + } +} + +const initialMessages = Array.from({ length: 36 }, (_, index) => makeMessage(index)) + +function getMessageIndexById(messages: ChatMessage[]) { + return new Map(messages.map((message, index) => [message.id, index])) +} + +export default function Page() { + const scrollRef = useRef(null) + const didInitialScrollRef = useRef(false) + const autoHistoryEnabledRef = useRef(false) + const shouldFollowStreamRef = useRef(false) + const loadingHistoryRef = useRef(false) + const streamTimerRef = useRef(null) + const firstMessageIndexRef = useRef(0) + const nextMessageIndexRef = useRef(initialMessages.length) + const messagesRef = useRef(initialMessages) + const messageIndexByIdRef = useRef(getMessageIndexById(initialMessages)) + + const [, setMessageSnapshot] = useState(initialMessages) + const [isLoadingHistory, setIsLoadingHistory] = useState(false) + const [isStreaming, setIsStreaming] = useState(false) + + const indexToKey = useCallback((index: number) => messagesRef.current[index]!.id, []) + const keyToIndex = useCallback((key: string | number) => messageIndexByIdRef.current.get(String(key)) ?? -1, []) + const estimatedSize = useCallback( + (index: number) => ((messagesRef.current[index]?.text.length ?? 0) > 110 ? 112 : 78), + [], + ) + + const { virtualizer, ref } = useListVirtualizer({ + count: messagesRef.current.length, + estimatedSize, + indexToKey, + keyToIndex, + anchorTo: "end", + followOnAppend: true, + scrollEndThreshold: SCROLL_END_THRESHOLD, + overscan: 6, + }) + + const setScrollElementRef = useCallback( + (element: HTMLDivElement | null) => { + scrollRef.current = element + ref(element) + }, + [ref], + ) + + const scrollToLatest = useCallback( + (options: { smooth?: boolean } = {}) => { + if (options.smooth) { + virtualizer.scrollToEnd({ smooth: true }) + return + } + + virtualizer.scrollToEnd() + + const element = scrollRef.current + if (!element) return + + element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight) + virtualizer.handleScroll({ + currentTarget: { + scrollTop: element.scrollTop, + scrollLeft: element.scrollLeft, + }, + }) + }, + [virtualizer], + ) + + const scrollToLatestSettled = useCallback(() => { + let attempts = 0 + + const scroll = () => { + scrollToLatest() + attempts += 1 + + if (attempts < 4) { + requestAnimationFrame(scroll) + } + } + + scroll() + }, [scrollToLatest]) + + const syncMessages = useCallback((nextMessages: ChatMessage[]) => { + messagesRef.current = nextMessages + messageIndexByIdRef.current = getMessageIndexById(nextMessages) + setMessageSnapshot(nextMessages) + }, []) + + const prependHistory = useCallback(() => { + if (loadingHistoryRef.current) return + + const shouldFollow = virtualizer.isAtEnd() + loadingHistoryRef.current = true + setIsLoadingHistory(true) + + window.setTimeout(() => { + const nextFirstIndex = firstMessageIndexRef.current - HISTORY_PAGE_SIZE + firstMessageIndexRef.current = nextFirstIndex + const olderMessages = Array.from({ length: HISTORY_PAGE_SIZE }, (_, offset) => + makeMessage(nextFirstIndex + offset), + ) + const nextMessages = [...olderMessages, ...messagesRef.current] + + messagesRef.current = nextMessages + messageIndexByIdRef.current = getMessageIndexById(nextMessages) + virtualizer.prependItems(HISTORY_PAGE_SIZE) + setMessageSnapshot(nextMessages) + if (shouldFollow) { + requestAnimationFrame(scrollToLatestSettled) + } + + loadingHistoryRef.current = false + setIsLoadingHistory(false) + }, 200) + }, [scrollToLatestSettled, virtualizer]) + + const handleTranscriptScroll = useCallback( + (event: UIEvent) => { + virtualizer.handleScroll(event) + + if (!autoHistoryEnabledRef.current) return + if (loadingHistoryRef.current) return + if (virtualizer.isAtEnd()) return + if (event.currentTarget.scrollTop > HISTORY_LOAD_THRESHOLD) return + + prependHistory() + }, + [prependHistory, virtualizer], + ) + + useEffect(() => { + if (didInitialScrollRef.current) return + + let frame = 0 + let attempts = 0 + let cancelled = false + + const scroll = () => { + if (cancelled || didInitialScrollRef.current) return + scrollToLatest() + attempts += 1 + + if (attempts < 4) { + frame = requestAnimationFrame(scroll) + return + } + + if (didInitialScrollRef.current) return + didInitialScrollRef.current = true + autoHistoryEnabledRef.current = true + } + + frame = requestAnimationFrame(scroll) + + return () => { + cancelled = true + cancelAnimationFrame(frame) + } + }, [scrollToLatest]) + + useEffect(() => { + return () => { + if (streamTimerRef.current != null) { + window.clearInterval(streamTimerRef.current) + } + } + }, []) + + const appendMessage = useCallback(() => { + const shouldFollow = virtualizer.isAtEnd() + const nextIndex = nextMessageIndexRef.current + nextMessageIndexRef.current += 1 + const nextMessages = [...messagesRef.current, makeMessage(nextIndex)] + syncMessages(nextMessages) + virtualizer.updateOptions({ count: nextMessages.length }) + if (shouldFollow) { + requestAnimationFrame(scrollToLatestSettled) + } + }, [scrollToLatestSettled, syncMessages, virtualizer]) + + const streamReply = useCallback(() => { + if (streamTimerRef.current != null) return + + shouldFollowStreamRef.current = virtualizer.isAtEnd() + const id = `stream-${Date.now()}` + const chunks = [ + "Assistant is composing a streamed response.", + "Assistant is composing a streamed response. New tokens keep extending the latest row.", + "Assistant is composing a streamed response. New tokens keep extending the latest row while the viewport remains pinned only when you are already at the latest message.", + ] + let chunkIndex = 0 + + setIsStreaming(true) + const nextMessages = [...messagesRef.current, { id, author: "Assistant" as const, text: chunks[0]! }] + syncMessages(nextMessages) + virtualizer.updateOptions({ count: nextMessages.length }) + if (shouldFollowStreamRef.current) { + requestAnimationFrame(scrollToLatestSettled) + } + + streamTimerRef.current = window.setInterval(() => { + chunkIndex += 1 + + if (chunkIndex >= chunks.length) { + if (streamTimerRef.current != null) { + window.clearInterval(streamTimerRef.current) + streamTimerRef.current = null + } + setIsStreaming(false) + return + } + + syncMessages( + messagesRef.current.map((message) => (message.id === id ? { ...message, text: chunks[chunkIndex]! } : message)), + ) + if (shouldFollowStreamRef.current) { + requestAnimationFrame(scrollToLatestSettled) + } + }, 450) + }, [scrollToLatestSettled, syncMessages, virtualizer]) + + const virtualItems = virtualizer.getVirtualItems() + const totalSize = virtualizer.getTotalSize() + const isAtEnd = virtualizer.isAtEnd() + const currentMessages = messagesRef.current + + return ( +
+

Chat Virtualizer

+

+ End anchoring keeps prepended history stable, follows appended messages only when already pinned, and preserves + the latest message while streamed content grows. Scroll near the top to load older messages automatically. +

+ +
+ + + + +
+ +
+
+ {virtualItems.map((virtualItem) => { + const message = currentMessages[virtualItem.index] + if (!message) return null + + const isOwnMessage = message.author === "You" + + return ( +
+
+ {message.author} + {message.text} +
+
+ ) + })} +
+
+ +
+ Messages: {currentMessages.length} + Rendered: {virtualItems.length} + Distance from end: {Math.round(virtualizer.getDistanceFromEnd())}px +
+
+ ) +} diff --git a/packages/utilities/virtualizer/src/index.ts b/packages/utilities/virtualizer/src/index.ts index 0822dfdbb4..3781803d6a 100644 --- a/packages/utilities/virtualizer/src/index.ts +++ b/packages/utilities/virtualizer/src/index.ts @@ -3,6 +3,7 @@ export { ListVirtualizer } from "./list-virtualizer" export { WaterfallVirtualizer } from "./waterfall-virtualizer" export type { CSSProperties, + FollowOnAppend, GridVirtualizerOptions, GroupMeta, InitialMeasurements, @@ -18,11 +19,13 @@ export type { ScrollAnchor, ScrollByOptions, ScrollState, + ScrollToEndOptions, ScrollToIndexOptions, ScrollToIndexResult, ShouldAdjustScrollOnSizeChangeContext, TimerId, VirtualizerDir, + VirtualizerAnchor, VirtualItem, VirtualizerOrientation, VirtualRange, diff --git a/packages/utilities/virtualizer/src/types.ts b/packages/utilities/virtualizer/src/types.ts index f6cac5862e..cc09d781e7 100644 --- a/packages/utilities/virtualizer/src/types.ts +++ b/packages/utilities/virtualizer/src/types.ts @@ -47,6 +47,8 @@ export interface ScrollAnchor { } type ScrollAlignment = "start" | "center" | "end" | "auto" +export type VirtualizerAnchor = "start" | "end" +export type FollowOnAppend = boolean | "auto" | "smooth" | "instant" export type ScrollEasing = (t: number) => number export interface ScrollToIndexOptions { @@ -80,6 +82,8 @@ export interface ScrollByOptions { smooth?: ScrollToIndexOptions["smooth"] } +export interface ScrollToEndOptions extends ScrollByOptions {} + export interface VirtualRange { startIndex: number endIndex: number @@ -218,6 +222,26 @@ export interface VirtualizerOptions { /** Enable scroll anchor preservation during updates */ preserveScrollAnchor?: boolean + /** + * Controls which side of the scrollable content is treated as the stable anchor. + * + * Use "end" for chat, logs, and reverse feeds where the latest item appears at + * the end and the viewport should stay pinned while streaming output grows. + */ + anchorTo?: VirtualizerAnchor + + /** + * When used with `anchorTo: "end"`, follow appended items only if the viewport + * was already at the end before the append. `true` is equivalent to "auto". + */ + followOnAppend?: FollowOnAppend + + /** + * Distance in pixels from the end that still counts as being at the end. + * Used by `followOnAppend` and `isAtEnd()`. + */ + scrollEndThreshold?: number + /** * Control whether a measured size change should compensate the current scroll offset. * diff --git a/packages/utilities/virtualizer/src/virtualizer.ts b/packages/utilities/virtualizer/src/virtualizer.ts index 42930f1857..7fc16e5058 100644 --- a/packages/utilities/virtualizer/src/virtualizer.ts +++ b/packages/utilities/virtualizer/src/virtualizer.ts @@ -9,6 +9,7 @@ import type { ScrollByOptions, ScrollAnchor, ScrollState, + ScrollToEndOptions, ScrollToIndexOptions, ScrollToIndexResult, ShouldAdjustScrollOnSizeChangeContext, @@ -31,6 +32,13 @@ type RangeChangeReasonDetails = { reason: RangeChangeReason } type SmoothScrollOptions = Exclude, boolean> type SmoothScrollFunction = NonNullable type RtlScrollBehavior = "negative" | "positive-descending" | "positive-ascending" +type AnchorSnapshot = ScrollAnchor & { align: "start" | "end" } +type CountChangeSnapshot = { + previousCount: number + nextCount: number + previousLastKey: string | number | undefined + wasAtEnd: boolean +} function easeOutCubic(t: number): number { const shiftedT = t - 1 @@ -159,6 +167,9 @@ export abstract class Virtualizer= 0 && byUser < this.options.count ? byUser : undefined + } const cached = this.keyIndexCache.get(key) - if (cached !== undefined) return cached + if (cached !== undefined) { + return cached >= 0 && cached < this.options.count ? cached : undefined + } for (let i = 0; i < this.options.count; i++) { if (this.getItemKey(i) === key) return i @@ -854,6 +869,42 @@ export abstract class Virtualizer { changed = this.onItemMeasured(index, size) @@ -1079,7 +1131,12 @@ export abstract class Virtualizer { changed = this.onItemMeasured(index, size) @@ -1111,7 +1169,12 @@ export abstract class Virtualizer = [] + const updates: Array<{ index: number; size: number; shouldAdjust: boolean; shouldPreserveEnd: boolean }> = [] for (const [index, { size, element }] of this.pendingSizeUpdates) { if (element && this.elementsByIndex.get(index) !== element) continue const previousSize = this.getKnownItemSize(index) ?? this.getEstimatedSize(index) @@ -1174,6 +1237,7 @@ export abstract class Virtualizer update.shouldPreserveEnd) const adjustCandidate = updates.reduce((min, update) => { if (!update.shouldAdjust) return min return update.index < min ? update.index : min }, Infinity) - if (adjustCandidate !== Infinity) { + if (shouldPreserveEnd) { + applyMeasurements() + if (anySizeChanged) { + this.scrollToEnd() + } + } else if (adjustCandidate !== Infinity) { this.preserveScrollPosition(adjustCandidate, applyMeasurements) } else { applyMeasurements() @@ -1299,6 +1369,13 @@ export abstract class Virtualizer): void { if (this.isDestroyed) return const prev = { ...this.options } + const countChanged = nextOptions.count !== undefined && nextOptions.count !== this.options.count const preserveAnchor = nextOptions.preserveScrollAnchor ?? this.options.preserveScrollAnchor - const shouldRestoreAnchor = - preserveAnchor && nextOptions.count !== undefined && nextOptions.count !== this.options.count - const anchor = shouldRestoreAnchor ? this.getScrollAnchor() : null + const nextAnchorTo = nextOptions.anchorTo ?? this.options.anchorTo + const shouldRestoreAnchor = preserveAnchor && countChanged + const anchor = shouldRestoreAnchor ? this.getAnchorSnapshot(nextAnchorTo) : null + const countSnapshot = countChanged ? this.getCountChangeSnapshot(nextOptions.count!) : null Object.assign(this.options, nextOptions) if (nextOptions.dir !== undefined) { @@ -1621,11 +1718,46 @@ export abstract class Virtualizer 0 ? this.getItemKey(this.options.count - 1) : undefined, + wasAtEnd: this.getDistanceFromEnd() <= nextScrollEndThreshold, } } + private shouldFollowOnAppend(snapshot: CountChangeSnapshot): boolean { + if (this.options.anchorTo !== "end") return false + if (this.options.followOnAppend === false) return false + if (!snapshot.wasAtEnd) return false + return this.isAppendChange(snapshot) + } + + private isAppendChange(snapshot: CountChangeSnapshot): boolean { + if (snapshot.nextCount <= snapshot.previousCount) return false + if (snapshot.previousCount === 0) return true + if (snapshot.previousLastKey === undefined) return false + + return this.getIndexForKey(snapshot.previousLastKey) === snapshot.previousCount - 1 + } + + private followAppendedItems(): void { + const followOnAppend = this.options.followOnAppend + const smooth = followOnAppend === "smooth" && this.scrollElement ? true : undefined + this.scrollToEnd({ smooth }) + } + /** Regular method (not a class field) so subclasses can override with `super`. */ destroy(): void { this.isDestroyed = true diff --git a/packages/utilities/virtualizer/tests/list-virtualizer.test.ts b/packages/utilities/virtualizer/tests/list-virtualizer.test.ts index 6c65a79306..c9a91d9cf5 100644 --- a/packages/utilities/virtualizer/tests/list-virtualizer.test.ts +++ b/packages/utilities/virtualizer/tests/list-virtualizer.test.ts @@ -848,6 +848,217 @@ describe("ListVirtualizer", () => { expect(virtualizer.getScrollAnchor()).toEqual({ key: "a", offset: 5 }) }) + test("treats negative keyToIndex results as missing keys", () => { + const items = ["a", "b", "c"] + const virtualizer = new ListVirtualizer({ + count: items.length, + estimatedSize: () => 10, + overscan: 0, + initialRect: initialRect(20), + indexToKey: (index) => items[index]!, + keyToIndex: (key) => items.indexOf(key as string), + }) + + expect(virtualizer.restoreScrollAnchor({ key: "missing", offset: 0 })).toBeNull() + }) + + test("exposes end distance helpers and scrollToEnd", () => { + const virtualizer = new ListVirtualizer({ + count: 10, + estimatedSize: () => 10, + overscan: 0, + initialRect: initialRect(30), + initialOffset: 20, + scrollEndThreshold: 5, + }) + + expect(virtualizer.getDistanceFromEnd()).toBe(50) + expect(virtualizer.isAtEnd()).toBe(false) + expect(virtualizer.scrollToEnd()).toEqual({ scrollTop: 70, scrollLeft: 0 }) + expect(virtualizer.getDistanceFromEnd()).toBe(0) + expect(virtualizer.isAtEnd()).toBe(true) + }) + + test("uses the attached scroll element for distance from end", () => { + const { element } = createMockScrollContainer({ + viewport: { width: 100, height: 30 }, + scrollTop: 60, + }) + Object.assign(element, { + clientHeight: 30, + scrollHeight: 100, + }) + + const virtualizer = new ListVirtualizer({ + count: 10, + estimatedSize: () => 10, + overscan: 0, + initialRect: initialRect(30), + scrollEndThreshold: 10, + }) + + virtualizer.init(element) + + expect(virtualizer.getDistanceFromEnd()).toBe(10) + expect(virtualizer.isAtEnd()).toBe(true) + expect(virtualizer.scrollToEnd()).toEqual({ scrollTop: 70, scrollLeft: 0 }) + expect(element.scrollTop).toBe(70) + }) + + test("follows appended items when end anchored and already at the end", () => { + const initialItems = ["a", "b", "c", "d", "e"] + let items = initialItems + + const virtualizer = new ListVirtualizer({ + count: items.length, + estimatedSize: () => 10, + overscan: 0, + initialRect: initialRect(30), + anchorTo: "end", + followOnAppend: true, + indexToKey: (index) => items[index]!, + keyToIndex: (key) => items.indexOf(key as string), + }) + + virtualizer.scrollToEnd() + expect(virtualizer.getScrollState().offset.y).toBe(20) + + items = [...initialItems, "f"] + virtualizer.updateOptions({ + count: items.length, + indexToKey: (index) => items[index]!, + keyToIndex: (key) => items.indexOf(key as string), + }) + + expect(virtualizer.getScrollState().offset.y).toBe(30) + expect(virtualizer.isAtEnd()).toBe(true) + }) + + test("does not follow appended items when the user is reading history", () => { + const initialItems = ["a", "b", "c", "d", "e"] + let items = initialItems + + const virtualizer = new ListVirtualizer({ + count: items.length, + estimatedSize: () => 10, + overscan: 0, + initialRect: initialRect(30), + anchorTo: "end", + followOnAppend: true, + scrollEndThreshold: 5, + indexToKey: (index) => items[index]!, + keyToIndex: (key) => items.indexOf(key as string), + }) + + virtualizer.scrollToOffset(10) + expect(virtualizer.getDistanceFromEnd()).toBe(10) + + items = [...initialItems, "f"] + virtualizer.updateOptions({ + count: items.length, + indexToKey: (index) => items[index]!, + keyToIndex: (key) => items.indexOf(key as string), + }) + + expect(virtualizer.getScrollState().offset.y).toBe(10) + expect(virtualizer.getDistanceFromEnd()).toBe(20) + }) + + test("does not restore an end anchor for appends when the user is reading history", () => { + const initialItems = ["a", "b", "c", "d", "e"] + let items = initialItems + + const virtualizer = new ListVirtualizer({ + count: items.length, + estimatedSize: () => 100, + overscan: 0, + initialRect: initialRect(200), + anchorTo: "end", + followOnAppend: true, + indexToKey: (index) => items[index]!, + keyToIndex: (key) => items.indexOf(key as string), + }) + + for (let index = 0; index < items.length; index++) { + virtualizer.measureItem(index, 50) + } + virtualizer.scrollToOffset(0) + + items = [...initialItems, "f"] + virtualizer.updateOptions({ + count: items.length, + indexToKey: (index) => items[index]!, + keyToIndex: (key) => items.indexOf(key as string), + }) + + expect(virtualizer.getScrollState().offset.y).toBe(0) + }) + + test("uses scrollEndThreshold to decide whether appended items should follow", () => { + const initialItems = ["a", "b", "c", "d", "e"] + let items = initialItems + + const virtualizer = new ListVirtualizer({ + count: items.length, + estimatedSize: () => 10, + overscan: 0, + initialRect: initialRect(30), + anchorTo: "end", + followOnAppend: "instant", + scrollEndThreshold: 6, + indexToKey: (index) => items[index]!, + keyToIndex: (key) => items.indexOf(key as string), + }) + + virtualizer.scrollToOffset(15) + expect(virtualizer.getDistanceFromEnd()).toBe(5) + + items = [...initialItems, "f"] + virtualizer.updateOptions({ + count: items.length, + indexToKey: (index) => items[index]!, + keyToIndex: (key) => items.indexOf(key as string), + }) + + expect(virtualizer.getScrollState().offset.y).toBe(30) + }) + + test("keeps an end anchored viewport pinned when the last item grows", () => { + const virtualizer = new ListVirtualizer({ + count: 5, + estimatedSize: () => 20, + overscan: 0, + initialRect: initialRect(60), + anchorTo: "end", + }) + + virtualizer.scrollToEnd() + expect(virtualizer.getScrollState().offset.y).toBe(40) + + virtualizer.measureItem(4, 40) + + expect(virtualizer.getTotalSize()).toBe(120) + expect(virtualizer.getScrollState().offset.y).toBe(60) + expect(virtualizer.isAtEnd()).toBe(true) + }) + + test("does not pin last item growth when the viewport is away from the end", () => { + const virtualizer = new ListVirtualizer({ + count: 5, + estimatedSize: () => 20, + overscan: 0, + initialRect: initialRect(60), + anchorTo: "end", + }) + + virtualizer.scrollToOffset(20) + virtualizer.measureItem(4, 40) + + expect(virtualizer.getTotalSize()).toBe(120) + expect(virtualizer.getScrollState().offset.y).toBe(20) + expect(virtualizer.isAtEnd()).toBe(false) + }) + test("preserves groups initialized during construction", () => { const virtualizer = new ListVirtualizer({ count: 100, diff --git a/shared/src/routes.ts b/shared/src/routes.ts index 3e9818636c..5365f03fa1 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -640,6 +640,7 @@ export const componentRoutes: ComponentRoute[] = [ { slug: "scroll-padding", title: "Scroll Padding" }, { slug: "sticky", title: "Sticky Headers" }, { slug: "infinite-scroll", title: "Infinite Scroll" }, + { slug: "chat", title: "Chat" }, { slug: "window", title: "Window scroll" }, { slug: "perf", title: "Perf: Fixed Height" }, { slug: "perf-variable", title: "Perf: Variable (measureElement)" }, diff --git a/website/data/guides/virtualizer.mdx b/website/data/guides/virtualizer.mdx index daab31f0fc..b8a3957e85 100644 --- a/website/data/guides/virtualizer.mdx +++ b/website/data/guides/virtualizer.mdx @@ -68,6 +68,9 @@ const virtualizer = new ListVirtualizer({ dir: "ltr", indexToKey: (index) => messages[index].id, keyToIndex: (key) => messageIndexById.get(key) ?? -1, + anchorTo: "end", + followOnAppend: true, + scrollEndThreshold: 80, onRangeChange: ({ range, reason }) => { // reason: "scroll" | "resize" | "measurement" | "count" | "manual" updateVisibleWindow(range, reason) @@ -181,15 +184,29 @@ const virtualizer = new ListVirtualizer({ estimatedSize: () => 20, indexToKey: (index) => items[index].id, keyToIndex: (key) => keyToIndexMap.get(key) ?? -1, + anchorTo: "end", + followOnAppend: true, + scrollEndThreshold: 80, shouldAdjustScrollOnSizeChange: ({ key, delta, viewportStart }) => { return isPrependedKey(key) && delta !== 0 && viewportStart > 0 }, }) ``` +Chat-specific APIs: + +- `anchorTo: "end"` treats the end of the list as the stable edge. This is + useful for chat, logs, and reverse feeds. +- `followOnAppend` scrolls to the end after appended items only when the + viewport was already within `scrollEndThreshold` of the end. +- `scrollToEnd()`, `getDistanceFromEnd()`, and `isAtEnd()` power "Jump to + latest" controls with the same end-distance logic as `followOnAppend`. + Practical pattern: 1. Keep stable `indexToKey` / `keyToIndex`. 2. Prepend data and update `count`. 3. Re-measure prepended rows. -4. Use `shouldAdjustScrollOnSizeChange` to decide when compensation applies. +4. Use `anchorTo: "end"` and `followOnAppend` for latest-message behavior. +5. Use `shouldAdjustScrollOnSizeChange` for custom compensation rules when + prepended or streamed rows resize.