UNPKG

@tanstack/virtual-core

Version:

Headless UI for virtualizing scrollable elements in TS/JS + Frameworks

1,499 lines (1,336 loc) 68.1 kB
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)