From 5a482536b3fff695bdbd8235f62589797aa18014 Mon Sep 17 00:00:00 2001 From: tigerBeA Date: Tue, 8 Sep 2026 17:48:15 +0800 Subject: [PATCH 1/2] fix(virtual-core): preserve keys in lazy measurement builds --- packages/virtual-core/src/index.ts | 38 ++++++----- .../virtual-core/src/lazy-measurements.ts | 19 +++--- packages/virtual-core/tests/index.test.ts | 66 +++++++++++++++++++ 3 files changed, 97 insertions(+), 26 deletions(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 55c276edf..9cf14135b 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -409,9 +409,12 @@ export class Virtualizer< isScrolling = false private scrollState: ScrollState | null = null measurementsCache: Array = [] - // Flat backing store for the lanes===1 fast path: [start_0, size_0, start_1, size_1, ...]. - // null until the first single-lane build; reused (and grown) across rebuilds. - private _flatMeasurements: Float64Array | null = null + // Keys belong to the layout build, even when VirtualItems are read later. + // The flat [start, size, ...] buffer is reused across builds. + private _singleLaneMeasurements: { + flat: Float64Array + items: Array + } | null = null itemSizeCache = new Map() private itemSizeCacheVersion = 0 private laneAssignments = new Map() // index → lane cache @@ -1272,6 +1275,7 @@ export class Virtualizer< const itemSizeCache = this.itemSizeCache if (!enabled) { this.measurementsCache = [] + this._singleLaneMeasurements = null this.itemSizeCache.clear() this.laneAssignments.clear() return [] @@ -1291,6 +1295,7 @@ export class Virtualizer< this.lanesChangedFlag = false // Reset immediately this.lanesSettling = true // Start settling period this.measurementsCache = [] + this._singleLaneMeasurements = null this.itemSizeCache.clear() this.laneAssignments.clear() // Clear lane cache for new lane count // Force min = 0 on the rebuild @@ -1320,21 +1325,22 @@ export class Virtualizer< // per-item VirtualItem object allocation. We write start/size pairs // into a Float64Array and return a Proxy that builds VirtualItem // objects on demand (only the indices a consumer actually reads). - // - // At n=100k this drops cold-mount cost from ~2.5ms (eager object - // allocation) to roughly the cost of a single typed-array fill. if (lanes === 1) { // Reuse flat backing if large enough; else grow (preserving data // before `min` to mirror the slice-and-rebuild contract). const need = count * 2 - let flat = this._flatMeasurements + let flat = this._singleLaneMeasurements?.flat if (!flat || flat.length < need) { const next = new Float64Array(need) if (flat && min > 0) next.set(flat.subarray(0, min * 2)) flat = next - this._flatMeasurements = flat } + const items: Array = + min === 0 + ? new Array(count) + : this._singleLaneMeasurements!.items.slice() + let runningStart: number if (min === 0) { runningStart = paddingStart + scrollMargin @@ -1346,6 +1352,7 @@ export class Virtualizer< for (let i = min; i < count; i++) { const key = getItemKey(i) + items[i] = key const measuredSize = itemSizeCache.get(key) const size = typeof measuredSize === 'number' @@ -1356,7 +1363,8 @@ export class Virtualizer< runningStart += size + gap } - const view = createLazyMeasurementsView(count, flat, getItemKey) + this._singleLaneMeasurements = { flat, items } + const view = createLazyMeasurementsView(items, flat) this.measurementsCache = view return view } @@ -1490,8 +1498,8 @@ export class Virtualizer< lanes, // Pass the typed array so binary search + forward-walk can read // start/end directly from Float64Array, skipping the Proxy traps. - lanes === 1 && this._flatMeasurements != null - ? this._flatMeasurements + lanes === 1 && this._singleLaneMeasurements !== null + ? this._singleLaneMeasurements.flat : null, ) return this.range @@ -1627,8 +1635,8 @@ export class Virtualizer< let cachedSize: number let itemStart: number let key: Key - const flat = this._flatMeasurements - if (this.options.lanes === 1 && flat !== null) { + const flat = this._singleLaneMeasurements?.flat + if (this.options.lanes === 1 && flat != null) { key = this.options.getItemKey(index) itemStart = flat[index * 2]! cachedSize = flat[index * 2 + 1]! @@ -1749,7 +1757,7 @@ export class Virtualizer< // Same fast-path as calculateRange: read start values directly from the // typed array during binary search to skip the Proxy.get materialization // per probe. - const flat = this._flatMeasurements + const flat = this._singleLaneMeasurements?.flat const useFlat = this.options.lanes === 1 && flat != null const idx = findNearestBinarySearch( 0, @@ -1969,7 +1977,7 @@ export class Virtualizer< // when available; avoids a Proxy.get + VirtualItem materialization // just to call getTotalSize (which React renders trigger every commit). const lastIdx = measurements.length - 1 - const flat = this._flatMeasurements + const flat = this._singleLaneMeasurements?.flat if (flat != null) { end = flat[lastIdx * 2]! + flat[lastIdx * 2 + 1]! } else { diff --git a/packages/virtual-core/src/lazy-measurements.ts b/packages/virtual-core/src/lazy-measurements.ts index 4b8cd425d..e200b321d 100644 --- a/packages/virtual-core/src/lazy-measurements.ts +++ b/packages/virtual-core/src/lazy-measurements.ts @@ -1,19 +1,16 @@ // Lazy materialization for the lanes===1 fast path. Backed by a // Float64Array (stride 2: start, size, …); VirtualItems are constructed on -// first indexed read and cached. Saves the per-item object allocation at -// large list counts where most items are never visible. +// first indexed read, replacing the stored key. Saves the per-item object +// allocation at large list counts where most items are never visible. import type { VirtualItem } from './index' -type Key = number | string | bigint - export function createLazyMeasurementsView( - count: number, + cache: Array, flat: Float64Array, - getItemKey: (i: number) => Key, ): Array { - const cache: Array = new Array(count) - return new Proxy(cache as any, { + const count = cache.length + return new Proxy(cache, { get(target, prop, receiver) { if (typeof prop === 'string') { // Cheap digit-prefix sniff before number coerce. @@ -21,12 +18,12 @@ export function createLazyMeasurementsView( if (c >= 48 && c <= 57) { const i = +prop if (Number.isInteger(i) && i >= 0 && i < count) { - let v = target[i] - if (!v) { + let v = target[i]! + if (typeof v !== 'object') { const s = flat[i * 2]! v = target[i] = { index: i, - key: getItemKey(i), + key: v, start: s, size: flat[i * 2 + 1]!, end: s + flat[i * 2 + 1]!, diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index b9fc6996a..3e0950f5e 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -1436,6 +1436,44 @@ test('lazy fast path: same item read twice returns identical reference (cache wo expect(a).toBe(b) }) +test.each([ + { name: 'initial build', resizeIndex: null }, + { name: 'partial rebuild', resizeIndex: 2 }, +])( + 'lazy fast path: preserves unread item keys after $name', + ({ resizeIndex }) => { + const messages = ['a', 'b', 'c', 'd'] + const v = new Virtualizer({ + count: messages.length, + estimateSize: () => 50, + getItemKey: (index) => messages[index]!, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + expect(v.getTotalSize()).toBe(200) + + if (resizeIndex !== null) { + v.resizeItem(resizeIndex, 80) + expect(v.getTotalSize()).toBe(230) + } + + // The old layout is still needed while setOptions compares the two lists. + messages.splice(0, 1) + messages.push('e') + + expect(v.getVirtualItemForOffset(50)).toEqual({ + index: 1, + key: 'b', + start: 50, + size: 50, + end: 100, + lane: 0, + }) + }, +) + test('lazy fast path: out-of-range access returns undefined', () => { const v = new Virtualizer({ count: 5, @@ -3002,6 +3040,34 @@ test('anchorTo:end does not yank a scrolled-up user when items append', () => { expect(scrollToFn).not.toHaveBeenCalled() }) +test('anchorTo:end preserves a reading anchor with a stable key callback', () => { + const messages = Array.from({ length: 20 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer, scrollToFn, emitScroll } = createChatVirtualizer({ + messages, + offset: 400, + followOnAppend: false, + }) + const readingKey = virtualizer.getVirtualItemForOffset(400)!.key + virtualizer.getVirtualItems() + + for (let step = 1; step <= 2; step++) { + scrollToFn.mockClear() + messages.splice(0, 1) + messages.push({ id: `m-${19 + step}` }) + // Keep the same callback; the old edge keys have not been read yet. + virtualizer.setOptions(virtualizer.options) + virtualizer._willUpdate() + + const target = 400 - step * 50 + expect(scrollToFn.mock.calls.at(-1)?.[0]).toBe(target) + emitScroll(target) + expect(virtualizer.getVirtualItemForOffset(target)?.key).toBe(readingKey) + for (const item of virtualizer.getVirtualItems()) { + expect(item.key).toBe(messages[item.index]!.id) + } + } +}) + 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({ From e89ef7d9faa280a5931734882547d6c83605aa33 Mon Sep 17 00:00:00 2001 From: tigerBeA Date: Tue, 8 Sep 2026 17:50:25 +0800 Subject: [PATCH 2/2] fix(virtual-core): follow appends when older items are trimmed --- .changeset/follow-sliding-window.md | 7 + docs/api/virtualizer.md | 2 + packages/virtual-core/src/index.ts | 66 ++++++++-- .../virtual-core/src/lazy-measurements.ts | 6 + packages/virtual-core/tests/index.test.ts | 124 ++++++++++++++++++ 5 files changed, 192 insertions(+), 13 deletions(-) create mode 100644 .changeset/follow-sliding-window.md diff --git a/.changeset/follow-sliding-window.md b/.changeset/follow-sliding-window.md new file mode 100644 index 000000000..185f5db83 --- /dev/null +++ b/.changeset/follow-sliding-window.md @@ -0,0 +1,7 @@ +--- +'@tanstack/virtual-core': patch +--- + +Keep an end-pinned virtualizer following appended items when older items are trimmed in the same update and the item count does not increase. Recognize ordered, overlapping windows while preserving reading anchors for users who have scrolled away from the end. + +Preserve item keys in the lazy measurement cache so a stable `getItemKey` callback reading mutable data cannot change the identity of previously measured rows. diff --git a/docs/api/virtualizer.md b/docs/api/virtualizer.md index 049d93fe3..fcbc4cf5a 100644 --- a/docs/api/virtualizer.md +++ b/docs/api/virtualizer.md @@ -271,6 +271,8 @@ When used with `anchorTo: 'end'`, controls whether the virtualizer scrolls to th Passing `true` is equivalent to `'auto'`. Passing a scroll behavior uses that behavior for the follow. +Following also works when older items are trimmed from the start in the same update without increasing the count. This requires persistent keys, a non-empty suffix of the old list retained in order, and appended items with new keys. Non-growing updates with no retained items are not automatically followed. + 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` diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 9cf14135b..f273588b6 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1,4 +1,7 @@ -import { createLazyMeasurementsView } from './lazy-measurements' +import { + createLazyMeasurementsView, + getMeasurementKey, +} from './lazy-measurements' import { approxEqual, debounce, memo, notUndefined } from './utils' // Browser-aware iOS detection. Programmatic `scrollTo`/`scrollTop` writes @@ -398,6 +401,36 @@ type PendingScrollAnchor = [ anchorDelta: number, ] +function isAppendWithTrim( + prevCount: number, + nextCount: number, + getPreviousKey: (index: number) => Key, + getNextKey: (index: number) => Key, +): boolean { + if (nextCount === 0) return false + + const firstKey = getNextKey(0) + const removedKeys = new Set() + let removedCount = 0 + while (removedCount < prevCount) { + const key = getPreviousKey(removedCount) + if (key === firstKey) break + removedKeys.add(key) + removedCount++ + } + + const retainedCount = prevCount - removedCount + if (retainedCount === 0 || retainedCount >= nextCount) return false + + for (let i = 0; i < retainedCount; i++) { + if (getNextKey(i) !== getPreviousKey(removedCount + i)) return false + } + for (let i = retainedCount; i < nextCount; i++) { + if (removedKeys.has(getNextKey(i))) return false + } + return true +} + export class Virtualizer< TScrollElement extends Element | Window, TItemElement extends Element, @@ -594,15 +627,11 @@ export class Virtualizer< 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 previousItems = this._singleLaneMeasurements?.items ?? measurements + const getPreviousKey = (index: number) => + getMeasurementKey(previousItems[index]!) + const prevFirstKey = prevCount > 0 ? getPreviousKey(0) : null + const prevLastKey = prevCount > 0 ? getPreviousKey(prevCount - 1) : null const didCountChange = nextCount !== prevCount const didEdgeKeysChange = didCountChange || @@ -630,11 +659,21 @@ export class Virtualizer< if ( behavior && - nextCount > prevCount && + nextCount > 0 && this.isAtEnd(prevOptions.scrollEndThreshold) && (prevCount === 0 || merged.getItemKey(nextCount - 1) !== prevLastKey) ) { - followOnAppend = behavior + if ( + nextCount > prevCount || + isAppendWithTrim( + prevCount, + nextCount, + getPreviousKey, + merged.getItemKey, + ) + ) { + followOnAppend = behavior + } } } } @@ -675,7 +714,8 @@ export class Virtualizer< // (rubber-band), and a negative tracked offset never self-heals // when the element cannot scroll (#1229). const newOffset = Math.max(0, anchorItem.start + anchorOffset) - if (newOffset !== this.scrollOffset) { + // A no-op end scroll emits no event to correct a reading-anchor offset. + if (!followOnAppend && newOffset !== this.scrollOffset) { anchorDelta = newOffset - this.scrollOffset this.scrollOffset = newOffset anchorResolved = true diff --git a/packages/virtual-core/src/lazy-measurements.ts b/packages/virtual-core/src/lazy-measurements.ts index e200b321d..7060262f3 100644 --- a/packages/virtual-core/src/lazy-measurements.ts +++ b/packages/virtual-core/src/lazy-measurements.ts @@ -5,6 +5,12 @@ import type { VirtualItem } from './index' +export function getMeasurementKey( + item: VirtualItem | VirtualItem['key'], +): VirtualItem['key'] { + return typeof item === 'object' ? item.key : item +} + export function createLazyMeasurementsView( cache: Array, flat: Float64Array, diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 3e0950f5e..93d1bf5c6 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -3098,6 +3098,130 @@ test('followOnAppend accepts smooth behavior', () => { expect(scrollToFn.mock.calls[0]![1].behavior).toBe('smooth') }) +test.each([ + { removed: 1, appended: 1, behavior: true as const }, + { removed: 1, appended: 2, behavior: true as const }, + { removed: 3, appended: 3, behavior: true as const }, + { removed: 3, appended: 1, behavior: true as const }, + { removed: 1, appended: 1, behavior: 'smooth' as const }, +])( + 'followOnAppend follows a sliding window removing $removed and appending $appended with $behavior', + ({ removed, appended, behavior }) => { + const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) + const { setMessages, scrollToFn } = createChatVirtualizer({ + messages, + offset: 200, + followOnAppend: behavior, + }) + const nextMessages = [ + ...messages.slice(removed), + ...Array.from({ length: appended }, (_, i) => ({ id: `m-${8 + i}` })), + ] + + setMessages(nextMessages) + + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(nextMessages.length * 50 - 200) + expect(scrollToFn.mock.calls[0]![1].behavior).toBe( + behavior === true ? 'auto' : behavior, + ) + }, +) + +test.each([ + { offset: 100, followOnAppend: true, threshold: 1, target: 50 }, + { offset: 200, followOnAppend: false, threshold: 1, target: 150 }, + { offset: 195, followOnAppend: true, threshold: 4, target: 145 }, + { offset: 196, followOnAppend: true, threshold: 4, target: 200 }, +])( + 'followOnAppend respects offset $offset, enabled $followOnAppend and threshold $threshold for a sliding window', + ({ offset, followOnAppend, threshold, target }) => { + const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) + const { setMessages, scrollToFn } = createChatVirtualizer({ + messages, + offset, + followOnAppend, + threshold, + }) + + setMessages([...messages.slice(1), { id: 'm-8' }]) + + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(target) + }, +) + +test('followOnAppend stays pinned across sliding updates without scroll events', () => { + const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer, setMessages, scrollToFn } = createChatVirtualizer({ + messages, + offset: 200, + followOnAppend: true, + }) + + // Equal-size append + trim leaves the DOM offset unchanged, so no scroll event fires. + setMessages([...messages.slice(1), { id: 'm-8' }]) + expect(virtualizer.isAtEnd()).toBe(true) + + setMessages([...messages.slice(2), { id: 'm-8' }, { id: 'm-9' }]) + expect(virtualizer.isAtEnd()).toBe(true) + expect(scrollToFn.mock.calls.at(-1)![0]).toBe(200) +}) + +test('followOnAppend stays pinned with a stable key callback', () => { + const messages = Array.from({ length: 20 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer, scrollToFn } = createChatVirtualizer({ + messages, + offset: 800, + followOnAppend: true, + }) + virtualizer.getVirtualItems() + + for (let step = 1; step <= 2; step++) { + scrollToFn.mockClear() + messages.splice(0, 1) + messages.push({ id: `m-${19 + step}` }) + virtualizer.setOptions(virtualizer.options) + virtualizer._willUpdate() + + expect(scrollToFn.mock.calls.at(-1)?.[0]).toBe(800) + // A no-op end scroll produces no browser scroll event. + expect(virtualizer.isAtEnd()).toBe(true) + virtualizer.getVirtualItems() + } +}) + +test.each([ + { name: 'replacement', ids: [8, 9, 10, 11, 12, 13, 14, 15], target: null }, + { name: 'reorder', ids: [1, 0, 2, 3, 4, 5, 7, 6], target: null }, + { name: 'rotation', ids: [1, 2, 3, 4, 5, 6, 7, 0], target: 150 }, + { name: 'rotation with append', ids: [2, 3, 4, 5, 6, 7, 0, 8], target: 100 }, + { name: 'reordered overlap', ids: [1, 3, 2, 4, 5, 6, 7, 8], target: 150 }, + { name: 'trim only', ids: [1, 2, 3, 4, 5, 6, 7], target: 150 }, + { + name: 'prepend and trim tail', + ids: [-1, 0, 1, 2, 3, 4, 5, 6], + target: 250, + }, +])('followOnAppend does not follow $name', ({ ids, target }) => { + const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) + const { setMessages, scrollToFn } = createChatVirtualizer({ + messages, + offset: 200, + followOnAppend: true, + }) + + setMessages(ids.map((id) => ({ id: `m-${id}` }))) + + if (target === null) { + expect(scrollToFn).not.toHaveBeenCalled() + } else { + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(target) + expect(scrollToFn.mock.calls[0]![1].behavior).toBeUndefined() + } +}) + 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({