Files
SnowLuma/.pnpm-store/v11/files/8d/2f65df61dce4a0ff1cda10bc6754656d51a2cbf03a3d782f452d27adb9fa87534d00d9f874c1c578dbd7257290763b7aba39c246289d0cd1ddb159b9c3c668
T
TanTan 2a69b23b47
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
docs: 仓库链接指向本镜像仓库
2026-11-13 09:27:07 +08:00

2031 lines
63 KiB
Plaintext

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) {
const newOffset = 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
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
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
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 getFurthestMeasurement = (
measurements: Array<VirtualItem>,
index: number,
) => {
const furthestMeasurementsFound = new Map<number, true>()
const furthestMeasurements = new Map<number, VirtualItem>()
for (let m = index - 1; m >= 0; m--) {
const measurement = measurements[m]!
if (furthestMeasurementsFound.has(measurement.lane)) {
continue
}
const previousFurthestMeasurement = furthestMeasurements.get(
measurement.lane,
)
if (
previousFurthestMeasurement == null ||
measurement.end > previousFurthestMeasurement.end
) {
furthestMeasurements.set(measurement.lane, measurement)
} else if (measurement.end < previousFurthestMeasurement.end) {
furthestMeasurementsFound.set(measurement.lane, true)
}
if (furthestMeasurementsFound.size === this.options.lanes) {
break
}
}
return furthestMeasurements.size === this.options.lanes
? Array.from(furthestMeasurements.values()).sort((a, b) => {
if (a.end === b.end) {
return a.index - b.index
}
return a.end - b.end
})[0]
: undefined
}
private getMeasurementOptions = memo(
() => [
this.options.count,
this.options.paddingStart,
this.options.scrollMargin,
this.options.getItemKey,
this.options.enabled,
this.options.lanes,
this.options.laneAssignmentMode,
],
(
count,
paddingStart,
scrollMargin,
getItemKey,
enabled,
lanes,
laneAssignmentMode,
) => {
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,
}
},
{
key: false,
},
)
private getMeasurements = memo(
() => [this.getMeasurementOptions(), this.itemSizeCacheVersion],
(
{
count,
paddingStart,
scrollMargin,
getItemKey,
enabled,
lanes,
laneAssignmentMode,
},
_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) {
const gap = this.options.gap
// 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,
)
// Initialize from existing measurements (before min)
for (let m = 0; m < min; m++) {
const item = measurements[m]
if (item) {
laneLastIndex[item.lane] = m
}
}
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 + this.options.gap
: paddingStart + scrollMargin
} else {
// No cache - use original logic (find shortest lane)
const furthestMeasurement =
this.options.lanes === 1
? measurements[i - 1]
: this.getFurthestMeasurement(measurements, i)
start = furthestMeasurement
? furthestMeasurement.end + this.options.gap
: paddingStart + scrollMargin
lane = furthestMeasurement
? furthestMeasurement.lane
: i % this.options.lanes
if (this.options.lanes > 1 && 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
laneLastIndex[lane] = i
}
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)
}
})
return
}
const index = this.indexFromElement(node)
const key = this.options.getItemKey(index)
const prevNode = this.elementsCache.get(key)
if (prevNode !== node) {
if (prevNode) {
this.observer.unobserve(prevNode)
}
this.observer.observe(node)
this.elementsCache.set(key, node)
}
// Sync-measure when idle (initial render) or during programmatic scrolling
// (scrollToIndex/scrollToOffset) where reconcileScroll needs sizes in the same frame.
// During normal user scrolling, skip sync measurement — the RO callback handles it async.
if (
(!this.isScrolling || this.scrollState) &&
this.shouldMeasureDuringScroll(index)
) {
this.resizeItem(index, this.options.measureElement(node, undefined, this))
}
}
resizeItem = (index: number, size: number) => {
if (index < 0 || index >= this.options.count) return
// Fast field reads. For lanes===1 we read raw start/size from the flat
// typed array, avoiding a Proxy.get + VirtualItem allocation per call.
// For lanes>1 we fall back to the cached VirtualItem array.
let cachedSize: number
let itemStart: number
let key: Key
const flat = this._flatMeasurements
if (this.options.lanes === 1 && flat !== null) {
key = this.options.getItemKey(index)
itemStart = flat[index * 2]!
cachedSize = flat[index * 2 + 1]!
} else {
const item = this.measurementsCache[index]
if (!item) return
key = item.key
itemStart = item.start
cachedSize = item.size
}
const itemSize = this.itemSizeCache.get(key) ?? cachedSize
const delta = size - itemSize
if (delta !== 0) {
const wasAtEnd =
this.options.anchorTo === 'end' &&
this.scrollState?.behavior !== 'smooth' &&
this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold
const prevTotalSize = wasAtEnd ? this.getTotalSize() : 0
const shouldAdjustScroll =
this.scrollState?.behavior !== 'smooth' &&
(this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
? this.shouldAdjustScrollPositionOnItemSizeChange(
// The callback expects a VirtualItem; build one lazily only
// when the consumer actually supplied a custom predicate.
this.measurementsCache[index] ?? {
index,
key,
start: itemStart,
size: cachedSize,
end: itemStart + cachedSize,
lane: 0,
},
delta,
this,
)
: // Default: adjust when the resize is an above-viewport item.
// First measurement (!has(key)): always adjust — the item
// has never been sized, so the estimate→actual delta must
// be compensated regardless of scroll direction.
// Re-measurement (has(key)): skip during backward scroll
// to avoid the "items jump while scrolling up" cascade.
itemStart < this.getScrollOffset() + this.scrollAdjustments &&
(!this.itemSizeCache.has(key) ||
this.scrollDirection !== 'backward'))
if (this.pendingMin === null || index < this.pendingMin) {
this.pendingMin = index
}
this.itemSizeCache.set(key, size)
this.itemSizeCacheVersion++
if (wasAtEnd) {
this.applyScrollAdjustment(this.getTotalSize() - prevTotalSize)
} else if (shouldAdjustScroll) {
this.applyScrollAdjustment(delta)
}
this.notify(false)
}
}
getVirtualItems = memo(
() => [this.getVirtualIndexes(), this.getMeasurements()],
(indexes, measurements) => {
const virtualItems: Array<VirtualItem> = []
for (let k = 0, len = indexes.length; k < len; k++) {
const i = indexes[k]!
const measurement = measurements[i]!
virtualItems.push(measurement)
}
return virtualItems
},
{
key: process.env.NODE_ENV !== 'production' && 'getVirtualItems',
debug: () => this.options.debug,
},
)
getVirtualItemForOffset = (offset: number) => {
const measurements = this.getMeasurements()
if (measurements.length === 0) {
return undefined
}
// 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 useFlat = this.options.lanes === 1 && flat != null
const idx = findNearestBinarySearch(
0,
measurements.length - 1,
useFlat
? (i: number) => flat[i * 2]!
: (i: number) => notUndefined(measurements[i]).start,
offset,
)
return notUndefined(measurements[idx])
}
private getMaxScrollOffset = () => {
if (!this.scrollElement) return 0
if ('scrollHeight' in this.scrollElement) {
// Element
return this.options.horizontal
? this.scrollElement.scrollWidth - this.scrollElement.clientWidth
: this.scrollElement.scrollHeight - this.scrollElement.clientHeight
} else {
// Window
const doc = this.scrollElement.document.documentElement
return this.options.horizontal
? doc.scrollWidth - this.scrollElement.innerWidth
: doc.scrollHeight - this.scrollElement.innerHeight
}
}
private getVirtualDistanceFromEnd = () => {
return Math.max(
this.getTotalSize() - this.getSize() - this.getScrollOffset(),
0,
)
}
getDistanceFromEnd = () => {
return Math.max(this.getMaxScrollOffset() - this.getScrollOffset(), 0)
}
isAtEnd = (threshold = this.options.scrollEndThreshold) => {
return this.getDistanceFromEnd() <= threshold
}
getOffsetForAlignment = (
toOffset: number,
align: ScrollAlignment,
itemSize = 0,
) => {
if (!this.scrollElement) return 0
const size = this.getSize()
const scrollOffset = this.getScrollOffset()
if (align === 'auto') {
align = toOffset >= scrollOffset + size ? 'end' : 'start'
}
if (align === 'center') {
// When aligning to a particular item (e.g. with scrollToIndex),
// adjust offset by the size of the item to center on the item
toOffset += (itemSize - size) / 2
} else if (align === 'end') {
toOffset -= size
}
const maxOffset = this.getMaxScrollOffset()
return Math.max(Math.min(maxOffset, toOffset), 0)
}
getOffsetForIndex = (index: number, align: ScrollAlignment = 'auto') => {
index = Math.max(0, Math.min(index, this.options.count - 1))
const size = this.getSize()
const scrollOffset = this.getScrollOffset()
const item = this.measurementsCache[index]
if (!item) return
if (align === 'auto') {
if (item.end >= scrollOffset + size - this.options.scrollPaddingEnd) {
align = 'end'
} else if (item.start <= scrollOffset + this.options.scrollPaddingStart) {
align = 'start'
} else {
return [scrollOffset, align] as const
}
}
// For the last item with 'end' alignment, use browser's actual max scroll
// to account for borders/padding that aren't in our measurements
if (align === 'end' && index === this.options.count - 1) {
return [this.getMaxScrollOffset(), align] as const
}
const toOffset =
align === 'end'
? item.end + this.options.scrollPaddingEnd
: item.start - this.options.scrollPaddingStart
return [
this.getOffsetForAlignment(toOffset, align, item.size),
align,
] as const
}
scrollToOffset = (
toOffset: number,
{ align = 'start', behavior = 'auto' }: ScrollToOffsetOptions = {},
) => {
const offset = this.getOffsetForAlignment(toOffset, align)
const now = this.now()
this.scrollState = {
index: null,
align,
behavior,
startedAt: now,
lastTargetOffset: offset,
stableFrames: 0,
}
this._scrollToOffset(offset, { adjustments: undefined, behavior })
this.scheduleScrollReconcile()
}
scrollToIndex = (
index: number,
{
align: initialAlign = 'auto',
behavior = 'auto',
}: ScrollToIndexOptions = {},
) => {
index = Math.max(0, Math.min(index, this.options.count - 1))
const offsetInfo = this.getOffsetForIndex(index, initialAlign)
if (!offsetInfo) {
return
}
const [offset, align] = offsetInfo
const now = this.now()
this.scrollState = {
index,
align,
behavior,
startedAt: now,
lastTargetOffset: offset,
stableFrames: 0,
}
this._scrollToOffset(offset, { adjustments: undefined, behavior })
this.scheduleScrollReconcile()
}
scrollBy = (
delta: number,
{ behavior = 'auto' }: ScrollToOffsetOptions = {},
) => {
const offset = this.getScrollOffset() + delta
const now = this.now()
this.scrollState = {
index: null,
align: 'start',
behavior,
startedAt: now,
lastTargetOffset: offset,
stableFrames: 0,
}
this._scrollToOffset(offset, { adjustments: undefined, behavior })
this.scheduleScrollReconcile()
}
scrollToEnd = ({ behavior = 'auto' }: ScrollToEndOptions = {}) => {
if (this.options.count > 0) {
this.scrollToIndex(this.options.count - 1, {
align: 'end',
behavior,
})
return
}
this.scrollToOffset(Math.max(this.getTotalSize() - this.getSize(), 0), {
behavior,
})
}
getTotalSize = () => {
const measurements = this.getMeasurements()
let end: number
// If there are no measurements, set the end to paddingStart
// If there is only one lane, use the last measurement's end
// Otherwise find the maximum end value among all measurements
if (measurements.length === 0) {
end = this.options.paddingStart
} else if (this.options.lanes === 1) {
// Fast path: read last item's end directly from the flat typed array
// 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
if (flat != null) {
end = flat[lastIdx * 2]! + flat[lastIdx * 2 + 1]!
} else {
end = measurements[lastIdx]?.end ?? 0
}
} else {
const endByLane = Array<number | null>(this.options.lanes).fill(null)
let endIndex = measurements.length - 1
while (endIndex >= 0 && endByLane.some((val) => val === null)) {
const item = measurements[endIndex]!
if (endByLane[item.lane] === null) {
endByLane[item.lane] = item.end
}
endIndex--
}
end = Math.max(...endByLane.filter((val): val is number => val !== null))
}
return Math.max(
end - this.options.scrollMargin + this.options.paddingEnd,
0,
)
}
/**
* Returns a snapshot of currently-measured items suitable for round-
* tripping through state storage (sessionStorage, history, etc.) and
* passing back as `initialMeasurementsCache` on remount. Pair with the
* current `scrollOffset` to restore exact scroll position after navigation.
*
* Only items the consumer has actually rendered (and thus measured) appear
* in the snapshot; unmeasured items will fall back to `estimateSize` on
* restore. Returns an empty array if no items have been measured.
*/
takeSnapshot = (): Array<VirtualItem> => {
const snapshot: Array<VirtualItem> = []
if (this.itemSizeCache.size === 0) return snapshot
// Iterate measurementsCache only for indices whose key is in itemSizeCache
// (i.e., have been measured). We build VirtualItem objects with the
// current start/size/end so they can be persisted as plain data.
const m = this.getMeasurements()
for (const item of m) {
if (item && this.itemSizeCache.has(item.key)) {
// Force materialization (lazy path) and copy plain fields.
snapshot.push({
index: item.index,
key: item.key,
start: item.start,
size: item.size,
end: item.end,
lane: item.lane,
})
}
}
return snapshot
}
private _scrollToOffset = (
offset: number,
{
adjustments,
behavior,
}: {
adjustments: number | undefined
behavior: ScrollBehavior | undefined
},
) => {
// Record the intended logical scroll target so the next scroll event
// can reconcile against subpixel rounding by the browser.
this._intendedScrollOffset = offset + (adjustments ?? 0)
this.options.scrollToFn(offset, { behavior, adjustments }, this)
}
measure = () => {
// Reset pendingMin so the next getMeasurements rebuilds from index 0.
// Without this, a prior resizeItem() that left pendingMin > 0 would
// cause the rebuild to preserve stale items before that index.
this.pendingMin = null
this.itemSizeCache.clear()
this.laneAssignments.clear() // Clear lane cache for full re-layout
this.itemSizeCacheVersion++
this.notify(false)
}
}
const findNearestBinarySearch = (
low: number,
high: number,
getCurrentValue: (i: number) => number,
value: number,
) => {
while (low <= high) {
const middle = ((low + high) / 2) | 0
const currentValue = getCurrentValue(middle)
if (currentValue < value) {
low = middle + 1
} else if (currentValue > value) {
high = middle - 1
} else {
return middle
}
}
if (low > 0) {
return low - 1
} else {
return 0
}
}
// Monomorphic Float64Array variant — reads start values directly at stride
// 2 instead of through a getter closure. JITs the inner load to a typed-
// array bounds-check + load with no indirect call.
function findNearestBinarySearchFlat(
flat: Float64Array,
high: number,
value: number,
) {
let low = 0
while (low <= high) {
const middle = ((low + high) / 2) | 0
const currentValue = flat[middle * 2]!
if (currentValue < value) {
low = middle + 1
} else if (currentValue > value) {
high = middle - 1
} else {
return middle
}
}
return low > 0 ? low - 1 : 0
}
function calculateRangeImpl(
measurements: Array<VirtualItem>,
outerSize: number,
scrollOffset: number,
lanes: number,
flat: Float64Array | null,
) {
const lastIndex = measurements.length - 1
// handle case when item count is less than or equal to lanes
if (measurements.length <= lanes) {
return { startIndex: 0, endIndex: lastIndex }
}
if (lanes === 1 && flat !== null) {
// Hot single-lane path: typed-array reads, no closures, no Proxy traps.
const startIndex = findNearestBinarySearchFlat(
flat,
lastIndex,
scrollOffset,
)
let endIndex = startIndex
const limit = scrollOffset + outerSize
while (
endIndex < lastIndex &&
flat[endIndex * 2]! + flat[endIndex * 2 + 1]! < limit
) {
endIndex++
}
return { startIndex, endIndex }
}
// Fallback (lanes > 1 or no flat array): closure-based reads.
const getStart = (index: number) => measurements[index]!.start
let startIndex = findNearestBinarySearch(0, lastIndex, getStart, scrollOffset)
let endIndex = startIndex
if (lanes === 1) {
while (
endIndex < lastIndex &&
measurements[endIndex]!.end < scrollOffset + outerSize
) {
endIndex++
}
} else if (lanes > 1) {
// Expand forward until we include the visible items from all lanes
// which are closer to the end of the virtualizer window
const endPerLane = Array(lanes).fill(0)
while (
endIndex < lastIndex &&
endPerLane.some((pos) => pos < scrollOffset + outerSize)
) {
const item = measurements[endIndex]!
endPerLane[item.lane] = item.end
endIndex++
}
// Expand backward until we include all lanes' visible items
// closer to the top
const startPerLane = Array(lanes).fill(scrollOffset + outerSize)
while (startIndex >= 0 && startPerLane.some((pos) => pos >= scrollOffset)) {
const item = measurements[startIndex]!
startPerLane[item.lane] = item.start
startIndex--
}
// Align startIndex to the beginning of its lane
startIndex = Math.max(0, startIndex - (startIndex % lanes))
// Align endIndex to the end of its lane
endIndex = Math.min(lastIndex, endIndex + (lanes - 1 - (endIndex % lanes)))
}
return { startIndex, endIndex }
}