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