@tanstack/virtual-core
Version:
Headless UI for virtualizing scrollable elements in TS/JS + Frameworks
1,499 lines (1,336 loc) • 68.1 kB
text/typescript
import { createLazyMeasurementsView } from './lazy-measurements'
import { approxEqual, debounce, memo, notUndefined } from './utils'
// Browser-aware iOS detection. Programmatic `scrollTo`/`scrollTop` writes
// during a momentum-scroll cancel the momentum on iOS WebKit, so we defer
// scroll-position adjustments triggered by mid-scroll resizes until the
// scroll settles. SSR-safe (returns false when navigator is unavailable).
let _isIOSResult: boolean | undefined
const isIOSWebKit = (): boolean => {
if (_isIOSResult !== undefined) return _isIOSResult
if (typeof navigator === 'undefined') return (_isIOSResult = false)
if (/iP(hone|od|ad)/.test(navigator.userAgent)) return (_isIOSResult = true)
// iPadOS 13+ reports as MacIntel; touch-points distinguishes it from desktop.
const mtp = (navigator as Navigator & { maxTouchPoints?: number })
.maxTouchPoints
return (_isIOSResult =
navigator.platform === 'MacIntel' && mtp !== undefined && mtp > 0)
}
// Test hook: reset the iOS detection cache. Not exported.
export const _resetIOSDetectionForTests = () => {
_isIOSResult = undefined
}
export { approxEqual, debounce, memo, notUndefined } from './utils'
export type { NoInfer, PartialKeys } from './utils'
//
type ScrollDirection = 'forward' | 'backward'
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
}
type ScrollToOffsetOptions = ScrollToOptions
type ScrollToIndexOptions = ScrollToOptions
type ScrollToEndOptions = Pick<ScrollToOptions, 'behavior'>
export interface Range {
startIndex: number
endIndex: number
overscan: number
count: number
}
type Key = number | string | bigint
export interface VirtualItem {
key: Key
index: number
start: number
end: number
size: number
lane: number
}
export interface Rect {
width: number
height: number
}
//
const getRect = (element: HTMLElement): Rect => {
const { offsetWidth, offsetHeight } = element
return { width: offsetWidth, height: offsetHeight }
}
export const defaultKeyExtractor = (index: number) => index
export const defaultRangeExtractor = (range: Range) => {
const start = Math.max(range.startIndex - range.overscan, 0)
const end = Math.min(range.endIndex + range.overscan, range.count - 1)
const len = end - start + 1
const arr = new Array<number>(len)
for (let i = 0; i < len; i++) {
arr[i] = start + i
}
return arr
}
export const observeElementRect = <T extends Element>(
instance: Virtualizer<T, any>,
cb: (rect: Rect) => void,
) => {
const element = instance.scrollElement
if (!element) {
return
}
const targetWindow = instance.targetWindow
if (!targetWindow) {
return
}
const handler = (rect: Rect) => {
const { width, height } = rect
cb({ width: Math.round(width), height: Math.round(height) })
}
handler(getRect(element as unknown as HTMLElement))
if (!targetWindow.ResizeObserver) {
return () => {}
}
const observer = new targetWindow.ResizeObserver((entries) => {
const run = () => {
const entry = entries[0]
if (entry?.borderBoxSize) {
const box = entry.borderBoxSize[0]
if (box) {
handler({ width: box.inlineSize, height: box.blockSize })
return
}
}
handler(getRect(element as unknown as HTMLElement))
}
instance.options.useAnimationFrameWithResizeObserver
? requestAnimationFrame(run)
: run()
})
observer.observe(element, { box: 'border-box' })
return () => {
observer.unobserve(element)
}
}
const addEventListenerOptions = {
passive: true,
}
export const observeWindowRect = (
instance: Virtualizer<Window, any>,
cb: (rect: Rect) => void,
) => {
const element = instance.scrollElement
if (!element) {
return
}
const handler = () => {
cb({ width: element.innerWidth, height: element.innerHeight })
}
handler()
element.addEventListener('resize', handler, addEventListenerOptions)
return () => {
element.removeEventListener('resize', handler)
}
}
const supportsScrollend =
typeof window == 'undefined' ? true : 'onscrollend' in window
type ObserveOffsetCallBack = (offset: number, isScrolling: boolean) => void
// Shared core: both element and window variants attach scroll/scrollend
// listeners with the same lifecycle; they only differ in how to read the
// current offset from the scroll target.
const observeOffset = <T extends Element | Window>(
instance: Virtualizer<T, any>,
cb: ObserveOffsetCallBack,
readOffset: (target: T) => number,
) => {
const element = instance.scrollElement
if (!element) {
return
}
const targetWindow = instance.targetWindow
if (!targetWindow) {
return
}
const registerScrollendEvent =
instance.options.useScrollendEvent && supportsScrollend
let offset = 0
const fallback = registerScrollendEvent
? null
: debounce(
targetWindow,
() => cb(offset, false),
instance.options.isScrollingResetDelay,
)
const createHandler = (isScrolling: boolean) => () => {
offset = readOffset(element)
fallback?.()
cb(offset, isScrolling)
}
const handler = createHandler(true)
const endHandler = createHandler(false)
element.addEventListener('scroll', handler, addEventListenerOptions)
if (registerScrollendEvent) {
element.addEventListener('scrollend', endHandler, addEventListenerOptions)
}
return () => {
element.removeEventListener('scroll', handler)
if (registerScrollendEvent) {
element.removeEventListener('scrollend', endHandler)
}
}
}
export const observeElementOffset = <T extends Element>(
instance: Virtualizer<T, any>,
cb: ObserveOffsetCallBack,
) =>
observeOffset(instance, cb, (el) => {
const { horizontal, isRtl } = instance.options
return horizontal ? el.scrollLeft * ((isRtl && -1) || 1) : el.scrollTop
})
export const observeWindowOffset = (
instance: Virtualizer<Window, any>,
cb: ObserveOffsetCallBack,
) =>
observeOffset(instance, cb, (win) =>
instance.options.horizontal ? win.scrollX : win.scrollY,
)
export const measureElement = <TItemElement extends Element>(
element: TItemElement,
entry: ResizeObserverEntry | undefined,
instance: Virtualizer<any, TItemElement>,
) => {
// When useCachedMeasurements is enabled, return the cached size
// (or estimateSize as fallback) instead of measuring the DOM.
if (instance.options.useCachedMeasurements) {
const index = instance.indexFromElement(element)
const key = instance.options.getItemKey(index)
return (
instance.itemSizeCache.get(key) ?? instance.options.estimateSize(index)
)
}
if (entry?.borderBoxSize) {
const box = entry.borderBoxSize[0]
if (box) {
const size = Math.round(
box[instance.options.horizontal ? 'inlineSize' : 'blockSize'],
)
return size
}
}
// When called without a ResizeObserverEntry (sync measurement path),
// return the previously measured size if available. This avoids a
// synchronous layout read (offsetWidth/offsetHeight) on re-renders.
// The ResizeObserver is already observing the element and will deliver
// the accurate size asynchronously if it changed.
// Users who need synchronous DOM reads can provide a custom measureElement.
if (!entry) {
const index = instance.indexFromElement(element)
const key = instance.options.getItemKey(index)
const cachedSize = instance.itemSizeCache.get(key)
if (cachedSize !== undefined) {
return cachedSize
}
}
return (element as unknown as HTMLElement)[
instance.options.horizontal ? 'offsetWidth' : 'offsetHeight'
]
}
const scrollWithAdjustments = (
offset: number,
{
adjustments = 0,
behavior,
}: { adjustments?: number; behavior?: ScrollBehavior },
instance: Virtualizer<any, any>,
) => {
instance.scrollElement?.scrollTo?.({
[instance.options.horizontal ? 'left' : 'top']: offset + adjustments,
behavior,
})
}
export const windowScroll: <T extends Window>(
offset: number,
options: { adjustments?: number; behavior?: ScrollBehavior },
instance: Virtualizer<T, any>,
) => void = scrollWithAdjustments
export const elementScroll: <T extends Element>(
offset: number,
options: { adjustments?: number; behavior?: ScrollBehavior },
instance: Virtualizer<T, any>,
) => void = scrollWithAdjustments
type LaneAssignmentMode = 'estimate' | 'measured'
export interface VirtualizerOptions<
TScrollElement extends Element | Window,
TItemElement extends Element,
> {
// Required from the user
count: number
getScrollElement: () => TScrollElement | null
estimateSize: (index: number) => number
// Required from the framework adapter (but can be overridden)
scrollToFn: (
offset: number,
options: { adjustments?: number; behavior?: ScrollBehavior },
instance: Virtualizer<TScrollElement, TItemElement>,
) => void
observeElementRect: (
instance: Virtualizer<TScrollElement, TItemElement>,
cb: (rect: Rect) => void,
) => void | (() => void)
observeElementOffset: (
instance: Virtualizer<TScrollElement, TItemElement>,
cb: ObserveOffsetCallBack,
) => void | (() => void)
// Optional
debug?: boolean
initialRect?: Rect
onChange?: (
instance: Virtualizer<TScrollElement, TItemElement>,
sync: boolean,
) => void
measureElement?: (
element: TItemElement,
entry: ResizeObserverEntry | undefined,
instance: Virtualizer<TScrollElement, TItemElement>,
) => number
overscan?: number
horizontal?: boolean
paddingStart?: number
paddingEnd?: number
scrollPaddingStart?: number
scrollPaddingEnd?: number
initialOffset?: number | (() => number)
getItemKey?: (index: number) => Key
rangeExtractor?: (range: Range) => Array<number>
scrollMargin?: number
gap?: number
indexAttribute?: string
initialMeasurementsCache?: Array<VirtualItem>
lanes?: number
anchorTo?: ScrollAnchor
followOnAppend?: FollowOnAppend
scrollEndThreshold?: number
isScrollingResetDelay?: number
useScrollendEvent?: boolean
enabled?: boolean
isRtl?: boolean
useAnimationFrameWithResizeObserver?: boolean
laneAssignmentMode?: LaneAssignmentMode
useCachedMeasurements?: boolean
}
type ScrollState = {
// what we want
index: number | null
align: ScrollAlignment
behavior: ScrollBehavior
// lifecycle
startedAt: number
// target tracking
lastTargetOffset: number
// settling
stableFrames: number
}
type PendingScrollAnchor = [
key: Key | null,
offset: number,
followOnAppend: ScrollBehavior | null,
anchorDelta: number,
]
export class Virtualizer<
TScrollElement extends Element | Window,
TItemElement extends Element,
> {
private unsubs: Array<void | (() => void)> = []
options!: Required<VirtualizerOptions<TScrollElement, TItemElement>>
scrollElement: TScrollElement | null = null
targetWindow: (Window & typeof globalThis) | null = null
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
itemSizeCache = new Map<Key, number>()
private itemSizeCacheVersion = 0
private laneAssignments = new Map<number, number>() // index → lane cache
// Earliest index dirtied since last getMeasurements() rebuild, or null.
private pendingMin: number | null = null
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
private scrollAdjustments = 0
// Sum of size-change deltas above-viewport that were skipped during
// iOS momentum scroll (writing scrollTop mid-momentum cancels it).
// Flushed in a single scrollTo when iOS is fully settled.
private _iosDeferredAdjustment = 0
// Touch state. iOS WebKit cancels momentum when scrollTop is written, so
// we defer adjustments not only during `isScrolling` but also through the
// touchstart→touchend window (active drag) and a short tail after
// touchend (early-momentum window — iOS only fires touch events once at
// the start of momentum, so we use a timer rather than another event).
private _iosTouching = false
private _iosJustTouchEnded = false
private _iosTouchEndTimerId: number | null = null
// Subpixel reconciliation. Safari (and Chrome/Firefox under certain DPRs)
// round scrollTop/scrollLeft writes to integer pixels. If we wrote 12345.5
// but the browser reports back 12346, the next reconcileScroll sees a
// "target changed" and re-fires scrollTo — a feedback loop that the
// approxEqual(<1.01) tolerance otherwise absorbs as a workaround.
// By remembering the intended value of our most-recent self-driven
// scrollTo, we can match the browser's rounded read back to the intended
// value when the diff is < 1.5 px, distinguishing it from a real user
// scroll. The +0.5 over Math.abs lets us also absorb the +1 / -1 cases.
private _intendedScrollOffset: number | null = null
shouldAdjustScrollPositionOnItemSizeChange:
| undefined
| ((
item: VirtualItem,
delta: number,
instance: Virtualizer<TScrollElement, TItemElement>,
) => boolean)
elementsCache = new Map<Key, TItemElement>()
private now = () => this.targetWindow?.performance?.now?.() ?? Date.now()
private observer = (() => {
let _ro: ResizeObserver | null = null
const get = () => {
if (_ro) {
return _ro
}
if (!this.targetWindow || !this.targetWindow.ResizeObserver) {
return null
}
return (_ro = new this.targetWindow.ResizeObserver((entries) => {
entries.forEach((entry) => {
const run = () => {
const node = entry.target as TItemElement
const index = this.indexFromElement(node)
if (!node.isConnected) {
this.observer.unobserve(node)
// Find the cache entry pointing to this exact node and remove
// it. We can't call getItemKey(index) here because items may
// have been removed since this node was rendered — the index
// could be stale and out-of-bounds in the user's data array
// (regression test in e2e/.../stale-index.spec.ts, fix #1148).
// The === comparison naturally handles the React-replaced-
// a-node-for-the-same-key case: that entry now points to a
// different node, so this loop won't match.
for (const [cacheKey, cachedNode] of this.elementsCache) {
if (cachedNode === node) {
this.elementsCache.delete(cacheKey)
break
}
}
return
}
if (this.shouldMeasureDuringScroll(index)) {
this.resizeItem(
index,
this.options.measureElement(node, entry, this),
)
}
}
this.options.useAnimationFrameWithResizeObserver
? requestAnimationFrame(run)
: run()
})
}))
}
return {
disconnect: () => {
get()?.disconnect()
_ro = null
},
observe: (target: Element) =>
get()?.observe(target, { box: 'border-box' }),
unobserve: (target: Element) => get()?.unobserve(target),
}
})()
range: { startIndex: number; endIndex: number } | null = null
constructor(opts: VirtualizerOptions<TScrollElement, TItemElement>) {
this.setOptions(opts)
}
setOptions = (opts: VirtualizerOptions<TScrollElement, TItemElement>) => {
// Skip `{...defaults, ...opts}` because explicit `undefined` values in
// opts would override defaults with `undefined`.
const merged = {
debug: false,
initialOffset: 0,
overscan: 1,
paddingStart: 0,
paddingEnd: 0,
scrollPaddingStart: 0,
scrollPaddingEnd: 0,
horizontal: false,
getItemKey: defaultKeyExtractor,
rangeExtractor: defaultRangeExtractor,
onChange: () => {},
measureElement,
initialRect: { width: 0, height: 0 },
scrollMargin: 0,
gap: 0,
indexAttribute: 'data-index',
initialMeasurementsCache: [],
lanes: 1,
anchorTo: 'start',
followOnAppend: false,
scrollEndThreshold: 1,
isScrollingResetDelay: 150,
enabled: true,
isRtl: false,
useScrollendEvent: false,
useAnimationFrameWithResizeObserver: false,
laneAssignmentMode: 'estimate',
useCachedMeasurements: false,
} as unknown as Required<VirtualizerOptions<TScrollElement, TItemElement>>
for (const key in opts) {
const v = (opts as any)[key]
if (v !== undefined) (merged as any)[key] = v
}
const prevOptions = this.options as
| Required<VirtualizerOptions<TScrollElement, TItemElement>>
| undefined
let anchor: [Key, number] | null = null
let followOnAppend: ScrollBehavior | null = null
let edgeKeysChanged = false
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) {
edgeKeysChanged = true
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
// When edge keys changed (prepend, trim, reorder, etc.) the key→index
// mapping has shifted. Force a full measurement rebuild so the anchor
// resolution below reads positions from the new layout, not the stale
// memoised cache. Without this, a stable `getItemKey` reference +
// unchanged `count` would let getMeasurements() return the old layout.
if (edgeKeysChanged) {
this.pendingMin = 0
this.itemSizeCacheVersion++
}
// Eagerly adjust scrollOffset so the virtualizer computes the correct
// visible range during the current render pass — before _willUpdate
// syncs the DOM scroll position in a layout effect. Without this,
// the virtualizer would render the wrong items for one frame (the
// estimate-based positions are stale) and then correct in the next
// frame, producing a visible "jump" on prepend with dynamic sizes.
let anchorResolved = false
let anchorDelta = 0
if (anchor && this.scrollOffset !== null) {
const [anchorKey, anchorOffset] = anchor
const newMeasurements = this.getMeasurements()
const { count, getItemKey } = this.options
let idx = 0
while (idx < count && getItemKey(idx) !== anchorKey) {
idx++
}
if (idx < count) {
const anchorItem = newMeasurements[idx]
if (anchorItem) {
// Clamp to the reachable range's lower bound — anchorOffset may
// have been derived from a transiently negative scrollOffset
// (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) {
anchorDelta = newOffset - this.scrollOffset
this.scrollOffset = newOffset
anchorResolved = true
}
}
}
}
if (anchorResolved || followOnAppend) {
this.pendingScrollAnchor = [
anchorResolved ? anchor![0] : null,
anchorResolved ? anchor![1] : 0,
followOnAppend,
anchorDelta,
]
}
}
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,
})
// Eagerly carry the intended target in `scrollOffset` so callers that
// read it before the next scroll event — notably the next `resizeItem`
// tick's `getVirtualDistanceFromEnd()` / `wasAtEnd` check — see the
// post-adjustment position even when the DOM `scrollTop` write was
// clamped because the consumer hasn't grown the sizer yet (`notify()`
// runs after this in `resizeItem`). Same idea as the eager
// `scrollOffset` adjustment for prepend in `setOptions` (#1176). The
// adjustment is now baked into `scrollOffset`, so zero
// `scrollAdjustments` to keep their sum invariant.
if (this.scrollOffset !== null) {
this.scrollOffset += this.scrollAdjustments
// Clamp only the lower bound: a negative offset is unreachable, and
// on an unscrollable element (content fits the viewport) no scroll
// event ever fires to correct it, permanently skewing
// getDistanceFromEnd() and wedging _flushIosDeferredIfReady (#1229).
// Upper-bound overflow stays untouched — it is transiently
// legitimate mid-prepend while the consumer's sizer catches up.
if (this.scrollOffset < 0) this.scrollOffset = 0
this.scrollAdjustments = 0
}
}
}
private maybeNotify = memo(
() => {
this.calculateRange()
return [
this.isScrolling,
this.range ? this.range.startIndex : null,
this.range ? this.range.endIndex : null,
]
},
(isScrolling) => {
this.notify(isScrolling)
},
{
key: process.env.NODE_ENV !== 'production' && 'maybeNotify',
debug: () => this.options.debug,
initialDeps: [
this.isScrolling,
this.range ? this.range.startIndex : null,
this.range ? this.range.endIndex : null,
] as [boolean, number | null, number | null],
},
)
private cleanup = () => {
this.unsubs.filter(Boolean).forEach((d) => d!())
this.unsubs = []
this.observer.disconnect()
if (this.rafId != null && this.targetWindow) {
this.targetWindow.cancelAnimationFrame(this.rafId)
this.rafId = null
}
this.scrollState = null
// The iOS gesture/deferral state is scoped to the current scroll
// element: the touch listeners that maintain it were just removed, and
// an in-flight touch keeps targeting the old element (implicit touch
// capture), so the new element never reports it. Carrying the state
// over would replay a stale deferred delta on the new element's first
// flush, and a cleanup that lands mid-touch or inside the post-touchend
// grace window would strand _iosTouching / _iosJustTouchEnded as true
// (the listener unsub clears the grace timer, and with it the only
// pending reset of the flag), deferring every adjustment on the new
// element until its next touch cycle.
this._iosDeferredAdjustment = 0
this._iosTouching = false
this._iosJustTouchEnded = false
this.scrollElement = null
this.targetWindow = null
}
_didMount = () => {
return () => {
this.cleanup()
}
}
_willUpdate = () => {
const scrollElement = this.options.enabled
? this.options.getScrollElement()
: null
if (this.scrollElement !== scrollElement) {
this.cleanup()
if (!scrollElement) {
this.maybeNotify()
return
}
this.scrollElement = scrollElement
if (this.scrollElement && 'ownerDocument' in this.scrollElement) {
this.targetWindow = this.scrollElement.ownerDocument.defaultView
} else {
this.targetWindow = this.scrollElement?.window ?? null
}
this.elementsCache.forEach((cached) => {
this.observer.observe(cached)
})
this.unsubs.push(
this.options.observeElementRect(this, (rect) => {
this.scrollRect = rect
this.maybeNotify()
}),
)
this.unsubs.push(
this.options.observeElementOffset(this, (offset, isScrolling) => {
// A scroll event that reports movement but lands on the offset we
// already hold — and isn't a self-write read-back — is a spurious
// no-op re-emit that Safari/Firefox fire after a re-render's layout
// (Chrome doesn't). Treating it as scrolling re-arms `isScrolling`,
// which forces a render that triggers another such event: an
// infinite re-render loop. Ignore it. (Self-writes are handled by
// the `_intendedScrollOffset` reconciliation just below.)
if (
isScrolling &&
this._intendedScrollOffset === null &&
offset === this.scrollOffset
) {
return
}
// If this scroll event looks like the browser's read-back of a
// value we just wrote, prefer our intended (sub-pixel-accurate)
// value over the browser's rounded one. The 1.5 px tolerance is
// tight enough to avoid mistaking a real user scroll for a
// self-write — by the time the user has moved 1.5 px, the
// intended value will already have been consumed by a prior
// scroll event and cleared.
if (
this._intendedScrollOffset !== null &&
Math.abs(offset - this._intendedScrollOffset) < 1.5
) {
offset = this._intendedScrollOffset
}
this._intendedScrollOffset = null
this.scrollAdjustments = 0
// If the offset hasn't moved, this is the echo of our own
// adjustment write — `applyScrollAdjustment` already folded it
// into `scrollOffset`. There's no direction to infer, so leave
// it alone; a real gesture always moves the offset.
const prevOffset = this.getScrollOffset()
this.scrollDirection = isScrolling
? prevOffset === offset
? this.scrollDirection
: prevOffset < offset
? 'forward'
: 'backward'
: null
this.scrollOffset = offset
this.isScrolling = isScrolling
// Flush deferred iOS adjustments if we're now fully settled.
// "Fully settled" means: not actively scrolling, no finger on
// screen, and the post-touchend grace window has expired.
this._flushIosDeferredIfReady()
if (this.scrollState) {
this.scheduleScrollReconcile()
}
this.maybeNotify()
}),
)
// Touch event listeners (iOS-aware deferral). We attach unconditionally
// — the listeners are passive and cheap; on non-touch devices they
// simply never fire. The gating by isIOSWebKit() lives in resizeItem
// and _flushIosDeferredIfReady so we only burn the path on iOS.
if ('addEventListener' in this.scrollElement) {
const scrollEl = this.scrollElement as unknown as EventTarget
const onTouchStart = () => {
this._iosTouching = true
this._iosJustTouchEnded = false
if (this._iosTouchEndTimerId !== null && this.targetWindow != null) {
this.targetWindow.clearTimeout(this._iosTouchEndTimerId)
this._iosTouchEndTimerId = null
}
}
const onTouchEnd = () => {
this._iosTouching = false
if (!isIOSWebKit() || this.targetWindow == null) {
// Non-iOS: nothing more to track. Just clear the touching flag.
return
}
this._iosJustTouchEnded = true
// After ~150 ms with no scroll/touch events, momentum is done.
this._iosTouchEndTimerId = this.targetWindow.setTimeout(() => {
this._iosJustTouchEnded = false
this._iosTouchEndTimerId = null
// After the grace window, attempt to flush. The scroll event
// for momentum decay may have already fired before our timer.
this._flushIosDeferredIfReady()
}, 150)
}
scrollEl.addEventListener(
'touchstart',
onTouchStart,
addEventListenerOptions,
)
scrollEl.addEventListener(
'touchend',
onTouchEnd,
addEventListenerOptions,
)
this.unsubs.push(() => {
scrollEl.removeEventListener('touchstart', onTouchStart)
scrollEl.removeEventListener('touchend', onTouchEnd)
if (this._iosTouchEndTimerId !== null && this.targetWindow != null) {
this.targetWindow.clearTimeout(this._iosTouchEndTimerId)
this._iosTouchEndTimerId = null
}
})
}
this._scrollToOffset(this.getScrollOffset(), {
adjustments: undefined,
behavior: undefined,
})
}
const anchor = this.pendingScrollAnchor
this.pendingScrollAnchor = null
if (anchor && this.scrollElement && this.options.enabled) {
const [key, _offset, followOnAppend, anchorDelta] = anchor
if (key !== null && !followOnAppend) {
// scrollOffset was eagerly adjusted in setOptions so the
// virtualizer already computed the correct range during render.
// Now sync the browser's actual scroll position to match.
// Skip when followOnAppend is set — scrollToEnd will handle it.
//
// On iOS WebKit, writing scrollTop during touch/momentum cancels
// the in-flight scroll. Defer the DOM sync the same way
// applyScrollAdjustment does — accumulate the delta and let
// _flushIosDeferredIfReady handle it once the scroll settles.
if (
isIOSWebKit() &&
(this.isScrolling || this._iosTouching || this._iosJustTouchEnded)
) {
if (anchorDelta !== 0) {
this._iosDeferredAdjustment += anchorDelta
}
} else {
this._scrollToOffset(this.getScrollOffset(), {
adjustments: undefined,
behavior: undefined,
})
}
}
if (followOnAppend) {
this.scrollToEnd({ behavior: followOnAppend })
}
}
}
// Apply any accumulated iOS-deferred scroll adjustment, but only when we're
// truly settled — not actively scrolling, not under an active touch, and
// past the post-touchend grace window. Called from the scroll callback
// and the touchend grace-timer.
private _flushIosDeferredIfReady = () => {
if (this._iosDeferredAdjustment === 0) return
if (this.isScrolling) return
if (this._iosTouching) return
if (this._iosJustTouchEnded) return
// Phase 2b: Safari elastic-overscroll (rubber-band) lets scrollTop go
// negative or beyond scrollHeight - clientHeight. Writing scrollTop
// while in that zone snaps the page back to the clamped value at the
// end of the bounce, often discarding the user's intent. Skip the
// flush; the next in-bounds scroll event will retry.
const cur = this.getScrollOffset()
const max = this.getMaxScrollOffset()
if (cur < 0 || cur > max) return
// At the end clamp the browser already absorbed a shrink above the
// viewport (it clamped scrollTop onto the new bottom), so replaying our
// deferred negative delta would lift the view off the bottom — drop it.
// Positive deltas still replay: growth above doesn't clamp. (#1233)
if (this._iosDeferredAdjustment < 0 && cur >= max - 1) {
this._iosDeferredAdjustment = 0
return
}
const delta = this._iosDeferredAdjustment
this._iosDeferredAdjustment = 0
// Roll the deferred delta into the running accumulator so any resize
// landing between now and the resulting scroll event computes from the
// post-flush offset rather than the stale one.
this._scrollToOffset(cur, {
adjustments: (this.scrollAdjustments += delta),
behavior: undefined,
})
}
private rafId: number | null = null
private scheduleScrollReconcile() {
if (!this.targetWindow) {
this.scrollState = null
return
}
if (this.rafId != null) return
this.rafId = this.targetWindow.requestAnimationFrame(() => {
this.rafId = null
this.reconcileScroll()
})
}
private reconcileScroll() {
if (!this.scrollState) return
const el = this.scrollElement
if (!el) return
// Safety valve: bail out if reconciliation has been running too long
const MAX_RECONCILE_MS = 5000
if (this.now() - this.scrollState.startedAt > MAX_RECONCILE_MS) {
this.scrollState = null
return
}
const offsetInfo =
this.scrollState.index != null
? this.getOffsetForIndex(this.scrollState.index, this.scrollState.align)
: undefined
const targetOffset = offsetInfo
? offsetInfo[0]
: this.scrollState.lastTargetOffset
// Require one stable frame where target matches scroll offset.
// approxEqual() already tolerates minor fluctuations, so one frame is sufficient
// to confirm scroll has reached its target without premature cleanup.
const STABLE_FRAMES = 1
const targetChanged = targetOffset !== this.scrollState.lastTargetOffset
if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) {
this.scrollState.stableFrames++
if (this.scrollState.stableFrames >= STABLE_FRAMES) {
// Final-pass exact landing. The reconcile-stable check uses a 1.01px
// tolerance (approxEqual) so we don't fight subpixel browser rounding
// during the converging phase. Once we're definitively settled,
// commit the exact target so consumers calling scrollToIndex(N)
// end up at the EXACT computed position of item N — matching
// virtuoso's 0px landing accuracy rather than our prior 0.5-1px.
if (this.getScrollOffset() !== targetOffset) {
this._scrollToOffset(targetOffset, {
adjustments: undefined,
behavior: 'auto',
})
}
this.scrollState = null
return
}
} else {
this.scrollState.stableFrames = 0
if (targetChanged) {
// When the target moves during smooth scroll (because items came into
// view and got measured, shifting positions), the original logic was
// to immediately snap to 'auto' — visibly jarring on long
// scroll-to-index calls. Now: keep smooth while we're still far
// (more than a viewport) from the new target. Only fall back to
// 'auto' for the final approach, so the user sees one continuous
// motion that smoothly adjusts its endpoint as measurements arrive.
const viewport = this.getSize() || 600
const distance = Math.abs(targetOffset - this.getScrollOffset())
const keepSmooth =
this.scrollState.behavior === 'smooth' && distance > viewport
this.scrollState.lastTargetOffset = targetOffset
if (!keepSmooth) {
this.scrollState.behavior = 'auto'
}
this._scrollToOffset(targetOffset, {
adjustments: undefined,
behavior: keepSmooth ? 'smooth' : 'auto',
})
}
}
// Always reschedule while scrollState is active to guarantee
// the safety valve timeout runs even if no scroll events fire
// (e.g. no-op scrollToFn, detached element)
this.scheduleScrollReconcile()
}
private getSize = () => {
if (!this.options.enabled) {
this.scrollRect = null
return 0
}
this.scrollRect = this.scrollRect ?? this.options.initialRect
return this.scrollRect[this.options.horizontal ? 'width' : 'height']
}
private getScrollOffset = () => {
if (!this.options.enabled) {
this.scrollOffset = null
return 0
}
this.scrollOffset =
this.scrollOffset ??
(typeof this.options.initialOffset === 'function'
? this.options.initialOffset()
: this.options.initialOffset)
return this.scrollOffset
}
private getMeasurementOptions = memo(
() => [
this.options.count,
this.options.paddingStart,
this.options.scrollMargin,
this.options.getItemKey,
this.options.enabled,
this.options.lanes,
this.options.laneAssignmentMode,
this.options.gap,
],
(
count,
paddingStart,
scrollMargin,
getItemKey,
enabled,
lanes,
laneAssignmentMode,
gap,
) => {
const lanesChanged =
this.prevLanes !== undefined && this.prevLanes !== lanes
if (lanesChanged) {
// Set flag for getMeasurements to handle
this.lanesChangedFlag = true
}
this.prevLanes = lanes
this.pendingMin = null
return {
count,
paddingStart,
scrollMargin,
getItemKey,
enabled,
lanes,
laneAssignmentMode,
gap,
}
},
{
key: false,
},
)
private getMeasurements = memo(
() => [this.getMeasurementOptions(), this.itemSizeCacheVersion],
(
{
count,
paddingStart,
scrollMargin,
getItemKey,
enabled,
lanes,
laneAssignmentMode,
gap,
},
_itemSizeCacheVersion,
) => {
const itemSizeCache = this.itemSizeCache
if (!enabled) {
this.measurementsCache = []
this.itemSizeCache.clear()
this.laneAssignments.clear()
return []
}
// Clean up stale lane cache entries when count decreases
if (this.laneAssignments.size > count) {
for (const index of this.laneAssignments.keys()) {
if (index >= count) {
this.laneAssignments.delete(index)
}
}
}
// ✅ Force complete recalculation when lanes change
if (this.lanesChangedFlag) {
this.lanesChangedFlag = false // Reset immediately
this.lanesSettling = true // Start settling period
this.measurementsCache = []
this.itemSizeCache.clear()
this.laneAssignments.clear() // Clear lane cache for new lane count
// Force min = 0 on the rebuild
this.pendingMin = null
}
// Don't restore from initialMeasurementsCache during lane changes
// as it contains stale lane assignments from the previous lane count
if (this.measurementsCache.length === 0 && !this.lanesSettling) {
this.measurementsCache = this.options.initialMeasurementsCache
this.measurementsCache.forEach((item) => {
this.itemSizeCache.set(item.key, item.size)
})
}
// During lanes settling, ignore pendingMin to prevent repositioning
const min = this.lanesSettling ? 0 : (this.pendingMin ?? 0)
this.pendingMin = null
// ✅ End settling period when cache is fully built
if (this.lanesSettling && this.measurementsCache.length === count) {
this.lanesSettling = false
}
// ─── Fast path: single-lane lazy materialization ────────────────────
// For lanes === 1 (the default and most common case), skip the
// 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
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
}
let runningStart: number
if (min === 0) {
runningStart = paddingStart + scrollMargin
} else {
// Continue from where we left off
const prevIdx = min - 1
runningStart = flat[prevIdx * 2]! + flat[prevIdx * 2 + 1]! + gap
}
for (let i = min; i < count; i++) {
const key = getItemKey(i)
const measuredSize = itemSizeCache.get(key)
const size =
typeof measuredSize === 'number'
? measuredSize
: this.options.estimateSize(i)
flat[i * 2] = runningStart
flat[i * 2 + 1] = size
runningStart += size + gap
}
const view = createLazyMeasurementsView(count, flat, getItemKey)
this.measurementsCache = view
return view
}
const measurements = this.measurementsCache.slice(0, min)
// ✅ Performance: Track last item index per lane for O(1) lookup
const laneLastIndex: Array<number | undefined> = new Array(lanes).fill(
undefined,
)
// Running end position of each lane's last item, so the shortest lane
// can be found with an O(lanes) argmin instead of the old backward walk
// through `measurements` (getFurthestMeasurement). `filledLanes` tracks
// how many lanes have at least one item, mirroring the previous
// "all lanes seen → shortest lane, else i % lanes" branch.
const laneEnds = new Float64Array(lanes)
let filledLanes = 0
// Initialize from existing measurements (before min)
for (let m = 0; m < min; m++) {
const item = measurements[m]
if (item) {
if (laneLastIndex[item.lane] === undefined) filledLanes++
laneLastIndex[item.lane] = m
laneEnds[item.lane] = item.end
}
}
for (let i = min; i < count; i++) {
const key = getItemKey(i)
// Check for cached lane assignment
const cachedLane = this.laneAssignments.get(i)
let lane: number
let start: number
const shouldCacheLane =
laneAssignmentMode === 'estimate' || itemSizeCache.has(key)
if (cachedLane !== undefined && this.options.lanes > 1) {
// Use cached lane - O(1) lookup for previous item in same lane
lane = cachedLane
const prevIndex = laneLastIndex[lane]
const prevInLane =
prevIndex !== undefined ? measurements[prevIndex] : undefined
start = prevInLane
? prevInLane.end + gap
: paddingStart + scrollMargin
} else if (filledLanes === lanes) {
// No cache, every lane seeded: place in the shortest lane.
// Read the running per-lane ends (O(lanes) argmin) instead of the
// old backward scan. Tie-break on the lane's last-item index to
// preserve the previous sort-by-(end, index) placement exactly.
let bestLane = 0
let bestEnd = laneEnds[0]!
let bestIdx = laneLastIndex[0]!
for (let l = 1; l < lanes; l++) {
const e = laneEnds[l]!
if (e < bestEnd || (e === bestEnd && laneLastIndex[l]! < bestIdx)) {
bestLane = l
bestEnd = e
bestIdx = laneLastIndex[l]!
}
}
lane = bestLane
start = bestEnd + gap
if (shouldCacheLane) {
this.laneAssignments.set(i, lane)
}
} else {
// No cache and not every lane seeded yet — seed lanes in order,
// matching the previous `i % lanes` fallback for the first row.
lane = i % this.options.lanes
start = paddingStart + scrollMargin
if (shouldCacheLane) {
this.laneAssignments.set(i, lane)
}
}
const measuredSize = itemSizeCache.get(key)
const size =
typeof measuredSize === 'number'
? measuredSize
: this.options.estimateSize(i)
const end = start + size
measurements[i] = {
index: i,
start,
size,
end,
key,
lane,
}
// ✅ Performance: Update lane's last item index + running end
if (laneLastIndex[lane] === undefined) filledLanes++
laneLastIndex[lane] = i
laneEnds[lane] = end
}
this.measurementsCache = measurements
return measurements
},
{
key: process.env.NODE_ENV !== 'production' && 'getMeasurements',
debug: () => this.options.debug,
},
)
calculateRange = memo(
() => [
this.getMeasurements(),
this.getSize(),
this.getScrollOffset(),
this.options.lanes,
],
(measurements, outerSize, scrollOffset, lanes) => {
if (measurements.length === 0 || outerSize === 0) {
this.range = null
return null
}
this.range = calculateRangeImpl(
measurements,
outerSize,
scrollOffset,
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
: null,
)
return this.range
},
{
key: process.env.NODE_ENV !== 'production' && 'calculateRange',
debug: () => this.options.debug,
},
)
getVirtualIndexes = memo(
() => {
let startIndex: number | null = null
let endIndex: number | null = null
const range = this.calculateRange()
if (range) {
startIndex = range.startIndex
endIndex = range.endIndex
}
this.maybeNotify.updateDeps([this.isScrolling, startIndex, endIndex])
return [
this.options.rangeExtractor,
this.options.overscan,
this.options.count,
startIndex,
endIndex,
]
},
(rangeExtractor, overscan, count, startIndex, endIndex) => {
return startIndex === null || endIndex === null
? []
: rangeExtractor({
startIndex,
endIndex,
overscan,
count,
})
},
{
key: process.env.NODE_ENV !== 'production' && 'getVirtualIndexes',
debug: () => this.options.debug,
},
)
indexFromElement = (node: TItemElement) => {
const attributeName = this.options.indexAttribute
const indexStr = node.getAttribute(attributeName)
if (!indexStr) {
console.warn(
`Missing attribute name '${attributeName}={index}' on measured element.`,
)
return -1
}
return parseInt(indexStr, 10)
}
/**
* Determines if an item at the given index should be measured during smooth scroll.
* During smooth scroll, only items within a buffer range around the target are measured
* to prevent items far from the target from pushing it away.
*/
private shouldMeasureDuringScroll = (index: number): boolean => {
// No scroll state or not smooth scroll - always allow measurements
if (!this.scrollState || this.scrollState.behavior !== 'smooth') {
return true
}
const scrollIndex =
this.scrollState.index ??
this.getVirtualItemForOffset(this.scrollState.lastTargetOffset)?.index
if (scrollIndex !== undefined && this.range) {
// Allow measurements within a buffer range around the scroll target
const bufferSize = Math.max(
this.options.overscan,
Math.ceil((this.range.endIndex - this.range.startIndex) / 2),
)
const minIndex = Math.max(0, scrollIndex - bufferSize)
const maxIndex = Math.min(
this.options.count - 1,
scrollIndex + bufferSize,
)
return index >= minIndex && index <= maxIndex
}
return true
}
measureElement = (node: TItemElement | null) => {
if (!node) {
this.elementsCache.forEach((cached, key) => {
if (!cached.isConnected) {
this.observer.unobserve(cached)
this.elementsCache.delete(key)