Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/follow-sliding-window.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/api/virtualizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
104 changes: 76 additions & 28 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<Key>()
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,
Expand All @@ -409,9 +442,12 @@ export class Virtualizer<
isScrolling = false
private scrollState: ScrollState | null = null
measurementsCache: Array<VirtualItem> = []
// 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<Key | VirtualItem>
} | null = null
itemSizeCache = new Map<Key, number>()
private itemSizeCacheVersion = 0
private laneAssignments = new Map<number, number>() // index → lane cache
Expand Down Expand Up @@ -591,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 ||
Expand Down Expand Up @@ -627,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 ||
Comment thread
tigerBeA marked this conversation as resolved.
isAppendWithTrim(
prevCount,
nextCount,
getPreviousKey,
merged.getItemKey,
)
) {
followOnAppend = behavior
}
}
}
}
Expand Down Expand Up @@ -672,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
Expand Down Expand Up @@ -1272,6 +1315,7 @@ export class Virtualizer<
const itemSizeCache = this.itemSizeCache
if (!enabled) {
this.measurementsCache = []
this._singleLaneMeasurements = null
this.itemSizeCache.clear()
this.laneAssignments.clear()
return []
Expand All @@ -1291,6 +1335,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
Expand Down Expand Up @@ -1320,21 +1365,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<Key | VirtualItem> =
min === 0
? new Array(count)
: this._singleLaneMeasurements!.items.slice()

let runningStart: number
if (min === 0) {
runningStart = paddingStart + scrollMargin
Expand All @@ -1346,6 +1392,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'
Expand All @@ -1356,7 +1403,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
}
Expand Down Expand Up @@ -1490,8 +1538,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
Expand Down Expand Up @@ -1627,8 +1675,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]!
Expand Down Expand Up @@ -1749,7 +1797,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,
Expand Down Expand Up @@ -1969,7 +2017,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 {
Expand Down
23 changes: 13 additions & 10 deletions packages/virtual-core/src/lazy-measurements.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,35 @@
// 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 getMeasurementKey(
item: VirtualItem | VirtualItem['key'],
): VirtualItem['key'] {
return typeof item === 'object' ? item.key : item
}

export function createLazyMeasurementsView(
count: number,
cache: Array<VirtualItem | VirtualItem['key']>,
flat: Float64Array,
getItemKey: (i: number) => Key,
): Array<VirtualItem> {
const cache: Array<VirtualItem | undefined> = 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.
const c = prop.charCodeAt(0)
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]!,
Expand Down
Loading