From 7b77c3e49662b09f8fd157c4b8837296602e9c4a Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Thu, 15 Oct 2026 08:41:49 +0200 Subject: [PATCH] test(virtual-core): split index.test.ts into topic files index.test.ts had grown to ~7000 lines with ~230 flat tests. Move the tests into one file per topic, without changing any test: - scroll-commands: scrollToIndex/Offset/By, reconcile, cancelScroll, read-backs - measurement: resizeItem, measure(), ResizeObserver cache and batching - range: defaultRangeExtractor and the lazy fast path - ios-deferral: iOS deferred corrections, touch phases, Phase 2a/2b, #1250 - ios-fling-wait: iOS 27 commands that wait for a fling tail - adjustment: scroll-up jank, self-write direction latch, #1218 - anchor-end: anchorTo end, followOnAppend, #1229, #1258 - dom-observers: elementScroll/windowScroll, observeElement/WindowOffset - index: exports, lanes, setOptions, takeSnapshot Helpers used by more than one file move to tests/helpers.ts; the others stay next to their tests. Test bodies and names are unchanged; vitest reports the same 408 tests before and after. Co-Authored-By: Claude Opus 5.5 --- .../virtual-core/tests/adjustment.test.ts | 515 ++ .../virtual-core/tests/anchor-end.test.ts | 664 ++ .../virtual-core/tests/dom-observers.test.ts | 337 + packages/virtual-core/tests/helpers.ts | 142 + packages/virtual-core/tests/index.test.ts | 6766 +---------------- .../virtual-core/tests/ios-deferral.test.ts | 1686 ++++ .../virtual-core/tests/ios-fling-wait.test.ts | 390 + .../virtual-core/tests/measurement.test.ts | 996 +++ packages/virtual-core/tests/range.test.ts | 332 + .../tests/scroll-commands.test.ts | 1466 ++++ 10 files changed, 6675 insertions(+), 6619 deletions(-) create mode 100644 packages/virtual-core/tests/adjustment.test.ts create mode 100644 packages/virtual-core/tests/anchor-end.test.ts create mode 100644 packages/virtual-core/tests/dom-observers.test.ts create mode 100644 packages/virtual-core/tests/helpers.ts create mode 100644 packages/virtual-core/tests/ios-deferral.test.ts create mode 100644 packages/virtual-core/tests/ios-fling-wait.test.ts create mode 100644 packages/virtual-core/tests/measurement.test.ts create mode 100644 packages/virtual-core/tests/range.test.ts create mode 100644 packages/virtual-core/tests/scroll-commands.test.ts diff --git a/packages/virtual-core/tests/adjustment.test.ts b/packages/virtual-core/tests/adjustment.test.ts new file mode 100644 index 000000000..a82e7ea53 --- /dev/null +++ b/packages/virtual-core/tests/adjustment.test.ts @@ -0,0 +1,515 @@ +import { describe, expect, test, vi } from 'vitest' +import { + Virtualizer, + elementScroll, + observeElementOffset, + observeElementRect, +} from '../src/index' + +test('scroll-up jank: backward-scroll skips adjustment on re-measurement by default', () => { + // Default behavior: when an already-measured above-viewport item resizes + // AGAIN while the user is scrolling BACKWARD, we no longer write to + // scrollTop. This avoids the well-known "items jump while scrolling up" jank. + const scrollToFn = vi.fn() + let scrollCb: ((o: number, s: boolean) => void) | null = null + const v = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => + ({ + scrollTop: 200, + scrollLeft: 0, + scrollHeight: 500, + clientHeight: 200, + offsetHeight: 200, + }) as any, + scrollToFn, + observeElementRect: () => {}, + observeElementOffset: (_inst, cb) => { + scrollCb = cb + // Simulate user starting at scrollTop=200, then scrolling up to 100. + cb(200, false) + return () => {} + }, + }) + v._willUpdate() + v['getMeasurements']() + // Seed item 0's size so the backward resize below is a RE-measurement. + v.resizeItem(0, 80) + // Now simulate backward scroll: from 200 to 100 (offset decreases). + scrollCb!(100, true) + expect(v.scrollDirection).toBe('backward') + scrollToFn.mockClear() + + // Re-measure an above-viewport item while scrolling backward. + v.resizeItem(0, 100) // item 0 grows by 20px + + // Default behavior: no scroll-position adjustment fires for re-measurements. + expect(scrollToFn).not.toHaveBeenCalled() +}) + +test('scroll-up jank: backward-scroll still applies adjustment on first measurement', () => { + // First measurement is special: the item was rendered at its estimate and is + // now reporting its actual size. That estimate→actual delta lives above the + // viewport and MUST be compensated, or the anchored content jumps when + // scrolling up into never-measured rows. + const scrollToFn = vi.fn() + let scrollCb: ((o: number, s: boolean) => void) | null = null + const v = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => + ({ + scrollTop: 200, + scrollLeft: 0, + scrollHeight: 500, + clientHeight: 200, + offsetHeight: 200, + }) as any, + scrollToFn, + observeElementRect: () => {}, + observeElementOffset: (_inst, cb) => { + scrollCb = cb + cb(200, false) + return () => {} + }, + }) + v._willUpdate() + v['getMeasurements']() + // Backward scroll: 200 → 100. + scrollCb!(100, true) + expect(v.scrollDirection).toBe('backward') + scrollToFn.mockClear() + + // First measurement of an above-viewport item while scrolling backward. + v.resizeItem(0, 100) // never measured before → estimate(50)→actual(100) + + // Adjustment still fires for the first measurement. + expect(scrollToFn).toHaveBeenCalled() +}) + +test('scroll-up jank: forward-scroll still applies adjustment (no regression)', () => { + const scrollToFn = vi.fn() + let scrollCb: ((o: number, s: boolean) => void) | null = null + const v = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => + ({ + scrollTop: 100, + scrollLeft: 0, + scrollHeight: 500, + clientHeight: 200, + offsetHeight: 200, + }) as any, + scrollToFn, + observeElementRect: () => {}, + observeElementOffset: (_inst, cb) => { + scrollCb = cb + cb(100, false) + return () => {} + }, + }) + v._willUpdate() + v['getMeasurements']() + // Forward scroll: 100 → 200 + scrollCb!(200, true) + expect(v.scrollDirection).toBe('forward') + scrollToFn.mockClear() + + v.resizeItem(0, 100) + + // Forward scroll: adjustment still fires. + expect(scrollToFn).toHaveBeenCalled() +}) + +test('scroll-up jank: idle (scrollDirection=null) still applies adjustment', () => { + // When not actively scrolling, adjustment still fires — needed for the + // mount-time measurement storm where items measure before any scroll. + const scrollToFn = vi.fn() + const v = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => + ({ + scrollTop: 100, + scrollLeft: 0, + scrollHeight: 500, + clientHeight: 200, + offsetHeight: 200, + }) as any, + scrollToFn, + observeElementRect: () => {}, + observeElementOffset: (_inst, cb) => { + cb(100, false) // not scrolling + return () => {} + }, + }) + v._willUpdate() + v['getMeasurements']() + expect(v.scrollDirection).toBeNull() + scrollToFn.mockClear() + + v.resizeItem(0, 100) + expect(scrollToFn).toHaveBeenCalled() +}) + +// ─── scroll direction vs. self-write read-backs ───────────────────────────── +// +// `applyScrollAdjustment` folds the pending adjustment into `scrollOffset` +// eagerly, so the browser's scroll event for our own `scrollTop` write +// arrives at exactly the offset we already hold (directly, or snapped there +// by the `pendingWrite` sub-pixel reconciliation). That equality +// carries no directional information — it must not be classified as a +// `'backward'` gesture, or the default +// `shouldAdjustScrollPositionOnItemSizeChange` skips above-viewport +// compensation for the whole `isScrollingResetDelay` window (see the +// multi-frame reflow test below). + +function createAdjustmentVirtualizer({ + count = 30, + offset, + onChange, +}: { + count?: number + offset: number + onChange?: (instance: any, sync: boolean) => void +}) { + const scrollToFn = vi.fn() + let scrollCb: ((o: number, s: boolean) => void) | null = null + const v = new Virtualizer({ + count, + estimateSize: () => 50, + getScrollElement: () => + ({ + scrollTop: offset, + scrollLeft: 0, + scrollHeight: count * 50, + clientHeight: 200, + offsetHeight: 200, + }) as any, + scrollToFn, + onChange, + observeElementRect: (_inst: any, cb: any) => { + cb({ width: 400, height: 200 }) + return () => {} + }, + observeElementOffset: (_inst: any, cb: any) => { + scrollCb = cb + cb(offset, false) + return () => {} + }, + }) + v._didMount() + v._willUpdate() + v['getMeasurements']() + scrollToFn.mockClear() + return { + v, + scrollToFn, + emitScroll: (o: number, isScrolling: boolean) => scrollCb!(o, isScrolling), + } +} + +test('self-write read-back of a positive adjustment does not latch a scroll direction', () => { + const { v, scrollToFn, emitScroll } = createAdjustmentVirtualizer({ + offset: 600, + }) + + v['applyScrollAdjustment'](40) + expect(scrollToFn).toHaveBeenCalledTimes(1) + // The adjustment is already folded into `scrollOffset`. + expect(v.scrollOffset).toBe(640) + + // Browser read-back of our own write: a scroll event at exactly the + // offset we already hold. + emitScroll(640, true) + + // The event traversed the listener (not swallowed by a no-op guard)... + expect(v.isScrolling).toBe(true) + // ...but an echo of our own write carries no direction information. + expect(v.scrollDirection).toBeNull() +}) + +test('self-write read-back of a negative adjustment does not latch a scroll direction', () => { + const { v, emitScroll } = createAdjustmentVirtualizer({ offset: 600 }) + + v['applyScrollAdjustment'](-40) + expect(v.scrollOffset).toBe(560) + + emitScroll(560, true) + + expect(v.isScrolling).toBe(true) + expect(v.scrollDirection).toBeNull() +}) + +test('self-write read-back rounded by the browser still does not latch a direction', () => { + const { v, emitScroll } = createAdjustmentVirtualizer({ offset: 600 }) + + v['applyScrollAdjustment'](40.5) + expect(v.scrollOffset).toBe(640.5) + + // The browser rounds the write to an integer; the `pendingWrite` + // reconciliation snaps the read-back to the held value. + emitScroll(640, true) + + expect(v.isScrolling).toBe(true) + expect(v.scrollDirection).toBeNull() + expect(v.scrollOffset).toBe(640.5) +}) + +describe('a scrollToFn that reports its write back synchronously', () => { + // The read-back runs inside `applyScrollAdjustment`, before it folds the + // delta into `scrollOffset`; the read-back has already put `scrollOffset` + // at the target, so the delta must not be added a second time. + const setup = (offset: number) => { + const harness = createAdjustmentVirtualizer({ offset }) + harness.scrollToFn.mockImplementation( + (to: number, { adjustments }: { adjustments?: number }) => + harness.emitScroll(to + (adjustments ?? 0), true), + ) + return harness + } + + test('a positive adjustment lands on its target once', () => { + const { v, scrollToFn } = setup(600) + v['applyScrollAdjustment'](40) + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(v.scrollOffset).toBe(640) + expect(v['pendingWrite']).toBeNull() + }) + + test('a negative adjustment lands on its target once', () => { + const { v } = setup(600) + v['applyScrollAdjustment'](-40) + expect(v.scrollOffset).toBe(560) + }) + + test('a read-back above the top is still clamped to 0', () => { + const { v } = setup(20) + v['applyScrollAdjustment'](-50) + expect(v.scrollOffset).toBe(0) + }) +}) + +test('real scroll gestures still latch forward/backward and reset to null', () => { + const { v, emitScroll } = createAdjustmentVirtualizer({ offset: 600 }) + + emitScroll(650, true) + expect(v.scrollDirection).toBe('forward') + emitScroll(700, true) + expect(v.scrollDirection).toBe('forward') + + emitScroll(620, true) + expect(v.scrollDirection).toBe('backward') + emitScroll(540, true) + expect(v.scrollDirection).toBe('backward') + + // Debounced reset emission after the gesture ends. + emitScroll(540, false) + expect(v.scrollDirection).toBeNull() + expect(v.isScrolling).toBe(false) +}) + +test('adjustment read-back during a real backward gesture preserves the direction', () => { + const { v, emitScroll } = createAdjustmentVirtualizer({ offset: 600 }) + + // Real backward gesture: the user is scrolling up. + emitScroll(500, true) + expect(v.scrollDirection).toBe('backward') + + // A compensation write lands mid-gesture; its read-back arrives at + // exactly the held offset. The user is still scrolling up, so the + // direction must survive, not be erased. + v['applyScrollAdjustment'](30) + expect(v.scrollOffset).toBe(530) + emitScroll(530, true) + + expect(v.isScrolling).toBe(true) + expect(v.scrollDirection).toBe('backward') + + // After the reset emission, a fresh adjustment read-back must yield + // null — not a stale 'backward' from before the reset. + emitScroll(530, false) + expect(v.scrollDirection).toBeNull() + + v['applyScrollAdjustment'](30) + expect(v.scrollOffset).toBe(560) + emitScroll(560, true) + expect(v.isScrolling).toBe(true) + expect(v.scrollDirection).toBeNull() +}) + +test('multi-frame reflow: above-viewport re-measure compensation is never skipped by self-write read-backs', () => { + // The end-to-end shape of the bug, with the DEFAULT + // shouldAdjustScrollPositionOnItemSizeChange: a side pane opens and its + // width animation re-wraps rows for several frames while the user sits + // scrolled up. Each frame re-measures above-viewport rows; the browser + // echoes the compensation write back as a scroll event before the next + // frame's ResizeObserver callbacks (scroll fires before RO in the + // rendering steps). If that echo latches 'backward', every later frame's + // re-measure is skipped until the isScrollingResetDelay reset and the + // viewport drifts. + const { v, scrollToFn, emitScroll } = createAdjustmentVirtualizer({ + offset: 600, + }) + + // Pre-measure the rows we'll re-wrap so they're in itemSizeCache — the + // backward-skip leg of the default predicate only applies to re-measures. + for (let i = 0; i < 5; i++) { + v.resizeItem(i, 55) + v['getMeasurements']() + } + // 5 first measurements × +5 each were compensated and folded. + expect(v.scrollOffset).toBe(625) + // Settle: the browser caught up and the scrolling window expired. + emitScroll(625, false) + expect(v.isScrolling).toBe(false) + expect(v.scrollDirection).toBeNull() + scrollToFn.mockClear() + + // The reflow: 5 frames, each re-measures one above-viewport row +20px, + // then the browser delivers the scroll event for the compensation write + // the way it would between frames. + for (let i = 0; i < 5; i++) { + const before = v.scrollOffset! + v.resizeItem(i, 75) + v['getMeasurements']() + if (v.scrollOffset! !== before) { + // A compensation write happened — deliver its read-back. + emitScroll(v.scrollOffset!, true) + } + } + + // Every frame's above-viewport delta must have been compensated: + // 625 + 5 × 20 = 725. If the first frame's read-back latched 'backward', + // frames 2-5 were skipped and the offset is stuck at 645. + expect(scrollToFn).toHaveBeenCalledTimes(5) + expect(v.scrollOffset).toBe(725) +}) + +test('above-viewport compensation notifies synchronously so the scroll write and transforms commit in one paint (#1227)', () => { + // applyScrollAdjustment writes scrollTop synchronously inside the resize + // (ResizeObserver) callback. If the follow-up notify were async, the + // browser could paint one frame with the new scrollTop but the old item + // transforms — the viewport visibly jumps by `delta` and snaps back. The + // compensating resize must notify synchronously (sync=true) so the render + // flushes in the same callback. + const onChange = vi.fn() + const { v, scrollToFn } = createAdjustmentVirtualizer({ + offset: 600, + onChange, + }) + v['getMeasurements']() + onChange.mockClear() + scrollToFn.mockClear() + + // Item 0 sits entirely above the fold (offset 600) — a first measurement + // there compensates regardless of scroll direction, moving scrollTop. + v.resizeItem(0, 90) + + // The compensation write happened... + expect(scrollToFn).toHaveBeenCalledTimes(1) + // ...and its notify was synchronous so transforms flush in this same frame. + expect(onChange).toHaveBeenCalled() + expect(onChange.mock.calls.at(-1)![1]).toBe(true) +}) + +test('a resize that moves no scroll position keeps the cheaper async notify (#1227)', () => { + // No scroll write → no same-frame constraint → the notify stays async so we + // don't force a synchronous render on every below-fold measurement. + const onChange = vi.fn() + const { v, scrollToFn } = createAdjustmentVirtualizer({ + offset: 0, + onChange, + }) + v['getMeasurements']() + onChange.mockClear() + scrollToFn.mockClear() + + // Item 10 (start 500) sits below the fold at offset 0 — measuring it does + // not compensate, so scrollTop is untouched. + v.resizeItem(10, 90) + + expect(scrollToFn).not.toHaveBeenCalled() + expect(onChange).toHaveBeenCalled() + expect(onChange.mock.calls.at(-1)![1]).toBe(false) +}) + +// ─── #1218: viewport-spanning item growth must not drift the viewport ───────── +// A streaming chat message whose top is scrolled above the fold but whose +// bottom extends below it grows at its bottom. That growth happens *below* the +// anchor point (the fold), so scrollTop must stay put. Only an item that is +// ENTIRELY above the fold should shift scrollTop on re-measure. +function makeAdjustmentVirtualizer(scrollTop: number) { + const scrollToFn = vi.fn(elementScroll) + let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = + null + const el = { + scrollTop, + scrollLeft: 0, + scrollHeight: 100000, + clientHeight: 200, + offsetHeight: 200, + scrollTo: vi.fn(({ top }: { top: number }) => { + el.scrollTop = top + }), + addEventListener: () => {}, + removeEventListener: () => {}, + } + const v = new Virtualizer({ + count: 50, + estimateSize: () => 50, + getScrollElement: () => el as any, + scrollToFn, + observeElementRect: (_i, cb) => { + cb({ width: 300, height: 200 }) + }, + observeElementOffset: (_i, cb) => { + scrollCallback = cb + cb(scrollTop, false) // settled, not scrolling + return () => {} + }, + }) + v._willUpdate() + v['getMeasurements']() + return { v, scrollToFn } +} + +test('#1218: re-measuring a viewport-spanning item does not drift scroll', () => { + // Viewport 175..375, items 50px by estimate. Item 3: start=150, end=200 — + // spans the fold at 175. + const { v, scrollToFn } = makeAdjustmentVirtualizer(175) + v.resizeItem(3, 60) // seed the size cache (item still spans the fold) + scrollToFn.mockClear() + + const before = v.scrollOffset + v.resizeItem(3, 160) // stream: grow at the bottom by 100 (below the fold) + + expect(v.scrollOffset).toBe(before) + expect(scrollToFn).not.toHaveBeenCalled() +}) + +test('#1218: re-measuring an entirely-above item still anchors (no regression)', () => { + // Item 1: start=50, end=100 — fully above the fold at 175. + const { v } = makeAdjustmentVirtualizer(175) + v.resizeItem(1, 60) + const before = v.scrollOffset! + + v.resizeItem(1, 160) // grows by 100 entirely above the fold + + // scrollTop shifts by the delta so content below the fold stays put. + expect(v.scrollOffset).toBe(before + 100) +}) + +test('#1218: first measurement of a spanning item still compensates', () => { + // First measurement (estimate→actual) always compensates an above-fold top, + // regardless of spanning — the estimated block sat above the fold. Item 3 + // starts at 150 (above fold 175) and has never been measured. + const { v } = makeAdjustmentVirtualizer(175) + const before = v.scrollOffset! + + v.resizeItem(3, 120) // first measure: 50 -> 120, delta +70 + + expect(v.scrollOffset).toBe(before + 70) +}) diff --git a/packages/virtual-core/tests/anchor-end.test.ts b/packages/virtual-core/tests/anchor-end.test.ts new file mode 100644 index 000000000..62175d780 --- /dev/null +++ b/packages/virtual-core/tests/anchor-end.test.ts @@ -0,0 +1,664 @@ +import { expect, test, vi } from 'vitest' +import { + Virtualizer, + observeElementOffset, + observeElementRect, +} from '../src/index' + +// ─── end anchoring / chat-style reverse virtualization ────────────────────── + +function createChatVirtualizer({ + messages, + offset, + viewportSize = 200, + itemSize = 50, + followOnAppend = false, + threshold = 1, + paddingEnd = 0, +}: { + messages: Array<{ id: string }> + offset: number + viewportSize?: number + itemSize?: number + followOnAppend?: boolean | 'auto' | 'smooth' | 'instant' + threshold?: number + paddingEnd?: number +}) { + let currentMessages = messages + const scrollToFn = vi.fn() + let offsetCb: ((offset: number, isScrolling: boolean) => void) | null = null + const scrollElement = { + scrollTop: offset, + scrollLeft: 0, + // The sizer is `getTotalSize()`, which includes paddingEnd. + scrollHeight: messages.length * itemSize + paddingEnd, + scrollWidth: 1000, + clientHeight: viewportSize, + clientWidth: 400, + offsetHeight: viewportSize, + ownerDocument: { + defaultView: { + requestAnimationFrame: (_cb: FrameRequestCallback) => { + return 1 + }, + cancelAnimationFrame: vi.fn(), + performance: { now: () => Date.now() }, + ResizeObserver: vi.fn(function () { + return { + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + } + }), + }, + }, + } as unknown as HTMLDivElement + + const makeOptions = () => { + const messagesSnapshot = currentMessages + + return { + count: messagesSnapshot.length, + estimateSize: () => itemSize, + getItemKey: (index: number) => messagesSnapshot[index]!.id, + getScrollElement: () => scrollElement, + scrollToFn, + observeElementRect: ( + _instance: any, + cb: (rect: { width: number; height: number }) => void, + ) => { + cb({ width: 400, height: viewportSize }) + return () => {} + }, + observeElementOffset: ( + _instance: any, + cb: (offset: number, isScrolling: boolean) => void, + ) => { + offsetCb = cb + cb(scrollElement.scrollTop, false) + return () => {} + }, + anchorTo: 'end' as const, + followOnAppend, + scrollEndThreshold: threshold, + paddingEnd, + } + } + + const virtualizer = new Virtualizer(makeOptions()) + virtualizer._willUpdate() + virtualizer['getMeasurements']() + scrollToFn.mockClear() + + return { + virtualizer, + scrollElement, + scrollToFn, + setMessages(nextMessages: Array<{ id: string }>) { + currentMessages = nextMessages + virtualizer.setOptions(makeOptions()) + ;(scrollElement as any).scrollHeight = + nextMessages.length * itemSize + paddingEnd + virtualizer._willUpdate() + }, + // Simulate the browser's scroll event after a write (or a gesture). + emitScroll(nextOffset: number, isScrolling = false) { + scrollElement.scrollTop = nextOffset + offsetCb?.(nextOffset, isScrolling) + }, + } +} + +test('anchorTo:end keeps visible content stable when older items are prepended', () => { + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const { setMessages, scrollToFn } = createChatVirtualizer({ + messages, + offset: 100, + }) + + setMessages([{ id: 'm--2' }, { id: 'm--1' }, ...messages]) + + expect(scrollToFn).toHaveBeenCalledTimes(1) + const [offset, options] = scrollToFn.mock.calls[0]! + expect(offset).toBe(200) + expect(options.adjustments).toBeUndefined() +}) + +test('anchorTo:end does not yank a scrolled-up user when items append', () => { + const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) + const { setMessages, scrollToFn } = createChatVirtualizer({ + messages, + offset: 100, + followOnAppend: true, + }) + + setMessages([...messages, { id: 'm-8' }]) + + expect(scrollToFn).not.toHaveBeenCalled() +}) + +test('anchorTo:end preserves a reading anchor with a stable key callback', () => { + const messages = Array.from({ length: 20 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer, scrollToFn, emitScroll } = createChatVirtualizer({ + messages, + offset: 400, + followOnAppend: false, + }) + const readingKey = virtualizer.getVirtualItemForOffset(400)!.key + virtualizer.getVirtualItems() + + for (let step = 1; step <= 2; step++) { + scrollToFn.mockClear() + messages.splice(0, 1) + messages.push({ id: `m-${19 + step}` }) + // Keep the same callback; the old edge keys have not been read yet. + virtualizer.setOptions(virtualizer.options) + virtualizer._willUpdate() + + const target = 400 - step * 50 + expect(scrollToFn.mock.calls.at(-1)?.[0]).toBe(target) + emitScroll(target) + expect(virtualizer.getVirtualItemForOffset(target)?.key).toBe(readingKey) + for (const item of virtualizer.getVirtualItems()) { + expect(item.key).toBe(messages[item.index]!.id) + } + } +}) + +test('followOnAppend keeps an end-pinned user at the end when items append', () => { + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const { setMessages, scrollToFn } = createChatVirtualizer({ + messages, + offset: 50, + followOnAppend: true, + }) + + setMessages([...messages, { id: 'm-5' }]) + + expect(scrollToFn).toHaveBeenCalledTimes(1) + const [offset, options] = scrollToFn.mock.calls[0]! + expect(offset).toBe(100) + expect(options.behavior).toBe('auto') +}) + +test('followOnAppend accepts smooth behavior', () => { + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const { setMessages, scrollToFn } = createChatVirtualizer({ + messages, + offset: 50, + followOnAppend: 'smooth', + }) + + setMessages([...messages, { id: 'm-5' }]) + + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![1].behavior).toBe('smooth') +}) + +test.each([ + { removed: 1, appended: 1, behavior: true as const }, + { removed: 1, appended: 2, behavior: true as const }, + { removed: 3, appended: 3, behavior: true as const }, + { removed: 3, appended: 1, behavior: true as const }, + { removed: 1, appended: 1, behavior: 'smooth' as const }, +])( + 'followOnAppend follows a sliding window removing $removed and appending $appended with $behavior', + ({ removed, appended, behavior }) => { + const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) + const { setMessages, scrollToFn } = createChatVirtualizer({ + messages, + offset: 200, + followOnAppend: behavior, + }) + const nextMessages = [ + ...messages.slice(removed), + ...Array.from({ length: appended }, (_, i) => ({ id: `m-${8 + i}` })), + ] + + setMessages(nextMessages) + + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(nextMessages.length * 50 - 200) + expect(scrollToFn.mock.calls[0]![1].behavior).toBe( + behavior === true ? 'auto' : behavior, + ) + }, +) + +test.each([ + { offset: 100, followOnAppend: true, threshold: 1, target: 50 }, + { offset: 200, followOnAppend: false, threshold: 1, target: 150 }, + { offset: 195, followOnAppend: true, threshold: 4, target: 145 }, + { offset: 196, followOnAppend: true, threshold: 4, target: 200 }, +])( + 'followOnAppend respects offset $offset, enabled $followOnAppend and threshold $threshold for a sliding window', + ({ offset, followOnAppend, threshold, target }) => { + const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) + const { setMessages, scrollToFn } = createChatVirtualizer({ + messages, + offset, + followOnAppend, + threshold, + }) + + setMessages([...messages.slice(1), { id: 'm-8' }]) + + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(target) + }, +) + +test('followOnAppend stays pinned across sliding updates without scroll events', () => { + const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer, setMessages, scrollToFn } = createChatVirtualizer({ + messages, + offset: 200, + followOnAppend: true, + }) + + // Equal-size append + trim leaves the DOM offset unchanged, so no scroll event fires. + setMessages([...messages.slice(1), { id: 'm-8' }]) + expect(virtualizer.isAtEnd()).toBe(true) + + setMessages([...messages.slice(2), { id: 'm-8' }, { id: 'm-9' }]) + expect(virtualizer.isAtEnd()).toBe(true) + expect(scrollToFn.mock.calls.at(-1)![0]).toBe(200) +}) + +test('followOnAppend stays pinned with a stable key callback', () => { + const messages = Array.from({ length: 20 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer, scrollToFn } = createChatVirtualizer({ + messages, + offset: 800, + followOnAppend: true, + }) + virtualizer.getVirtualItems() + + for (let step = 1; step <= 2; step++) { + scrollToFn.mockClear() + messages.splice(0, 1) + messages.push({ id: `m-${19 + step}` }) + virtualizer.setOptions(virtualizer.options) + virtualizer._willUpdate() + + expect(scrollToFn.mock.calls.at(-1)?.[0]).toBe(800) + // A no-op end scroll produces no browser scroll event. + expect(virtualizer.isAtEnd()).toBe(true) + virtualizer.getVirtualItems() + } +}) + +test.each([ + { name: 'replacement', ids: [8, 9, 10, 11, 12, 13, 14, 15], target: null }, + { name: 'reorder', ids: [1, 0, 2, 3, 4, 5, 7, 6], target: null }, + { name: 'rotation', ids: [1, 2, 3, 4, 5, 6, 7, 0], target: 150 }, + { name: 'rotation with append', ids: [2, 3, 4, 5, 6, 7, 0, 8], target: 100 }, + { name: 'reordered overlap', ids: [1, 3, 2, 4, 5, 6, 7, 8], target: 150 }, + { name: 'trim only', ids: [1, 2, 3, 4, 5, 6, 7], target: 150 }, + { + name: 'prepend and trim tail', + ids: [-1, 0, 1, 2, 3, 4, 5, 6], + target: 250, + }, +])('followOnAppend does not follow $name', ({ ids, target }) => { + const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) + const { setMessages, scrollToFn } = createChatVirtualizer({ + messages, + offset: 200, + followOnAppend: true, + }) + + setMessages(ids.map((id) => ({ id: `m-${id}` }))) + + if (target === null) { + expect(scrollToFn).not.toHaveBeenCalled() + } else { + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(target) + expect(scrollToFn.mock.calls[0]![1].behavior).toBeUndefined() + } +}) + +test('anchorTo:end keeps a pinned streaming message pinned as it grows', () => { + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer, scrollElement, scrollToFn } = createChatVirtualizer({ + messages, + offset: 50, + }) + + ;(scrollElement as any).scrollHeight = 320 + virtualizer.resizeItem(4, 120) + + expect(scrollToFn).toHaveBeenCalledTimes(1) + const [offset, options] = scrollToFn.mock.calls[0]! + expect(offset).toBe(50) + expect(options.adjustments).toBe(70) +}) + +test('anchorTo:end stays pinned across consecutive resizes when the scrollTop write is clamped', () => { + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer, scrollToFn } = createChatVirtualizer({ + messages, + offset: 50, + }) + + // First growth tick. The DOM `scrollTop` write may be clamped because the + // consumer hasn't grown the sizer yet (`notify()` runs after the + // adjustment in `resizeItem`), so no scroll event fires — `scrollToFn` + // here is a no-op mock, mirroring that. + virtualizer.resizeItem(4, 120) + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![1].adjustments).toBe(70) + scrollToFn.mockClear() + + // Second growth tick with no scroll event in between. Before the fix, + // `getVirtualDistanceFromEnd()` would compute against the stale + // `scrollOffset` (50) and a grown `getTotalSize()` (320), conclude we had + // drifted 70 px from the end (> `scrollEndThreshold: 1`), and skip the + // adjustment — drifting forever from tick 2 onward. + virtualizer.resizeItem(4, 200) + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(120) + expect(scrollToFn.mock.calls[0]![1].adjustments).toBe(80) +}) + +test('anchorTo:end does not follow streaming growth when user is away from end', () => { + const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer, scrollToFn } = createChatVirtualizer({ + messages, + offset: 50, + }) + + virtualizer.resizeItem(7, 120) + + expect(scrollToFn).not.toHaveBeenCalled() +}) + +test('isAtEnd and getDistanceFromEnd use the scroll element max offset', () => { + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer } = createChatVirtualizer({ + messages, + offset: 40, + threshold: 15, + }) + + expect(virtualizer.getDistanceFromEnd()).toBe(10) + expect(virtualizer.isAtEnd()).toBe(true) + expect(virtualizer.isAtEnd(5)).toBe(false) +}) + +// ─── #1229: negative tracked scrollOffset must not survive compensation ───── +// anchorTo: 'end' + element scrolling + items measuring smaller than their +// estimates + content shorter than the viewport. The end-anchor compensation +// applies a negative delta; the DOM clamps the scrollTop write to 0 and an +// unscrollable element never fires a scroll event, so an unclamped tracked +// offset would stay negative forever — phantom getDistanceFromEnd(), wedged +// _flushIosDeferredIfReady. + +const makeUnscrollableElement = () => { + // Content fits the viewport: the browser clamps scrollHeight to + // clientHeight, so maxScrollOffset = 0 and no scroll event can fire. + const el = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 400, + scrollHeight: 600, + clientWidth: 400, + clientHeight: 600, + scrollTo: ({ top }: { top: number }) => { + el.scrollTop = Math.max(0, Math.min(top, 0)) + }, + } + return el as unknown as HTMLDivElement +} + +const unscrollableOptions = ( + scrollElement: HTMLDivElement, + offsetCbRef: { + current: ((offset: number, isScrolling: boolean) => void) | null + }, +) => ({ + count: 5, + // Estimates larger than the real measured sizes + estimateSize: () => 100, + anchorTo: 'end' as const, + getScrollElement: () => scrollElement, + scrollToFn: ( + offset: number, + { + adjustments = 0, + behavior, + }: { adjustments?: number; behavior?: ScrollBehavior }, + instance: Virtualizer, + ) => { + instance.scrollElement?.scrollTo?.({ top: offset + adjustments, behavior }) + }, + observeElementRect: ( + _instance: unknown, + cb: (rect: { width: number; height: number }) => void, + ) => { + cb({ width: 400, height: 600 }) + return () => {} + }, + observeElementOffset: ( + _instance: unknown, + cb: (offset: number, isScrolling: boolean) => void, + ) => { + offsetCbRef.current = cb + cb(0, false) + return () => {} + }, +}) + +test('anchorTo end: shrink compensation clamps tracked scrollOffset at 0 when content fits the viewport (#1229)', () => { + const scrollElement = makeUnscrollableElement() + const offsetCbRef = { current: null as any } + const virtualizer = new Virtualizer( + unscrollableOptions(scrollElement, offsetCbRef), + ) + + virtualizer._willUpdate() + virtualizer.getVirtualItems() + + // Real sizes come in smaller than the estimates (98 vs 100) + for (let i = 0; i < 5; i++) { + virtualizer.resizeItem(i, 98) + } + + // The element is trivially at its end: 490px of content in a 600px + // viewport, pinned at scrollTop 0. + expect(scrollElement.scrollTop).toBe(0) + expect(virtualizer.scrollOffset).toBe(0) + expect(virtualizer.getDistanceFromEnd()).toBe(0) + expect(virtualizer.isAtEnd()).toBe(true) +}) + +test('anchorTo end: setOptions re-anchor clamps tracked scrollOffset at 0 (#1229)', () => { + const scrollElement = makeUnscrollableElement() + const offsetCbRef = { current: null as any } + const options = unscrollableOptions(scrollElement, offsetCbRef) + const virtualizer = new Virtualizer(options) + + virtualizer._willUpdate() + virtualizer.getVirtualItems() + + // Simulate a transiently negative offset reported by a real scroll event + // (elastic overscroll) landing right before an options update. + offsetCbRef.current!(-10, false) + expect(virtualizer.scrollOffset).toBe(-10) + + // Trim the last item: edge keys change, triggering the end-anchor + // re-resolution in setOptions. The anchor item (index 0) still starts at + // 0, so the unclamped offset would be written back as -10. + virtualizer.setOptions({ ...options, count: 4 }) + + expect(virtualizer.scrollOffset).toBe(0) +}) + +// ─── #1258: compensation write clamped by a not-yet-grown sizer ───────────── +// 5 items × 50px + paddingEnd 80 = 330px sizer, 200px viewport → the bottom is +// scrollTop 130. When the last item grows by 70, resizeItem writes 200, but +// the consumer has not committed the 400px sizer yet, so the browser clamps +// the write to the current max (130) — exactly paddingEnd short once the +// overflowing item alone has extended scrollHeight. The write must be +// re-issued once the sizer has grown. + +function clampedGrowthSetup() { + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const setup = createChatVirtualizer({ + messages, + offset: 130, + paddingEnd: 80, + threshold: 0, + }) + setup.virtualizer.resizeItem(4, 120) + // The end-anchor compensation fired against the not-yet-grown sizer. + expect(setup.scrollToFn).toHaveBeenCalledTimes(1) + expect(setup.scrollToFn.mock.calls[0]![1].adjustments).toBe(70) + return setup +} + +test('#1258: clamped end-anchor write is re-issued after the read-back once the sizer grows', () => { + const { virtualizer, scrollElement, scrollToFn, emitScroll } = + clampedGrowthSetup() + + // Browser read-back of the clamped write: it landed on the old max. + emitScroll(130) + // Consumer commits the grown sizer, then the layout effect runs. + ;(scrollElement as any).scrollHeight = 400 + virtualizer._willUpdate() + + expect(scrollToFn).toHaveBeenCalledTimes(2) + expect(scrollToFn.mock.calls[1]![0]).toBe(200) +}) + +test('#1258: clamped end-anchor write is re-issued in a sync render before the read-back', () => { + const { virtualizer, scrollElement, scrollToFn } = clampedGrowthSetup() + + // flushSync path: the layout effect runs before the scroll event arrives. + ;(scrollElement as any).scrollHeight = 400 + virtualizer._willUpdate() + + expect(scrollToFn).toHaveBeenCalledTimes(2) + expect(scrollToFn.mock.calls[1]![0]).toBe(200) +}) + +test('#1258: a render without sizer growth does not re-issue; a later one does', () => { + const { virtualizer, scrollElement, scrollToFn, emitScroll } = + clampedGrowthSetup() + + emitScroll(130) + virtualizer._willUpdate() + expect(scrollToFn).toHaveBeenCalledTimes(1) + ;(scrollElement as any).scrollHeight = 400 + virtualizer._willUpdate() + expect(scrollToFn).toHaveBeenCalledTimes(2) +}) + +test('#1258: a real gesture after the clamp cancels the pending re-issue', () => { + const { virtualizer, scrollElement, scrollToFn, emitScroll } = + clampedGrowthSetup() + + emitScroll(130) + // The user scrolls up to read history. + emitScroll(60, true) + ;(scrollElement as any).scrollHeight = 400 + virtualizer._willUpdate() + + expect(scrollToFn).toHaveBeenCalledTimes(1) +}) + +test('#1258: an unclamped compensation write is not re-issued when the sizer later grows', () => { + // 8 × 50 = 400px, viewport 200, user reading at 100. Item 0 (above the + // fold) re-measures +10 → compensation writes 110, well within max 200. + const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer, scrollElement, scrollToFn, emitScroll } = + createChatVirtualizer({ messages, offset: 100 }) + + virtualizer.resizeItem(0, 60) + expect(scrollToFn).toHaveBeenCalledTimes(1) + emitScroll(110) + ;(scrollElement as any).scrollHeight = 450 + virtualizer._willUpdate() + + expect(scrollToFn).toHaveBeenCalledTimes(1) +}) + +test('#1266: a consumer that grows the sizer synchronously in onChange gets the clamped write re-issued without a re-render', () => { + // Direct DOM updates: the adapter sets the container height inside + // onChange, and with an unchanged range nothing re-renders afterwards. + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer, scrollElement, scrollToFn } = createChatVirtualizer({ + messages, + offset: 130, + paddingEnd: 80, + threshold: 0, + }) + virtualizer.options.onChange = (instance) => { + ;(scrollElement as any).scrollHeight = instance.getTotalSize() + } + + virtualizer.resizeItem(4, 120) + + expect(scrollToFn).toHaveBeenCalledTimes(2) + expect(scrollToFn.mock.calls[0]![1].adjustments).toBe(70) + expect(scrollToFn.mock.calls[1]![0]).toBe(200) + expect(virtualizer['_clampedAdjustment']).toBeNull() +}) + +test('#1258: both retry sites firing in one sync pass write exactly once', () => { + // flushSync consumer: onChange grows the sizer AND runs the layout effect + // (_willUpdate) synchronously inside notify. The retry after notify must then + // find nothing pending — no double write. + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer, scrollElement, scrollToFn } = createChatVirtualizer({ + messages, + offset: 130, + paddingEnd: 80, + threshold: 0, + }) + virtualizer.options.onChange = (instance) => { + ;(scrollElement as any).scrollHeight = instance.getTotalSize() + instance._willUpdate() + } + + virtualizer.resizeItem(4, 120) + + expect(scrollToFn).toHaveBeenCalledTimes(2) + expect(scrollToFn.mock.calls[1]![0]).toBe(200) + expect(virtualizer['_clampedAdjustment']).toBeNull() +}) + +test('#1258: a partially grown sizer re-issues and stays pending until the target fits', () => { + const { virtualizer, scrollElement, scrollToFn, emitScroll } = + clampedGrowthSetup() + emitScroll(130) + + // Sizer grew only to 360 → max 160, still short of the 200 target. + ;(scrollElement as any).scrollHeight = 360 + virtualizer._willUpdate() + expect(scrollToFn).toHaveBeenCalledTimes(2) + expect(scrollToFn.mock.calls[1]![0]).toBe(200) + expect(virtualizer['_clampedAdjustment']).toEqual({ + target: 200, + maxAtWrite: 160, + }) + + // The browser clamps that write to the new max; its read-back keeps it pending. + emitScroll(160) + expect(virtualizer['_clampedAdjustment']).not.toBeNull() + ;(scrollElement as any).scrollHeight = 400 + virtualizer._willUpdate() + expect(scrollToFn).toHaveBeenCalledTimes(3) + expect(scrollToFn.mock.calls[2]![0]).toBe(200) + expect(virtualizer['_clampedAdjustment']).toBeNull() +}) + +test('#1258: cleanup drops a pending clamped write', () => { + const { virtualizer } = clampedGrowthSetup() + expect(virtualizer['_clampedAdjustment']).not.toBeNull() + + virtualizer['cleanup']() + + expect(virtualizer['_clampedAdjustment']).toBeNull() +}) diff --git a/packages/virtual-core/tests/dom-observers.test.ts b/packages/virtual-core/tests/dom-observers.test.ts new file mode 100644 index 000000000..8056d57a9 --- /dev/null +++ b/packages/virtual-core/tests/dom-observers.test.ts @@ -0,0 +1,337 @@ +import { expect, test, vi } from 'vitest' +import { + Virtualizer, + elementScroll, + observeElementOffset, + observeElementRect, + observeWindowOffset, + windowScroll, +} from '../src/index' + +// ─── elementScroll / windowScroll public exports ───────────────────────────── + +function makeBaseInstance(scrollEl: any, opts: any = {}) { + return { + scrollElement: scrollEl, + options: { + horizontal: false, + ...opts, + }, + } as any +} + +test('elementScroll: calls scrollTo with top + behavior on the scroll element', () => { + const scrollTo = vi.fn() + const scrollEl = { scrollTo } + elementScroll(100, { behavior: 'smooth' }, makeBaseInstance(scrollEl) as any) + expect(scrollTo).toHaveBeenCalledWith({ top: 100, behavior: 'smooth' }) +}) + +test('elementScroll: applies adjustments offset', () => { + const scrollTo = vi.fn() + const scrollEl = { scrollTo } + elementScroll( + 100, + { adjustments: 50, behavior: 'auto' }, + makeBaseInstance(scrollEl) as any, + ) + expect(scrollTo).toHaveBeenCalledWith({ top: 150, behavior: 'auto' }) +}) + +test('elementScroll: uses left when horizontal is true', () => { + const scrollTo = vi.fn() + const scrollEl = { scrollTo } + elementScroll( + 100, + { behavior: 'auto' }, + makeBaseInstance(scrollEl, { horizontal: true }) as any, + ) + expect(scrollTo).toHaveBeenCalledWith({ left: 100, behavior: 'auto' }) +}) + +test('windowScroll: calls scrollTo with top + behavior on the window', () => { + const scrollTo = vi.fn() + const win = { scrollTo } + windowScroll(250, { behavior: 'smooth' }, makeBaseInstance(win) as any) + expect(scrollTo).toHaveBeenCalledWith({ top: 250, behavior: 'smooth' }) +}) + +test('windowScroll: applies adjustments + horizontal', () => { + const scrollTo = vi.fn() + const win = { scrollTo } + windowScroll( + 250, + { adjustments: -10, behavior: 'auto' }, + makeBaseInstance(win, { horizontal: true }) as any, + ) + expect(scrollTo).toHaveBeenCalledWith({ left: 240, behavior: 'auto' }) +}) + +test('elementScroll / windowScroll: no-op when scrollElement is null', () => { + expect(() => + elementScroll(100, {}, makeBaseInstance(null) as any), + ).not.toThrow() + expect(() => + windowScroll(100, {}, makeBaseInstance(null) as any), + ).not.toThrow() +}) + +// ─── observeElementOffset / observeWindowOffset ────────────────────────────── + +function makeObserveInstance( + element: any, + opts: { + horizontal?: boolean + isRtl?: boolean + useScrollendEvent?: boolean + isScrollingResetDelay?: number + } = {}, + targetWindow: any = { + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + }, +) { + return { + scrollElement: element, + targetWindow, + options: { + horizontal: false, + isRtl: false, + useScrollendEvent: false, + isScrollingResetDelay: 150, + ...opts, + }, + } as any +} + +test('observeElementOffset: returns undefined when scrollElement is null', () => { + const cb = vi.fn() + expect( + observeElementOffset(makeObserveInstance(null) as any, cb), + ).toBeUndefined() + expect(cb).not.toHaveBeenCalled() +}) + +test('observeElementOffset: attaches scroll listener and fires callback with scrollTop', () => { + const cb = vi.fn() + const listeners = new Map() + const el: any = { + scrollTop: 50, + scrollLeft: 0, + addEventListener: (name: string, fn: any) => listeners.set(name, fn), + removeEventListener: (name: string) => listeners.delete(name), + } + const cleanup = observeElementOffset(makeObserveInstance(el) as any, cb) + expect(listeners.has('scroll')).toBe(true) + // No scrollend listener by default + expect(listeners.has('scrollend')).toBe(false) + // Trigger scroll + listeners.get('scroll')!({} as Event) + expect(cb).toHaveBeenCalledWith(50, true) + cleanup?.() + expect(listeners.has('scroll')).toBe(false) +}) + +test('observeElementOffset: fallback reads offset after programmatic changes', () => { + vi.useFakeTimers() + try { + const cb = vi.fn() + const listeners = new Map() + const el: any = { + scrollTop: 50, + scrollLeft: 0, + addEventListener: (name: string, fn: any) => listeners.set(name, fn), + removeEventListener: (name: string) => listeners.delete(name), + } + observeElementOffset(makeObserveInstance(el) as any, cb) + + listeners.get('scroll')!({} as Event) + el.scrollTop = 125 + vi.advanceTimersByTime(150) + + expect(cb).toHaveBeenLastCalledWith(125, false) + } finally { + vi.useRealTimers() + } +}) + +// ─── cleanup resets the scroll flags ───────────────────────────────────────── +// The cancelled debounce is the only writer of `isScrolling = false`, and +// `cleanup()` also runs while the instance stays alive (element swap, +// `enabled: false`), so it has to reset the flags itself. + +const makeScrollFlagsVirtualizer = () => { + const MockResizeObserver = vi.fn(function () { + return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() } + }) + const mockWindow = { + requestAnimationFrame: vi.fn(), + cancelAnimationFrame: vi.fn(), + ResizeObserver: MockResizeObserver, + } + const makeElement = () => + ({ + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 1000, + scrollHeight: 5000, + offsetWidth: 400, + offsetHeight: 600, + ownerDocument: { defaultView: mockWindow }, + }) as unknown as HTMLDivElement + + const first = makeElement() + const second = makeElement() + let element: HTMLDivElement | null = first + let emit: ((offset: number, isScrolling: boolean) => void) | null = null + + const virtualizer = new Virtualizer({ + count: 100, + estimateSize: () => 50, + getScrollElement: () => element, + scrollToFn: vi.fn(), + observeElementRect: (_instance, cb) => { + cb({ width: 400, height: 600 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + emit = cb + return () => {} + }, + }) + + virtualizer._willUpdate() + + // Mid-scroll: this is the state the debounce used to clear on its own. + emit!(500, true) + + return { + virtualizer, + swapElement: () => { + element = second + virtualizer._willUpdate() + }, + disable: () => { + element = null + virtualizer._willUpdate() + }, + } +} + +test('cleanup resets the scroll flags when the scroll element is swapped', () => { + const { virtualizer, swapElement } = makeScrollFlagsVirtualizer() + + expect(virtualizer.isScrolling).toBe(true) + + swapElement() + + expect(virtualizer.isScrolling).toBe(false) + expect(virtualizer.scrollDirection).toBe(null) +}) + +test('cleanup resets the scroll flags when the scroll element goes away', () => { + const { virtualizer, disable } = makeScrollFlagsVirtualizer() + + expect(virtualizer.isScrolling).toBe(true) + + disable() + + expect(virtualizer.isScrolling).toBe(false) + expect(virtualizer.scrollDirection).toBe(null) +}) + +test('cleanup resets the scroll flags on unmount', () => { + const { virtualizer } = makeScrollFlagsVirtualizer() + + expect(virtualizer.isScrolling).toBe(true) + + virtualizer._didMount()() + + expect(virtualizer.isScrolling).toBe(false) + expect(virtualizer.scrollDirection).toBe(null) +}) + +test('observeElementOffset: cleanup drops the queued isScrolling reset', () => { + vi.useFakeTimers() + try { + const cb = vi.fn() + const listeners = new Map() + const el: any = { + scrollTop: 50, + scrollLeft: 0, + addEventListener: (name: string, fn: any) => listeners.set(name, fn), + removeEventListener: (name: string) => listeners.delete(name), + } + const cleanup = observeElementOffset(makeObserveInstance(el) as any, cb) + + // Each scroll arms a debounce that resets isScrolling to false. + listeners.get('scroll')!({} as Event) + expect(cb).toHaveBeenCalledWith(50, true) + cb.mockClear() + + // Tearing down inside that window must not leave the reset queued — + // it would arrive after the consumer stopped listening. + cleanup?.() + vi.advanceTimersByTime(1000) + + expect(cb).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } +}) + +test('observeElementOffset: reads scrollLeft + applies isRtl when horizontal', () => { + const cb = vi.fn() + const listeners = new Map() + const el: any = { + scrollTop: 0, + scrollLeft: 80, + addEventListener: (name: string, fn: any) => listeners.set(name, fn), + removeEventListener: (name: string) => listeners.delete(name), + } + observeElementOffset( + makeObserveInstance(el, { horizontal: true, isRtl: true }) as any, + cb, + ) + listeners.get('scroll')!({} as Event) + // isRtl flips sign + expect(cb).toHaveBeenCalledWith(-80, true) +}) + +test('observeWindowOffset: returns undefined when scrollElement is null', () => { + const cb = vi.fn() + expect( + observeWindowOffset(makeObserveInstance(null) as any, cb), + ).toBeUndefined() +}) + +test('observeWindowOffset: attaches scroll listener and fires callback with scrollY', () => { + const cb = vi.fn() + const listeners = new Map() + const win: any = { + scrollX: 0, + scrollY: 120, + addEventListener: (name: string, fn: any) => listeners.set(name, fn), + removeEventListener: (name: string) => listeners.delete(name), + } + const cleanup = observeWindowOffset(makeObserveInstance(win) as any, cb) + expect(listeners.has('scroll')).toBe(true) + listeners.get('scroll')!({} as Event) + expect(cb).toHaveBeenCalledWith(120, true) + cleanup?.() + expect(listeners.has('scroll')).toBe(false) +}) + +test('observeWindowOffset: reads scrollX when horizontal', () => { + const cb = vi.fn() + const listeners = new Map() + const win: any = { + scrollX: 75, + scrollY: 0, + addEventListener: (name: string, fn: any) => listeners.set(name, fn), + removeEventListener: (name: string) => listeners.delete(name), + } + observeWindowOffset(makeObserveInstance(win, { horizontal: true }) as any, cb) + listeners.get('scroll')!({} as Event) + expect(cb).toHaveBeenCalledWith(75, true) +}) diff --git a/packages/virtual-core/tests/helpers.ts b/packages/virtual-core/tests/helpers.ts new file mode 100644 index 000000000..bde7a7638 --- /dev/null +++ b/packages/virtual-core/tests/helpers.ts @@ -0,0 +1,142 @@ +import { + Virtualizer, + _resetIOSDetectionForTests, + observeElementOffset, + observeElementRect, +} from '../src/index' + +export function withFakeIOSUserAgent(fn: () => T, ios27 = false): T { + // jsdom navigator.userAgent lives on the prototype; we set an own property + // to shadow it, then remove the own property in finally so the prototype + // value is visible again for subsequent tests. + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)', + configurable: true, + }) + // iOS 27 is told apart by `overflow-anchor` support; jsdom has no `CSS`. + if (ios27) { + ;(globalThis as any).CSS = { + supports: (query: string) => query === 'overflow-anchor: auto', + } + } + _resetIOSDetectionForTests() + try { + return fn() + } finally { + delete (navigator as any).userAgent + delete (globalThis as any).CSS + _resetIOSDetectionForTests() + } +} + +export function makeDeferralHarness({ + ios = true, + ios27 = false, + start = 0, +}: { ios?: boolean; ios27?: boolean; start?: number } = {}) { + const rafCallbacks: Array = [] + let now = 0 + const timers = new Map void>() + let timerId = 0 + const mockWindow = { + requestAnimationFrame: (cb: FrameRequestCallback) => rafCallbacks.push(cb), + cancelAnimationFrame: () => { + rafCallbacks.length = 0 + }, + setTimeout: (fn: () => void) => { + timers.set(++timerId, fn) + return timerId + }, + clearTimeout: (id: number) => timers.delete(id), + performance: { now: () => now }, + } + const el = makeMockScrollElement({ + scrollTop: start, + scrollLeft: 0, + scrollHeight: 5000, + clientHeight: 200, + offsetHeight: 200, + ownerDocument: { defaultView: mockWindow }, + }) + let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = + null + const run = (fn: () => T) => (ios ? withFakeIOSUserAgent(fn, ios27) : fn()) + const writes: Array = [] + // Like iOS under a dragging finger: drop programmatic writes on the floor. + const browser = { ignoreWrites: false } + const v = run(() => { + const instance = new Virtualizer({ + count: 100, + estimateSize: () => 50, + initialOffset: start, + getScrollElement: () => el as any, + scrollToFn: (offset, { adjustments = 0 }) => { + writes.push(offset + adjustments) + if (browser.ignoreWrites) return + el.scrollTop = offset + adjustments + }, + observeElementRect: (_inst, cb) => { + cb({ width: 400, height: 200 }) + return () => {} + }, + observeElementOffset: (_inst, cb) => { + scrollCallback = cb + cb(start, false) + return () => {} + }, + }) + instance._willUpdate() + instance['getMeasurements']() + return instance + }) + return { + v, + el, + run, + writes, + browser, + // A scroll event; moves the element first when `offset` is given. + scroll: (isScrolling: boolean, offset = el.scrollTop) => { + el.scrollTop = offset + scrollCallback!(offset, isScrolling) + }, + frame: () => rafCallbacks.splice(0).forEach((cb) => cb(0)), + advance: (ms: number) => { + now += ms + }, + touch: (type: 'touchstart' | 'touchend') => run(() => el._dispatch(type)), + // The post-touchend tail times out: the gesture has settled. + expireTail: () => + run(() => { + const fns = [...timers.values()] + timers.clear() + fns.forEach((fn) => fn()) + }), + // Where a row sits relative to the top of the viewport. + rowTop: (index: number) => + v['getMeasurements']()[index]!.start - el.scrollTop, + } +} + +// Mock EventTarget that records listeners so tests can dispatch events +// without requiring a real DOM. Works in any environment, jsdom or not. +export function makeMockScrollElement(props: Record) { + const listeners = new Map void>>() + return { + ...props, + addEventListener(name: string, fn: (e: Event) => void) { + let s = listeners.get(name) + if (!s) listeners.set(name, (s = new Set())) + s.add(fn) + }, + removeEventListener(name: string, fn: (e: Event) => void) { + listeners.get(name)?.delete(fn) + }, + _dispatch(name: string) { + listeners.get(name)?.forEach((fn) => fn({} as Event)) + }, + _hasListener(name: string) { + return (listeners.get(name)?.size ?? 0) > 0 + }, + } as any +} diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 615778d9b..7e609fd27 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -1,16 +1,9 @@ -import { describe, expect, test, vi } from 'vitest' +import { expect, test, vi } from 'vitest' import { Virtualizer, - _resetIOSDetectionForTests, - defaultRangeExtractor, - elementScroll, observeElementOffset, observeElementRect, - observeWindowOffset, - windowScroll, } from '../src/index' -import flings from './fixtures/ios27-flings.json' -import type { VirtualizerOptions } from '../src/index' test('should export the Virtualizer class', () => { expect(Virtualizer).toBeDefined() @@ -170,84 +163,6 @@ test('should update getTotalSize() when count option changes (filtering/search)' expect(virtualizer.getTotalSize()).toBe(5000) // 100 × 50 }) -test('should not throw when component unmounts during scrollToIndex rAF loop', () => { - // Collect rAF callbacks so we can flush them manually - const rafCallbacks: Array = [] - const mockRaf = vi.fn((cb: FrameRequestCallback) => { - rafCallbacks.push(cb) - return rafCallbacks.length - }) - - const MockResizeObserver = vi.fn(function () { - return { - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), - } - }) - - const mockWindow = { - requestAnimationFrame: mockRaf, - cancelAnimationFrame: vi.fn(), - ResizeObserver: MockResizeObserver, - } - - const mockScrollElement = { - scrollTop: 0, - scrollLeft: 0, - scrollWidth: 1000, - scrollHeight: 5000, - offsetWidth: 400, - offsetHeight: 600, - ownerDocument: { - defaultView: mockWindow, - }, - } as unknown as HTMLDivElement - - const virtualizer = new Virtualizer({ - count: 100, - estimateSize: () => 50, - measureElement: (el) => el.getBoundingClientRect().height, - getScrollElement: () => mockScrollElement, - scrollToFn: vi.fn(), - observeElementRect: (instance, cb) => { - cb({ width: 400, height: 600 }) - return () => {} - }, - observeElementOffset: (instance, cb) => { - cb(0, false) - return () => {} - }, - }) - - // Initialize the virtualizer so targetWindow is set - virtualizer._willUpdate() - - // Populate elementsCache so isDynamicMode() returns true. - // This triggers the code path where the rAF callback calls - // this.targetWindow!.requestAnimationFrame(verify) - const mockElement = { - getBoundingClientRect: () => ({ height: 50 }), - isConnected: true, - setAttribute: vi.fn(), - } as unknown as HTMLElement - virtualizer.elementsCache.set(0, mockElement) - - // Trigger scrollToIndex which schedules a rAF callback - virtualizer.scrollToIndex(50) - - // Simulate component unmount — cleanup sets targetWindow to null - const unmount = virtualizer._didMount() - unmount() - - // Flush all pending rAF callbacks — this should not throw - // Without the fix, this crashes with: - // "Cannot read properties of null (reading 'requestAnimationFrame')" - expect(() => { - rafCallbacks.forEach((cb) => cb(0)) - }).not.toThrow() -}) - test("should defer lane caching until measurement when laneAssignmentMode is 'measured'", () => { const virtualizer = new Virtualizer({ count: 4, @@ -339,622 +254,105 @@ test("should cache lanes immediately when laneAssignmentMode is 'estimate' (defa expect(virtualizer['laneAssignments'].size).toBe(4) }) -function createMockEnvironment() { - const rafCallbacks: Array = [] - let rafIdCounter = 0 - const mockRaf = vi.fn((cb: FrameRequestCallback) => { - rafCallbacks.push(cb) - return ++rafIdCounter - }) - const mockCancelRaf = vi.fn() - - const MockResizeObserver = vi.fn(function () { - return { - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), - } - }) - - const mockWindow = { - requestAnimationFrame: mockRaf, - cancelAnimationFrame: mockCancelRaf, - performance: { now: () => Date.now() }, - ResizeObserver: MockResizeObserver, - } - - const mockScrollElement = { - scrollTop: 0, - scrollLeft: 0, - scrollWidth: 1000, - scrollHeight: 5000, - clientWidth: 400, - clientHeight: 600, - offsetWidth: 400, - offsetHeight: 600, - ownerDocument: { - defaultView: mockWindow, - }, - scrollTo: vi.fn(), - } as unknown as HTMLDivElement - - const scrollToFn = vi.fn() - - return { rafCallbacks, mockWindow, mockScrollElement, scrollToFn } -} +// ─── setOptions behavioral contract ────────────────────────────────────────── +// These tests pin down how setOptions merges defaults with user-supplied opts. +// They guard against regressions when changing the merge mechanism +// (currently: mutate opts + spread with defaults; will become: copy-without-undefined). -function createVirtualizer( - mockScrollElement: HTMLDivElement, - scrollToFn: ReturnType, -) { - return new Virtualizer({ - count: 100, +test('setOptions: undefined values should fall back to defaults', () => { + const virtualizer = new Virtualizer({ + count: 10, estimateSize: () => 50, - getScrollElement: () => mockScrollElement, - scrollToFn, - observeElementRect: (_instance, cb) => { - cb({ width: 400, height: 600 }) - return () => {} - }, - observeElementOffset: (_instance, cb) => { - cb(0, false) - return () => {} - }, - }) -} - -test('scrollToIndex(0) should reconcile correctly', () => { - const { rafCallbacks, mockScrollElement, scrollToFn } = - createMockEnvironment() - const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) - - virtualizer._willUpdate() - scrollToFn.mockClear() - - virtualizer.scrollToIndex(0) - - // scrollToFn should have been called with offset for index 0 - expect(scrollToFn).toHaveBeenCalled() - const calledOffset = scrollToFn.mock.calls[0]![0] - expect(calledOffset).toBe(0) - - // The first frame leaves room for pending ResizeObserver measurements. - rafCallbacks.shift()!(0) - expect(virtualizer['scrollState']).not.toBeNull() - rafCallbacks.shift()!(0) - - // scrollState should be cleared after settling - expect(virtualizer['scrollState']).toBeNull() -}) - -test.each([false, true])( - 'scrollToIndex waits for pending measurements (deferred observer: %s)', - (useAnimationFrameWithResizeObserver) => { - const { rafCallbacks, mockScrollElement, scrollToFn } = - createMockEnvironment() - const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) - virtualizer.setOptions({ - ...virtualizer.options, - useAnimationFrameWithResizeObserver, - }) - virtualizer._willUpdate() - virtualizer.getVirtualItems() - virtualizer.resizeItem(98, 60) - virtualizer.getVirtualItems() - virtualizer.scrollOffset = 4400 - scrollToFn.mockClear() - - virtualizer.scrollToIndex(99, { align: 'end' }) - expect(scrollToFn).toHaveBeenLastCalledWith( - 4400, - expect.anything(), - virtualizer, - ) - - // rAF runs before ResizeObserver. An optional rAF wrapper defers the - // measurement until after the next reconciliation callback as well. - rafCallbacks.shift()!(0) - if (useAnimationFrameWithResizeObserver) { - rafCallbacks.shift()?.(0) - } - virtualizer.resizeItem(98, 90) - Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 5030 }) - virtualizer.getVirtualItems() - rafCallbacks.shift()?.(0) - - expect(scrollToFn).toHaveBeenLastCalledWith( - 4430, - expect.anything(), - virtualizer, - ) - - virtualizer.scrollOffset = 4430 - // Reconciliation must still retire once the new target is stable. - for (let frame = 0; frame < 3; frame++) { - rafCallbacks.shift()?.(0) - } - expect(virtualizer['scrollState']).toBeNull() - expect(rafCallbacks).toHaveLength(0) - }, -) - -test.each([false, true])( - 'a new scroll away from a reached index target cancels reconciliation (retargeted: %s)', - (retargeted) => { - const { rafCallbacks, mockScrollElement, scrollToFn } = - createMockEnvironment() - const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) - let onScroll!: (offset: number, isScrolling: boolean) => void - virtualizer.setOptions({ - ...virtualizer.options, - observeElementOffset: (_instance, callback) => { - onScroll = callback - callback(0, false) - return () => {} - }, - }) - virtualizer._willUpdate() - virtualizer.getVirtualItems() - virtualizer.scrollOffset = 4400 - scrollToFn.mockClear() - - virtualizer.scrollToIndex(99, { align: 'end' }) - rafCallbacks.shift()!(0) - if (retargeted) { - virtualizer.resizeItem(97, 80) - Object.defineProperty(mockScrollElement, 'scrollHeight', { - value: 5030, - configurable: true, - }) - virtualizer.getVirtualItems() - rafCallbacks.shift()!(0) - // The target changed before its write produced a scroll event. Arrival - // at the previous target must still let an external scroll cancel it. - } - const writes = scrollToFn.mock.calls.length - onScroll(2000, true) - - // Measurements must not pull a reader back to the old target after they leave it. - virtualizer.resizeItem(98, 80) - Object.defineProperty(mockScrollElement, 'scrollHeight', { - value: retargeted ? 5060 : 5030, - }) - virtualizer.getVirtualItems() - rafCallbacks.shift()!(0) - - expect(virtualizer['scrollState']).toBeNull() - expect(virtualizer.scrollOffset).toBe(2000) - expect(scrollToFn).toHaveBeenCalledTimes(writes) - }, -) - -test.each(['auto', 'smooth'] as const)( - 'keeps an index scroll active before reaching its target (%s)', - (behavior) => { - const { mockScrollElement, scrollToFn } = createMockEnvironment() - const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) - let onScroll!: (offset: number, isScrolling: boolean) => void - virtualizer.setOptions({ - ...virtualizer.options, - observeElementOffset: (_instance, callback) => { - onScroll = callback - callback(0, false) - return () => {} - }, - }) - virtualizer._willUpdate() - virtualizer.getVirtualItems() - virtualizer.scrollToIndex(99, { align: 'end', behavior }) - // The scroll max shrinks to 2000 before the read-back, so the browser - // reports 2000 instead of the 4400 we wrote. That is where the fresh - // target clamps too, so it isn't mistaken for the user scrolling away. - Object.defineProperty(mockScrollElement, 'scrollHeight', { - value: 2600, - configurable: true, - }) - onScroll(2000, true) - - expect(virtualizer['scrollState']).not.toBeNull() - }, -) - -test.each([30, 3000])( - 'measurement compensation does not cancel a reached index target (growth: %s)', - (growth) => { - const { rafCallbacks, mockScrollElement, scrollToFn } = - createMockEnvironment() - scrollToFn.mockImplementation((offset, { adjustments }) => { - mockScrollElement.scrollTop = Math.min(4400, offset + (adjustments ?? 0)) - }) - const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) - let onScroll!: (offset: number, isScrolling: boolean) => void - virtualizer.setOptions({ - ...virtualizer.options, - observeElementOffset: (_instance, callback) => { - onScroll = callback - callback(0, false) - return () => {} - }, - }) - virtualizer._willUpdate() - virtualizer.getVirtualItems() - virtualizer.scrollOffset = 2500 - virtualizer.scrollToIndex(50, { align: 'start' }) - rafCallbacks.shift()!(0) - - // The browser may clamp this adjustment until the consumer grows the sizer. - virtualizer.resizeItem(0, 50 + growth) - onScroll(mockScrollElement.scrollTop, true) - - expect(virtualizer['scrollState']).not.toBeNull() - }, -) - -test('a browser clamp to the new max does not cancel a reached scrollToEnd', () => { - const { rafCallbacks, mockScrollElement, scrollToFn } = - createMockEnvironment() - const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) - let onScroll!: (offset: number, isScrolling: boolean) => void - virtualizer.setOptions({ - ...virtualizer.options, - paddingEnd: 40, - observeElementOffset: (_instance, callback) => { - onScroll = callback - callback(0, false) - return () => {} - }, - }) - Object.defineProperty(mockScrollElement, 'scrollHeight', { - value: 5040, - configurable: true, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + paddingStart: 100, }) - virtualizer._willUpdate() - virtualizer.getVirtualItems() - virtualizer.scrollOffset = 4440 - - // Already at the end, so the target counts as reached straight away. - virtualizer.scrollToEnd() - onScroll(4440, false) - // The last row shrinks and the browser clamps scrollTop to the new max. - // That is the scrollToEnd target (paddingEnd included), not a scroll away. - virtualizer.resizeItem(99, 20) - Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 5010 }) - onScroll(4410, false) - - expect(virtualizer['scrollState']).not.toBeNull() - rafCallbacks.shift()!(0) - expect(virtualizer['scrollState']?.lastTargetOffset).toBe(4410) -}) + // First confirm explicit value sticks + expect(virtualizer.options.paddingStart).toBe(100) -test('a smooth retarget is not cancelled by its own intermediate scroll events', () => { - const { rafCallbacks, mockScrollElement, scrollToFn } = - createMockEnvironment() - const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) - let onScroll!: (offset: number, isScrolling: boolean) => void + // Now setOptions with paddingStart: undefined → should fall back to default (0) virtualizer.setOptions({ - ...virtualizer.options, - observeElementOffset: (_instance, callback) => { - onScroll = callback - callback(0, false) - return () => {} - }, - }) - virtualizer._willUpdate() - virtualizer.getVirtualItems() - virtualizer.scrollOffset = 4400 - - // Already at the end, so the target counts as reached straight away. - virtualizer.scrollToIndex(99, { align: 'end', behavior: 'smooth' }) - rafCallbacks.shift()!(0) - - // A visible row grows by more than a viewport, so the retarget stays smooth. - virtualizer.resizeItem(98, 1050) - Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 6000 }) - virtualizer.getVirtualItems() - rafCallbacks.shift()!(0) - expect(scrollToFn).toHaveBeenLastCalledWith( - 5400, - { adjustments: undefined, behavior: 'smooth' }, - virtualizer, - ) - - // The smooth scroll reports a position on its way to the new target. - onScroll(4800, true) - - expect(virtualizer['scrollState']).not.toBeNull() -}) - -test('scrollToOffset should reconcile and clear scrollState', () => { - const { rafCallbacks, mockScrollElement, scrollToFn } = - createMockEnvironment() - const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) - - virtualizer._willUpdate() - scrollToFn.mockClear() - - virtualizer.scrollToOffset(200) - - expect(scrollToFn).toHaveBeenCalled() - - // scrollState should be set with index: null - expect(virtualizer['scrollState']).not.toBeNull() - expect(virtualizer['scrollState']!.index).toBeNull() - - // Simulate the scroll offset reaching the target - virtualizer.scrollOffset = 200 - - // Flush rAF — reconciliation should settle and clear scrollState - rafCallbacks.forEach((cb) => cb(0)) - - expect(virtualizer['scrollState']).toBeNull() -}) - -test('scrollBy should reconcile and clear scrollState', () => { - const { rafCallbacks, mockScrollElement, scrollToFn } = - createMockEnvironment() - const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) - - virtualizer._willUpdate() - scrollToFn.mockClear() - - virtualizer.scrollBy(100) - - expect(virtualizer['scrollState']).not.toBeNull() - expect(virtualizer['scrollState']!.index).toBeNull() - - // Simulate scroll offset reaching the target - virtualizer.scrollOffset = 100 - - rafCallbacks.forEach((cb) => cb(0)) - - expect(virtualizer['scrollState']).toBeNull() -}) - -test('reconcileScroll should bail out after timeout', () => { - const { rafCallbacks, mockWindow, mockScrollElement, scrollToFn } = - createMockEnvironment() - - // Make performance.now() return a controllable value - let fakeTime = 1000 - mockWindow.performance.now = () => fakeTime - - const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) - virtualizer._willUpdate() - - virtualizer.scrollToIndex(50) - - expect(virtualizer['scrollState']).not.toBeNull() - - // Advance time past the 5s safety valve - fakeTime = 7000 - - // Flush rAF — should trigger timeout bailout - rafCallbacks.forEach((cb) => cb(0)) - - expect(virtualizer['scrollState']).toBeNull() -}) - -test('cleanup should cancel pending RAF and clear scrollState', () => { - const { mockWindow, mockScrollElement, scrollToFn } = createMockEnvironment() - const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) - - virtualizer._willUpdate() - virtualizer.scrollToIndex(50) - - expect(virtualizer['scrollState']).not.toBeNull() - expect(virtualizer['rafId']).not.toBeNull() - - const unmount = virtualizer._didMount() - unmount() - - expect(virtualizer['scrollState']).toBeNull() - expect(virtualizer['rafId']).toBeNull() - expect(mockWindow.cancelAnimationFrame).toHaveBeenCalled() -}) - -test('cancelScroll stops reconciling toward the old target (#1285)', () => { - const { rafCallbacks, mockWindow, mockScrollElement, scrollToFn } = - createMockEnvironment() - const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) - - virtualizer._willUpdate() - virtualizer.scrollToIndex(50, { align: 'start' }) - - expect(virtualizer['scrollState']).not.toBeNull() - expect(virtualizer['rafId']).not.toBeNull() - - // The user takes over mid-travel. - virtualizer.cancelScroll() - - expect(virtualizer['scrollState']).toBeNull() - expect(virtualizer['rafId']).toBeNull() - expect(mockWindow.cancelAnimationFrame).toHaveBeenCalled() - - // A measurement above the old target would move it; without the cancel, - // reconcile chases the new target and yanks the viewport back. - scrollToFn.mockClear() - virtualizer.resizeItem(10, 200) - rafCallbacks.forEach((cb) => cb(0)) - - expect(scrollToFn).not.toHaveBeenCalled() -}) - -test('cancelScroll restores size-change compensation after a smooth scroll', () => { - const { mockScrollElement, scrollToFn } = createMockEnvironment() - const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) - - virtualizer._willUpdate() - virtualizer.scrollOffset = 1000 - virtualizer.scrollToIndex(50, { behavior: 'smooth' }) - virtualizer.cancelScroll() - scrollToFn.mockClear() - - // First measurement of an item above the fold is compensated again; while - // the smooth scrollState was active it would have been skipped. - virtualizer.resizeItem(0, 80) - - expect(scrollToFn).toHaveBeenCalledWith( - 1000, - expect.objectContaining({ adjustments: 30 }), - virtualizer, - ) -}) - -test('cancelScroll is a no-op when no scroll is in flight', () => { - const { rafCallbacks, mockScrollElement, scrollToFn } = - createMockEnvironment() - const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) - - virtualizer._willUpdate() - scrollToFn.mockClear() - - expect(() => virtualizer.cancelScroll()).not.toThrow() - expect(scrollToFn).not.toHaveBeenCalled() - - // A later scroll command still works normally. - virtualizer.scrollToOffset(200) - expect(virtualizer['scrollState']).not.toBeNull() - virtualizer.scrollOffset = 200 - rafCallbacks.forEach((cb) => cb(0)) - expect(virtualizer['scrollState']).toBeNull() -}) - -// ─── resizeItem / measurement cache invalidation ───────────────────────────── -// These tests pin down the contract that resizeItem invalidates the -// getMeasurements memo so subsequent reads reflect the new sizes. -// They guard against regressions when changing the invalidation mechanism -// (e.g. Map clone → version counter). - -test('resizeItem should persist size for a single index', () => { - const virtualizer = new Virtualizer({ - count: 5, + count: 10, estimateSize: () => 50, getScrollElement: () => null, scrollToFn: vi.fn(), observeElementRect: vi.fn(), observeElementOffset: vi.fn(), + paddingStart: undefined as any, }) - // Seed measurementsCache - virtualizer['getMeasurements']() - - virtualizer.resizeItem(2, 130) - - const measurements = virtualizer['getMeasurements']() - expect(measurements[2]!.size).toBe(130) - // Items after should be shifted by the delta (130 - 50 = 80) - expect(measurements[3]!.start).toBe(50 + 50 + 130) - expect(measurements[4]!.start).toBe(50 + 50 + 130 + 50) + expect(virtualizer.options.paddingStart).toBe(0) }) -test('resizeItem should persist sizes across many sequential calls', () => { - const N = 50 +test('setOptions: missing keys should fall back to defaults', () => { const virtualizer = new Virtualizer({ - count: N, - estimateSize: () => 10, + count: 10, + estimateSize: () => 50, getScrollElement: () => null, scrollToFn: vi.fn(), observeElementRect: vi.fn(), observeElementOffset: vi.fn(), }) - virtualizer['getMeasurements']() - - // Resize every item to a unique size - for (let i = 0; i < N; i++) { - virtualizer.resizeItem(i, 100 + i) - } - - const measurements = virtualizer['getMeasurements']() - let runningStart = 0 - for (let i = 0; i < N; i++) { - expect(measurements[i]!.size).toBe(100 + i) - expect(measurements[i]!.start).toBe(runningStart) - runningStart += 100 + i - } + // Defaults should apply for all unset options + expect(virtualizer.options.paddingStart).toBe(0) + expect(virtualizer.options.paddingEnd).toBe(0) + expect(virtualizer.options.overscan).toBe(1) + expect(virtualizer.options.horizontal).toBe(false) + expect(virtualizer.options.gap).toBe(0) + expect(virtualizer.options.lanes).toBe(1) + expect(virtualizer.options.enabled).toBe(true) }) -test('resizeItem should invalidate getMeasurements memo even when same key resized twice', () => { +test('setOptions: explicit falsy values (0, false) should NOT fall back to defaults', () => { const virtualizer = new Virtualizer({ - count: 3, + count: 10, estimateSize: () => 50, getScrollElement: () => null, scrollToFn: vi.fn(), observeElementRect: vi.fn(), observeElementOffset: vi.fn(), + paddingStart: 50, + overscan: 3, + enabled: true, }) - virtualizer['getMeasurements']() - - virtualizer.resizeItem(1, 100) - expect(virtualizer['getMeasurements']()[1]!.size).toBe(100) - - virtualizer.resizeItem(1, 200) - expect(virtualizer['getMeasurements']()[1]!.size).toBe(200) - - virtualizer.resizeItem(1, 75) - expect(virtualizer['getMeasurements']()[1]!.size).toBe(75) -}) - -test('resizeItem with same size as cached should be a no-op (no invalidation)', () => { - const virtualizer = new Virtualizer({ - count: 3, + // Now set them all to explicit falsy values + virtualizer.setOptions({ + count: 10, estimateSize: () => 50, getScrollElement: () => null, scrollToFn: vi.fn(), observeElementRect: vi.fn(), observeElementOffset: vi.fn(), + paddingStart: 0, + overscan: 0, + enabled: false, }) - virtualizer['getMeasurements']() - virtualizer.resizeItem(0, 80) - const before = virtualizer['getMeasurements']() - const beforeRef = before - // Same value, should short-circuit (delta === 0) - virtualizer.resizeItem(0, 80) - const after = virtualizer['getMeasurements']() - // Memo should return the same array reference - expect(after).toBe(beforeRef) + expect(virtualizer.options.paddingStart).toBe(0) + expect(virtualizer.options.overscan).toBe(0) + expect(virtualizer.options.enabled).toBe(false) }) -test('measure() should clear size cache and lane assignments', () => { +test('setOptions: subsequent calls do not accumulate stale options', () => { const virtualizer = new Virtualizer({ - count: 4, - lanes: 2, + count: 10, estimateSize: () => 50, getScrollElement: () => null, scrollToFn: vi.fn(), observeElementRect: vi.fn(), observeElementOffset: vi.fn(), + paddingStart: 100, + overscan: 5, }) - virtualizer['getMeasurements']() - virtualizer.resizeItem(0, 200) - virtualizer.resizeItem(1, 100) - - expect(virtualizer['itemSizeCache'].size).toBe(2) - expect(virtualizer['laneAssignments'].size).toBeGreaterThan(0) - - virtualizer.measure() - - expect(virtualizer['itemSizeCache'].size).toBe(0) - expect(virtualizer['laneAssignments'].size).toBe(0) - - // After measure(), sizes should fall back to estimateSize - const measurements = virtualizer['getMeasurements']() - expect(measurements[0]!.size).toBe(50) - expect(measurements[1]!.size).toBe(50) -}) - -test('measure() should fully invalidate when a later index was dirtied without an intervening getMeasurements()', () => { - // Regression: measure() used to clear itemSizeCache but not pendingMin. - // If resizeItem() had been called without a subsequent getMeasurements() - // to flush pendingMin, the next rebuild would preserve measurementsCache - // entries before that index — even though measure() is supposed to wipe - // everything. - const virtualizer = new Virtualizer({ - count: 6, + // Now call again with only count — paddingStart and overscan should reset to defaults + virtualizer.setOptions({ + count: 20, estimateSize: () => 50, getScrollElement: () => null, scrollToFn: vi.fn(), @@ -962,480 +360,115 @@ test('measure() should fully invalidate when a later index was dirtied without a observeElementOffset: vi.fn(), }) - // Seed item 0 with a non-estimate size, then flush so it's in measurementsCache. - virtualizer.resizeItem(0, 999) - virtualizer['getMeasurements']() - // Now dirty a later index without flushing — pendingMin will be 2. - virtualizer.resizeItem(2, 888) - expect(virtualizer['pendingMin']).toBe(2) - - virtualizer.measure() - - // After measure(), pendingMin must be null so the rebuild starts at 0 - // and discards the stale item-0 entry. - expect(virtualizer['pendingMin']).toBe(null) - - const m = virtualizer['getMeasurements']() - expect(m[0]!.size).toBe(50) - expect(m[2]!.size).toBe(50) + expect(virtualizer.options.count).toBe(20) + expect(virtualizer.options.paddingStart).toBe(0) + expect(virtualizer.options.overscan).toBe(1) }) -test('measure() should trigger a re-measurement on subsequent getMeasurements', () => { - let sizeFn = (i: number) => 50 +test('setOptions: does not mutate the caller-supplied opts object', () => { const virtualizer = new Virtualizer({ - count: 3, - estimateSize: (i) => sizeFn(i), + count: 10, + estimateSize: () => 50, getScrollElement: () => null, scrollToFn: vi.fn(), observeElementRect: vi.fn(), observeElementOffset: vi.fn(), }) - const before = virtualizer['getMeasurements']() - expect(before[0]!.size).toBe(50) - - // Change the estimateSize function via setOptions - sizeFn = () => 100 - virtualizer.measure() - - const after = virtualizer['getMeasurements']() - expect(after[0]!.size).toBe(100) -}) - -test('resizeItem on unknown index is a no-op', () => { - const virtualizer = new Virtualizer({ - count: 3, + const userOpts = { + count: 10, estimateSize: () => 50, getScrollElement: () => null, scrollToFn: vi.fn(), observeElementRect: vi.fn(), observeElementOffset: vi.fn(), - }) + paddingStart: undefined as any, + overscan: undefined as any, + } + const beforeKeys = Object.keys(userOpts).sort() - virtualizer['getMeasurements']() - // Index out of bounds — should not crash - expect(() => virtualizer.resizeItem(99, 100)).not.toThrow() + virtualizer.setOptions(userOpts) - // Cache should be untouched - expect(virtualizer['itemSizeCache'].size).toBe(0) + const afterKeys = Object.keys(userOpts).sort() + expect(afterKeys).toEqual(beforeKeys) + // Specifically: undefined-valued keys should still exist on the user's object + expect('paddingStart' in userOpts).toBe(true) + expect('overscan' in userOpts).toBe(true) }) -test('resizeItem out-of-order should produce correct positions regardless of measurement order', () => { - const N = 10 - const virtualizer = new Virtualizer({ - count: N, - estimateSize: () => 20, +test('takeSnapshot: returns measured items only, restorable via initialMeasurementsCache', () => { + const v1 = new Virtualizer({ + count: 20, + estimateSize: () => 50, getScrollElement: () => null, scrollToFn: vi.fn(), observeElementRect: vi.fn(), observeElementOffset: vi.fn(), }) + v1['getMeasurements']() - virtualizer['getMeasurements']() + // No measurements yet → empty snapshot + expect(v1.takeSnapshot()).toEqual([]) - // Resize in reverse order — should still produce a valid prefix-sum - for (let i = N - 1; i >= 0; i--) { - virtualizer.resizeItem(i, 30 + i) - } + // Measure a few items + v1.resizeItem(0, 80) + v1.resizeItem(1, 60) + v1.resizeItem(2, 100) - const measurements = virtualizer['getMeasurements']() - let runningStart = 0 - for (let i = 0; i < N; i++) { - expect(measurements[i]!.size).toBe(30 + i) - expect(measurements[i]!.start).toBe(runningStart) - runningStart += 30 + i - } -}) + const snapshot = v1.takeSnapshot() + expect(snapshot.length).toBe(3) + expect(snapshot[0]!.size).toBe(80) + expect(snapshot[1]!.size).toBe(60) + expect(snapshot[2]!.size).toBe(100) + // snapshot entries are plain objects (not Proxy refs) + expect(Object.keys(snapshot[0]!).sort()).toEqual([ + 'end', + 'index', + 'key', + 'lane', + 'size', + 'start', + ]) -test('getMeasurements memo should return same array reference when nothing changed', () => { - const virtualizer = new Virtualizer({ - count: 5, + // Restore: pass snapshot to a fresh virtualizer + const v2 = new Virtualizer({ + count: 20, estimateSize: () => 50, + initialMeasurementsCache: snapshot, getScrollElement: () => null, scrollToFn: vi.fn(), observeElementRect: vi.fn(), observeElementOffset: vi.fn(), }) - - const a = virtualizer['getMeasurements']() - const b = virtualizer['getMeasurements']() - expect(a).toBe(b) + const m2 = v2['getMeasurements']() + // Restored sizes match the snapshot + expect(m2[0]!.size).toBe(80) + expect(m2[1]!.size).toBe(60) + expect(m2[2]!.size).toBe(100) + // Unmeasured items fall back to estimateSize + expect(m2[5]!.size).toBe(50) }) -test('getMeasurements memo should return new array reference after resizeItem', () => { - const virtualizer = new Virtualizer({ - count: 5, +test('takeSnapshot: works with lanes>1 too', () => { + const v = new Virtualizer({ + count: 6, + lanes: 2, estimateSize: () => 50, getScrollElement: () => null, scrollToFn: vi.fn(), observeElementRect: vi.fn(), observeElementOffset: vi.fn(), }) - - const a = virtualizer['getMeasurements']() - virtualizer.resizeItem(0, 100) - const b = virtualizer['getMeasurements']() - expect(a).not.toBe(b) - expect(b[0]!.size).toBe(100) -}) - -// ─── elementsCache leak: disconnected node cleanup ─────────────────────────── - -test('RO callback should remove disconnected node from elementsCache', () => { - // Pins down that when the ResizeObserver fires for a node that has been - // disconnected from the DOM, that node is removed from elementsCache. - // Without the fix, elementsCache accumulates stale entries. - let roCallback: ResizeObserverCallback | null = null - const MockResizeObserver = vi.fn(function (cb: ResizeObserverCallback) { - roCallback = cb - return { - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), - } - }) - - const mockWindow = { - requestAnimationFrame: vi.fn(), - cancelAnimationFrame: vi.fn(), - performance: { now: () => Date.now() }, - ResizeObserver: MockResizeObserver, - } - - const mockScrollElement = { - scrollTop: 0, - scrollLeft: 0, - scrollWidth: 1000, - scrollHeight: 5000, - offsetWidth: 400, - offsetHeight: 600, - ownerDocument: { defaultView: mockWindow }, - } as unknown as HTMLDivElement - - const virtualizer = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => mockScrollElement, - scrollToFn: vi.fn(), - observeElementRect: (_inst, cb) => { - cb({ width: 400, height: 600 }) - return () => {} - }, - observeElementOffset: (_inst, cb) => { - cb(0, false) - return () => {} - }, - }) - - virtualizer._willUpdate() - - // Simulate React mounting an element by calling measureElement ref callback - const node = { - getAttribute: (name: string) => (name === 'data-index' ? '3' : null), - getBoundingClientRect: () => ({ height: 50, width: 400 }), - isConnected: true, - setAttribute: vi.fn(), - } as unknown as HTMLElement - - virtualizer.measureElement(node) - expect(virtualizer.elementsCache.get(3)).toBe(node) - - // Now simulate the node being disconnected from DOM - ;(node as any).isConnected = false - - // Fire the RO callback for this node — pretending it just resized - expect(roCallback).not.toBeNull() - roCallback!( - [ - { - target: node, - contentRect: { height: 50, width: 400 } as DOMRectReadOnly, - borderBoxSize: [{ blockSize: 50, inlineSize: 400 }], - contentBoxSize: [{ blockSize: 50, inlineSize: 400 }], - devicePixelContentBoxSize: [{ blockSize: 50, inlineSize: 400 }], - } as ResizeObserverEntry, - ], - {} as ResizeObserver, - ) - - // elementsCache should no longer contain the disconnected node - expect(virtualizer.elementsCache.has(3)).toBe(false) -}) - -test('measureElement should not cache a 0 size for a node that is not connected yet', () => { - // Vue calls function refs during patch, before the parent is inserted, - // so measureElement can receive a node whose offsetHeight is still 0. - let roCallback: ResizeObserverCallback | null = null - const observe = vi.fn() - const MockResizeObserver = vi.fn(function (cb: ResizeObserverCallback) { - roCallback = cb - return { - observe, - unobserve: vi.fn(), - disconnect: vi.fn(), - } - }) - - const mockWindow = { - requestAnimationFrame: vi.fn(), - cancelAnimationFrame: vi.fn(), - performance: { now: () => Date.now() }, - ResizeObserver: MockResizeObserver, - } - - const mockScrollElement = { - scrollTop: 0, - scrollLeft: 0, - scrollWidth: 1000, - scrollHeight: 5000, - offsetWidth: 400, - offsetHeight: 600, - ownerDocument: { defaultView: mockWindow }, - } as unknown as HTMLDivElement - - const virtualizer = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => mockScrollElement, - scrollToFn: vi.fn(), - observeElementRect: (_inst, cb) => { - cb({ width: 400, height: 600 }) - return () => {} - }, - observeElementOffset: (_inst, cb) => { - cb(0, false) - return () => {} - }, - }) - - virtualizer._willUpdate() - - const nodes = [0, 1, 2].map( - (index) => - ({ - getAttribute: (name: string) => - name === 'data-index' ? String(index) : null, - isConnected: false, - offsetHeight: 0, - offsetWidth: 0, - setAttribute: vi.fn(), - }) as unknown as HTMLElement, - ) - - nodes.forEach((node) => virtualizer.measureElement(node)) - - // Observed, but the 0 isn't cached: items keep their estimated positions. - expect(observe).toHaveBeenCalledTimes(3) - expect(virtualizer.elementsCache.get(1)).toBe(nodes[1]) - expect( - virtualizer - .getVirtualItems() - .slice(0, 3) - .map((item) => item.start), - ).toEqual([0, 50, 100]) - - nodes.forEach((node) => { - ;(node as any).isConnected = true - }) - roCallback!( - nodes.map( - (node) => - ({ - target: node, - contentRect: { height: 60, width: 400 } as DOMRectReadOnly, - borderBoxSize: [{ blockSize: 60, inlineSize: 400 }], - contentBoxSize: [{ blockSize: 60, inlineSize: 400 }], - devicePixelContentBoxSize: [{ blockSize: 60, inlineSize: 400 }], - }) as ResizeObserverEntry, - ), - {} as ResizeObserver, - ) - - expect( - virtualizer - .getVirtualItems() - .slice(0, 3) - .map((item) => item.start), - ).toEqual([0, 60, 120]) -}) - -test('RO callback should not delete cache entry if node was replaced by React', () => { - // Edge case: if React unmounts node A and mounts node B for the same key, - // a delayed RO callback for the now-disconnected node A must not delete - // the entry that now points to node B. - let roCallback: ResizeObserverCallback | null = null - const MockResizeObserver = vi.fn(function (cb: ResizeObserverCallback) { - roCallback = cb - return { - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), - } - }) - - const mockWindow = { - requestAnimationFrame: vi.fn(), - cancelAnimationFrame: vi.fn(), - performance: { now: () => Date.now() }, - ResizeObserver: MockResizeObserver, - } - - const mockScrollElement = { - scrollTop: 0, - scrollLeft: 0, - scrollWidth: 1000, - scrollHeight: 5000, - offsetWidth: 400, - offsetHeight: 600, - ownerDocument: { defaultView: mockWindow }, - } as unknown as HTMLDivElement - - const virtualizer = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => mockScrollElement, - scrollToFn: vi.fn(), - observeElementRect: (_inst, cb) => { - cb({ width: 400, height: 600 }) - return () => {} - }, - observeElementOffset: (_inst, cb) => { - cb(0, false) - return () => {} - }, - }) - - virtualizer._willUpdate() - - // Mount nodeA at index 3 - const nodeA = { - getAttribute: () => '3', - getBoundingClientRect: () => ({ height: 50, width: 400 }), - isConnected: true, - setAttribute: vi.fn(), - } as unknown as HTMLElement - virtualizer.measureElement(nodeA) - expect(virtualizer.elementsCache.get(3)).toBe(nodeA) - - // Mount nodeB for the same index — replaces nodeA in elementsCache - const nodeB = { - getAttribute: () => '3', - getBoundingClientRect: () => ({ height: 50, width: 400 }), - isConnected: true, - setAttribute: vi.fn(), - } as unknown as HTMLElement - virtualizer.measureElement(nodeB) - expect(virtualizer.elementsCache.get(3)).toBe(nodeB) - - // Now fire a delayed RO callback for the now-disconnected nodeA. - // This must NOT delete elementsCache[3] (which points to nodeB). - ;(nodeA as any).isConnected = false - roCallback!( - [ - { - target: nodeA, - contentRect: { height: 50, width: 400 } as DOMRectReadOnly, - borderBoxSize: [{ blockSize: 50, inlineSize: 400 }], - contentBoxSize: [{ blockSize: 50, inlineSize: 400 }], - devicePixelContentBoxSize: [{ blockSize: 50, inlineSize: 400 }], - } as ResizeObserverEntry, - ], - {} as ResizeObserver, - ) - - expect(virtualizer.elementsCache.get(3)).toBe(nodeB) -}) - -test('ignores connected stale ref and ResizeObserver measurements after count shrinks', () => { - let roCallback: ResizeObserverCallback | null = null - const MockResizeObserver = vi.fn(function (cb: ResizeObserverCallback) { - roCallback = cb - return { - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), - } - }) - const mockWindow = { - requestAnimationFrame: vi.fn(), - cancelAnimationFrame: vi.fn(), - performance: { now: () => Date.now() }, - ResizeObserver: MockResizeObserver, - } - const mockScrollElement = { - scrollTop: 0, - scrollLeft: 0, - scrollWidth: 1000, - scrollHeight: 5000, - offsetWidth: 400, - offsetHeight: 600, - ownerDocument: { defaultView: mockWindow }, - } as unknown as HTMLDivElement - - let virtualizer: Virtualizer - const getItemKey = vi.fn((index: number) => { - if (index < 0 || index >= virtualizer.options.count) { - throw new Error(`getItemKey received stale index ${index}`) - } - return index - }) - virtualizer = new Virtualizer({ - count: 22, - estimateSize: () => 50, - getItemKey, - useCachedMeasurements: true, - getScrollElement: () => mockScrollElement, - scrollToFn: vi.fn(), - observeElementRect: (_instance, cb) => { - cb({ width: 400, height: 600 }) - return () => {} - }, - observeElementOffset: (_instance, cb) => { - cb(0, false) - return () => {} - }, - }) - virtualizer._willUpdate() - - const staleNode = { - getAttribute: () => '21', - getBoundingClientRect: () => ({ height: 50, width: 400 }), - isConnected: true, - setAttribute: vi.fn(), - } as unknown as HTMLElement - virtualizer.measureElement(staleNode) - - virtualizer.setOptions({ ...virtualizer.options, count: 1 }) - getItemKey.mockClear() - - expect(() => virtualizer.measureElement(staleNode)).not.toThrow() - expect(getItemKey).not.toHaveBeenCalled() - - getItemKey.mockClear() - expect(roCallback).not.toBeNull() - expect(() => { - roCallback!( - [ - { - target: staleNode, - contentRect: { height: 50, width: 400 } as DOMRectReadOnly, - borderBoxSize: [{ blockSize: 50, inlineSize: 400 }], - contentBoxSize: [{ blockSize: 50, inlineSize: 400 }], - devicePixelContentBoxSize: [{ blockSize: 50, inlineSize: 400 }], - } as ResizeObserverEntry, - ], - {} as ResizeObserver, - ) - }).not.toThrow() - expect(getItemKey).not.toHaveBeenCalled() + v['getMeasurements']() + v.resizeItem(0, 80) + v.resizeItem(1, 90) + const snap = v.takeSnapshot() + expect(snap.length).toBe(2) + expect(snap[0]!.size).toBe(80) + expect(snap[1]!.size).toBe(90) }) -// ─── setOptions behavioral contract ────────────────────────────────────────── -// These tests pin down how setOptions merges defaults with user-supplied opts. -// They guard against regressions when changing the merge mechanism -// (currently: mutate opts + spread with defaults; will become: copy-without-undefined). - -test('setOptions: undefined values should fall back to defaults', () => { +test('setOptions: explicit value overrides default', () => { const virtualizer = new Virtualizer({ count: 10, estimateSize: () => 50, @@ -1443,5543 +476,38 @@ test('setOptions: undefined values should fall back to defaults', () => { scrollToFn: vi.fn(), observeElementRect: vi.fn(), observeElementOffset: vi.fn(), - paddingStart: 100, + overscan: 7, + gap: 12, + lanes: 3, }) - // First confirm explicit value sticks - expect(virtualizer.options.paddingStart).toBe(100) + expect(virtualizer.options.overscan).toBe(7) + expect(virtualizer.options.gap).toBe(12) + expect(virtualizer.options.lanes).toBe(3) +}) - // Now setOptions with paddingStart: undefined → should fall back to default (0) - virtualizer.setOptions({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - paddingStart: undefined as any, - }) - - expect(virtualizer.options.paddingStart).toBe(0) -}) - -test('setOptions: missing keys should fall back to defaults', () => { - const virtualizer = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - - // Defaults should apply for all unset options - expect(virtualizer.options.paddingStart).toBe(0) - expect(virtualizer.options.paddingEnd).toBe(0) - expect(virtualizer.options.overscan).toBe(1) - expect(virtualizer.options.horizontal).toBe(false) - expect(virtualizer.options.gap).toBe(0) - expect(virtualizer.options.lanes).toBe(1) - expect(virtualizer.options.enabled).toBe(true) -}) - -test('setOptions: explicit falsy values (0, false) should NOT fall back to defaults', () => { - const virtualizer = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - paddingStart: 50, - overscan: 3, - enabled: true, - }) - - // Now set them all to explicit falsy values - virtualizer.setOptions({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - paddingStart: 0, - overscan: 0, - enabled: false, - }) - - expect(virtualizer.options.paddingStart).toBe(0) - expect(virtualizer.options.overscan).toBe(0) - expect(virtualizer.options.enabled).toBe(false) -}) - -test('setOptions: subsequent calls do not accumulate stale options', () => { - const virtualizer = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - paddingStart: 100, - overscan: 5, - }) - - // Now call again with only count — paddingStart and overscan should reset to defaults - virtualizer.setOptions({ - count: 20, - estimateSize: () => 50, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - - expect(virtualizer.options.count).toBe(20) - expect(virtualizer.options.paddingStart).toBe(0) - expect(virtualizer.options.overscan).toBe(1) -}) - -test('setOptions: does not mutate the caller-supplied opts object', () => { - const virtualizer = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - - const userOpts = { - count: 10, - estimateSize: () => 50, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - paddingStart: undefined as any, - overscan: undefined as any, - } - const beforeKeys = Object.keys(userOpts).sort() - - virtualizer.setOptions(userOpts) - - const afterKeys = Object.keys(userOpts).sort() - expect(afterKeys).toEqual(beforeKeys) - // Specifically: undefined-valued keys should still exist on the user's object - expect('paddingStart' in userOpts).toBe(true) - expect('overscan' in userOpts).toBe(true) -}) - -// ─── pending min pointer for measure storms ────────────────────────────────── - -test('resizeItem random order should rebuild from earliest dirty index', () => { - // This pins down the min-of-pending-indices behavior. If indices 5, 0, 8 are - // dirtied in that order, getMeasurements must rebuild from index 0 onward so - // all later items have correct offsets. - const N = 20 - const virtualizer = new Virtualizer({ - count: N, - estimateSize: () => 10, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - - virtualizer['getMeasurements']() - - virtualizer.resizeItem(5, 50) - virtualizer.resizeItem(0, 30) - virtualizer.resizeItem(8, 70) - virtualizer.resizeItem(15, 100) - virtualizer.resizeItem(3, 40) - - const measurements = virtualizer['getMeasurements']() - // Sizes - expect(measurements[0]!.size).toBe(30) - expect(measurements[3]!.size).toBe(40) - expect(measurements[5]!.size).toBe(50) - expect(measurements[8]!.size).toBe(70) - expect(measurements[15]!.size).toBe(100) - - // Verify start/end are correct (prefix-sum invariant) for ALL items, - // even those that were not resized — they must have absorbed the shifts - // from earlier resized items. - let runningStart = 0 - for (let i = 0; i < N; i++) { - expect(measurements[i]!.start).toBe(runningStart) - runningStart += measurements[i]!.size - } -}) - -test('resizeItem in massive storm (10k items) does not crash on min lookup', () => { - // Regression: Math.min(...arr) spreads the array onto the call stack. - // V8's argument-list limit is around 125k. With many pending indices, - // this can throw RangeError. We test 10k to be well within range but - // catch any regression in the running-min mechanism. - const N = 10_000 - const virtualizer = new Virtualizer({ - count: N, - estimateSize: () => 10, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - - virtualizer['getMeasurements']() - - // Resize every item before reading measurements — accumulates N pending indices - for (let i = 0; i < N; i++) { - virtualizer.resizeItem(i, 20 + (i % 13)) - } - - expect(() => virtualizer['getMeasurements']()).not.toThrow() - const measurements = virtualizer['getMeasurements']() - expect(measurements.length).toBe(N) - // Verify last item has correct prefix-sum - let expected = 0 - for (let i = 0; i < N; i++) expected += 20 + (i % 13) - expect(measurements[N - 1]!.start + measurements[N - 1]!.size).toBe(expected) -}) - -// ─── defaultRangeExtractor ─────────────────────────────────────────────────── - -test('defaultRangeExtractor: simple range with no overscan', () => { - const result = defaultRangeExtractor({ - startIndex: 5, - endIndex: 10, - overscan: 0, - count: 100, - }) - expect(result).toEqual([5, 6, 7, 8, 9, 10]) -}) - -test('defaultRangeExtractor: range with overscan', () => { - const result = defaultRangeExtractor({ - startIndex: 5, - endIndex: 10, - overscan: 2, - count: 100, - }) - expect(result).toEqual([3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) -}) - -test('defaultRangeExtractor: clamps start to 0 when overscan would go negative', () => { - const result = defaultRangeExtractor({ - startIndex: 1, - endIndex: 5, - overscan: 5, - count: 100, - }) - expect(result).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) - expect(result[0]).toBe(0) -}) - -test('defaultRangeExtractor: clamps end to count-1 when overscan would go past', () => { - const result = defaultRangeExtractor({ - startIndex: 95, - endIndex: 99, - overscan: 5, - count: 100, - }) - expect(result).toEqual([90, 91, 92, 93, 94, 95, 96, 97, 98, 99]) - expect(result[result.length - 1]).toBe(99) -}) - -test('defaultRangeExtractor: overscan expands by whole rows when lanes > 1', () => { - const result = defaultRangeExtractor({ - startIndex: 35, - endIndex: 54, - overscan: 1, - count: 100, - lanes: 5, - }) - expect(result[0]).toBe(30) - expect(result[result.length - 1]).toBe(59) - expect(result.length).toBe(30) -}) - -test('defaultRangeExtractor: row overscan clamps to the list bounds', () => { - const result = defaultRangeExtractor({ - startIndex: 0, - endIndex: 98, - overscan: 2, - count: 100, - lanes: 5, - }) - expect(result[0]).toBe(0) - expect(result[result.length - 1]).toBe(99) -}) - -test('multi-lane virtualizer renders whole rows with overscan (#1036)', () => { - const virtualizer = new Virtualizer({ - count: 100, - lanes: 5, - gap: 2, - overscan: 1, - estimateSize: () => 50, - initialRect: { width: 600, height: 200 }, - initialOffset: 418, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - const indexes = virtualizer.getVirtualIndexes() - // Every rendered row is complete, so every lane gets the same number of - // items measured. A partial row lets one lane drift from the others. - expect(indexes[0]! % 5).toBe(0) - expect(indexes[indexes.length - 1]! % 5).toBe(4) -}) - -test('defaultRangeExtractor: single item range', () => { - const result = defaultRangeExtractor({ - startIndex: 5, - endIndex: 5, - overscan: 0, - count: 100, - }) - expect(result).toEqual([5]) -}) - -test('defaultRangeExtractor: returns a plain Array (not iterable proxy)', () => { - const result = defaultRangeExtractor({ - startIndex: 0, - endIndex: 3, - overscan: 0, - count: 100, - }) - expect(Array.isArray(result)).toBe(true) - expect(result.length).toBe(4) -}) - -test('defaultRangeExtractor: large range produces correct length', () => { - const result = defaultRangeExtractor({ - startIndex: 0, - endIndex: 999, - overscan: 0, - count: 1000, - }) - expect(result.length).toBe(1000) - expect(result[0]).toBe(0) - expect(result[999]).toBe(999) -}) - -// ─── Lazy fast path (lanes === 1) edge cases ───────────────────────────────── -// Pins down behavior of the typed-array-backed lazy measurements view. - -test('lazy fast path: empty list (count=0)', () => { - const v = new Virtualizer({ - count: 0, - estimateSize: () => 50, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - const m = v['getMeasurements']() - expect(m.length).toBe(0) - expect(v.getTotalSize()).toBe(0) -}) - -test('lazy fast path: respects paddingStart + scrollMargin + gap', () => { - const v = new Virtualizer({ - count: 5, - estimateSize: () => 40, - paddingStart: 10, - scrollMargin: 20, - gap: 8, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - const m = v['getMeasurements']() - // First item starts at paddingStart + scrollMargin = 30 - expect(m[0]!.start).toBe(30) - expect(m[0]!.size).toBe(40) - expect(m[0]!.end).toBe(70) - // Subsequent items separated by gap - expect(m[1]!.start).toBe(70 + 8) // prev.end + gap - expect(m[1]!.size).toBe(40) -}) - -test('changing gap invalidates cached measurements', () => { - const baseOptions = { - count: 5, - estimateSize: () => 50, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - } - const v = new Virtualizer({ ...baseOptions, gap: 0 }) - let m = v['getMeasurements']() - expect(m[1]!.start).toBe(50) - expect(v.getTotalSize()).toBe(250) - - v.setOptions({ ...baseOptions, gap: 40 }) - m = v['getMeasurements']() - // start(i) = i * (size + gap); previously stale starts survived a gap-only - // change because gap was missing from the measurement memo dependencies - expect(m[1]!.start).toBe(90) - expect(m[2]!.start).toBe(180) - expect(v.getTotalSize()).toBe(410) -}) - -test('changing gap invalidates cached measurements (multi-lane)', () => { - const baseOptions = { - count: 4, - lanes: 2, - estimateSize: () => 50, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - } - const v = new Virtualizer({ ...baseOptions, gap: 0 }) - let m = v['getMeasurements']() - expect(m[2]!.start).toBe(50) - - v.setOptions({ ...baseOptions, gap: 40 }) - m = v['getMeasurements']() - // second row in each lane starts at prevInLane.end + gap - expect(m[2]!.start).toBe(90) - expect(m[3]!.start).toBe(90) -}) - -test('lazy fast path: VirtualItem fields are correct', () => { - const v = new Virtualizer({ - count: 3, - estimateSize: () => 50, - getItemKey: (i) => `item-${i}`, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - const m = v['getMeasurements']() - expect(m[0]!.index).toBe(0) - expect(m[0]!.key).toBe('item-0') - expect(m[0]!.start).toBe(0) - expect(m[0]!.size).toBe(50) - expect(m[0]!.end).toBe(50) - expect(m[0]!.lane).toBe(0) - expect(m[1]!.index).toBe(1) - expect(m[1]!.key).toBe('item-1') - expect(m[2]!.key).toBe('item-2') -}) - -test('lazy fast path: same item read twice returns identical reference (cache works)', () => { - const v = new Virtualizer({ - count: 10, - estimateSize: () => 30, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - const m = v['getMeasurements']() - const a = m[5] - const b = m[5] - expect(a).toBe(b) -}) - -test.each([ - { name: 'initial build', resizeIndex: null }, - { name: 'partial rebuild', resizeIndex: 2 }, -])( - 'lazy fast path: preserves unread item keys after $name', - ({ resizeIndex }) => { - const messages = ['a', 'b', 'c', 'd'] - const v = new Virtualizer({ - count: messages.length, - estimateSize: () => 50, - getItemKey: (index) => messages[index]!, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - expect(v.getTotalSize()).toBe(200) - - if (resizeIndex !== null) { - v.resizeItem(resizeIndex, 80) - expect(v.getTotalSize()).toBe(230) - } - - // The old layout is still needed while setOptions compares the two lists. - messages.splice(0, 1) - messages.push('e') - - expect(v.getVirtualItemForOffset(50)).toEqual({ - index: 1, - key: 'b', - start: 50, - size: 50, - end: 100, - lane: 0, - }) - }, -) - -test('lazy fast path: out-of-range access returns undefined', () => { - const v = new Virtualizer({ - count: 5, - estimateSize: () => 30, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - const m = v['getMeasurements']() - expect(m[10]).toBeUndefined() - expect(m[-1]).toBeUndefined() - expect(m[5]).toBeUndefined() -}) - -test('lazy fast path: getTotalSize after resizeItem reflects new size', () => { - const v = new Virtualizer({ - count: 5, - estimateSize: () => 30, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - expect(v.getTotalSize()).toBe(150) - v.resizeItem(2, 100) - expect(v.getTotalSize()).toBe(120 + 100) // 4 * 30 + 100 -}) - -test('lazy fast path: getVirtualItemForOffset binary search returns correct item', () => { - const v = new Virtualizer({ - count: 100, - estimateSize: () => 30, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - const found = v.getVirtualItemForOffset(500) - // Item at offset 500 should be index 16 (500/30 = 16.67) - expect(found?.index).toBe(16) - expect(found?.start).toBe(480) - expect(found?.end).toBe(510) -}) - -test('lazy fast path: 1M-item list returns a sparse view, not an eagerly-allocated array', () => { - // Functional contract for the lazy fast path: a 1M-item virtualizer - // returns measurements that report the correct total length and produce - // exact start/size/end values on indexed access without requiring the - // whole array to be materialized. Sparse spot-checks across the range - // would fail if the fast path were silently allocating N VirtualItems - // (or if the typed-array backing computed offsets incorrectly). - const v = new Virtualizer({ - count: 1_000_000, - estimateSize: () => 30, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - const m = v['getMeasurements']() - expect(m.length).toBe(1_000_000) - expect(m[0]!.start).toBe(0) - expect(m[0]!.size).toBe(30) - expect(m[0]!.end).toBe(30) - expect(m[500_000]!.start).toBe(15_000_000) - expect(m[500_000]!.end).toBe(15_000_030) - expect(m[999_999]!.start).toBe(29_999_970) - expect(m[999_999]!.end).toBe(30_000_000) -}) - -// ─── iOS momentum-safe scroll adjustments ─────────────────────────────────── - -function withFakeIOSUserAgent(fn: () => T, ios27 = false): T { - // jsdom navigator.userAgent lives on the prototype; we set an own property - // to shadow it, then remove the own property in finally so the prototype - // value is visible again for subsequent tests. - Object.defineProperty(navigator, 'userAgent', { - value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)', - configurable: true, - }) - // iOS 27 is told apart by `overflow-anchor` support; jsdom has no `CSS`. - if (ios27) { - ;(globalThis as any).CSS = { - supports: (query: string) => query === 'overflow-anchor: auto', - } - } - _resetIOSDetectionForTests() - try { - return fn() - } finally { - delete (navigator as any).userAgent - delete (globalThis as any).CSS - _resetIOSDetectionForTests() - } -} - -function makeIOSTouchVirtualizer( - props: Record = {}, - options: Record = {}, -) { - const timers = new Map void>() - let timerId = 0 - const mockWindow = { - setTimeout: (fn: () => void, _ms: number) => { - const id = ++timerId - timers.set(id, fn) - return id - }, - clearTimeout: (id: number) => timers.delete(id), - requestAnimationFrame: () => 1, - cancelAnimationFrame: () => {}, - performance: { now: () => Date.now() }, - } - const el = makeMockScrollElement({ - scrollTop: 100, - scrollLeft: 0, - scrollHeight: 500, - clientHeight: 200, - offsetHeight: 200, - ...props, - ownerDocument: { defaultView: mockWindow }, - }) - const scrollToFn = vi.fn() - let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = - null - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => el as any, - scrollToFn, - observeElementRect: () => {}, - observeElementOffset: (_inst, cb) => { - scrollCallback = cb - cb(el.scrollTop, false) - return () => {} - }, - ...options, - }) - v._willUpdate() - v['getMeasurements']() - scrollToFn.mockClear() - return { - v, - el, - scrollToFn, - // A scroll event; the element is where it reports. - scroll: (offset: number, isScrolling: boolean) => { - el.scrollTop = offset - scrollCallback!(offset, isScrolling) - }, - touch: (type: 'touchstart' | 'touchend' | 'touchcancel') => - el._dispatch(type), - expireTouchTail: () => { - const id = - (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null - expect(id, 'a touch tail timer is armed').not.toBeNull() - const fn = timers.get(id!)! - fn() - }, - } -} - -test('iOS deferral: compensation is deferred during a touch-driven scroll and flushed once the gesture settles', () => { - withFakeIOSUserAgent(() => { - const { v, scrollToFn, scroll, touch, expireTouchTail } = - makeIOSTouchVirtualizer() - // Finger down, drag, release into momentum. - touch('touchstart') - scroll(120, true) - touch('touchend') - scroll(140, true) // momentum frame - - // Resize an item above the viewport mid-fling. The default predicate - // (item.start < scrollOffset) would write scrollTop immediately, and on - // iOS that cancels momentum (#884) — so it must defer. - v.resizeItem(0, 100) // item 0 was at start=0; now 50→100 grows by 50 - expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_holdOffset']).toBe(50) - - // The tail armed by the last momentum frame expires: gesture settled. - expireTouchTail() - expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(v['_holdOffset']).toBe(0) - }) -}) - -test('iOS deferral: multiple resizes during a fling accumulate and flush as one', () => { - withFakeIOSUserAgent(() => { - const { v, scrollToFn, scroll, touch, expireTouchTail } = - makeIOSTouchVirtualizer({ scrollTop: 200 }) - touch('touchstart') - touch('touchend') - scroll(200, true) - - // Three resizes during the fling: 10 + 15 + 20 = 45 total - v.resizeItem(0, 60) - v.resizeItem(1, 65) - v.resizeItem(2, 70) - - expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_holdOffset']).toBe(45) - - expireTouchTail() - // Single flush call - expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(v['_holdOffset']).toBe(0) - }) -}) - -test('iOS deferral: scrollToIndex with no target keeps the held correction for the flush', () => { - // A held correction is a shift between the layout and the element, not a - // pending replay: `scrollOffset` already carries it. A command with no - // target (an empty list) writes nothing and leaves it; the flush writes - // the layout position once the list has refilled (cf. @phcorp's - // differential run on #1316, which pinned the replay of the old model). - withFakeIOSUserAgent(() => { - const { v, scrollToFn, touch, expireTouchTail } = makeIOSTouchVirtualizer() - - touch('touchstart') - v.resizeItem(0, 90) // above the fold under a finger: held - expect(v['_holdOffset']).toBe(40) - expect(v.scrollOffset).toBe(140) - v.setOptions({ ...v.options, count: 0 }) - v['getMeasurements']() // the render for the empty list - v.scrollToIndex(0) // no target, nothing to write - expect(scrollToFn).not.toHaveBeenCalled() - v.setOptions({ ...v.options, count: 10 }) - v['getMeasurements']() - touch('touchend') - expireTouchTail() // settles: the flush writes the layout position - - expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(scrollToFn.mock.calls[0]![0]).toBe(140) - expect(v['_holdOffset']).toBe(0) - }) -}) - -test('iOS deferral: an absolute scroll command targets the layout and the hold moves the element short of it', () => { - // (#1233 manifestation A in the old model: a deferred delta replayed on - // top of a command's target.) A held correction is not dropped by a - // command; the command's target is a layout offset, the element is - // written the hold short of it under the shifted rows, and the flush - // moves both to the target together once a write is possible. - withFakeIOSUserAgent(() => { - const { v, scrollToFn, scroll, touch, expireTouchTail } = - makeIOSTouchVirtualizer({ scrollTop: 200 }) - touch('touchstart') - scroll(190, true) // the finger drags: the user owns the scroll - - // Hold a correction during the drag. - v.resizeItem(0, 100) - expect(v['_holdOffset']).toBe(50) - expect(v.scrollOffset).toBe(240) - - // The command writes the element 50 short of its layout target. - v.scrollToOffset(300) - expect(v['_holdOffset']).toBe(50) - expect(scrollToFn.mock.calls.at(-1)![0]).toBe(250) - - v.resizeItem(1, 100) // held too (finger still down) - expect(v['_holdOffset']).toBe(100) - v.scrollToIndex(5) - expect(scrollToFn.mock.calls.at(-1)![0]).toBe(250 - 100 + 50) - scrollToFn.mockClear() - - // The finger lifts and the writes it ignored have no read-back, so the - // last command still owns the target: the flush writes it (300, which - // the command wrote 100 short of), not the pre-command offset (290). - touch('touchend') - expireTouchTail() - expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(scrollToFn.mock.calls[0]![0]).toBe(300) - expect(v['_holdOffset']).toBe(0) - scroll(scrollToFn.mock.calls[0]![0], false) - expect(v['_holdOffset']).toBe(0) - }) -}) - -test('iOS deferral: a resize during an in-flight scrollToIndex is not replayed after it lands', () => { - // reconcileScroll retargets the index scroll from fresh measurements, so a - // size correction deferred while it travels is already accounted for — - // replaying it on the settle flush would push the list past the target. - withFakeIOSUserAgent(() => { - const rafCallbacks: Array = [] - const mockWindow = { - requestAnimationFrame: (cb: FrameRequestCallback) => - rafCallbacks.push(cb), - cancelAnimationFrame: () => {}, - } - const el = { - scrollTop: 0, - scrollLeft: 0, - scrollHeight: 5000, - clientHeight: 200, - offsetHeight: 200, - ownerDocument: { defaultView: mockWindow }, - } - let scrollCallback: - | ((offset: number, isScrolling: boolean) => void) - | null = null - const v = new Virtualizer({ - count: 100, - estimateSize: () => 50, - getScrollElement: () => el as any, - scrollToFn: (offset, { adjustments = 0 }) => { - el.scrollTop = offset + adjustments - }, - observeElementRect: (_inst, cb) => { - cb({ width: 400, height: 200 }) - return () => {} - }, - observeElementOffset: (_inst, cb) => { - scrollCallback = cb - cb(0, false) - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - const flushFrames = () => { - for (let i = 0; i < 10 && rafCallbacks.length > 0; i++) { - rafCallbacks.splice(0).forEach((cb) => cb(0)) - scrollCallback!(el.scrollTop, true) - } - } - - v.scrollToIndex(20, { align: 'start' }) - scrollCallback!(el.scrollTop, true) - expect(el.scrollTop).toBe(1000) - - // An item above the target measures 50px taller while the index scroll - // is still settling. - // No render runs in between (onChange is not set), so reconcile must - // pick the resize up itself. - v.resizeItem(10, 100) - flushFrames() - expect(el.scrollTop).toBe(1050) - - // The scroll settles: the landing must stay on the item. - scrollCallback!(el.scrollTop, false) - expect(el.scrollTop).toBe(v.getOffsetForIndex(20, 'start')![0]) - expect(el.scrollTop).toBe(1050) - }) -}) - -test('iOS deferral: a held correction is in scrollOffset, so back-to-back resizes stay consistent', () => { - // (In the old model the flush wrote `adjustments: delta` and had to roll - // it into `scrollAdjustments` for a resize landing before the read-back.) - // A held correction is folded into `scrollOffset` at once, like a written - // one, so the flush writes the plain layout position and a resize before - // its read-back compares against the right offset. - withFakeIOSUserAgent(() => { - const { v, scrollToFn, scroll, touch, expireTouchTail } = - makeIOSTouchVirtualizer({ scrollTop: 200 }) - touch('touchstart') - touch('touchend') - scroll(200, true) - - v.resizeItem(0, 100) - expect(v['_holdOffset']).toBe(50) - expect(v.scrollOffset).toBe(250) - expect(v['scrollAdjustments']).toBe(0) - - expireTouchTail() - - expect(scrollToFn).toHaveBeenCalledTimes(1) - const [offset, opts] = scrollToFn.mock.calls[0]! - expect(offset).toBe(250) - expect(opts.adjustments).toBeUndefined() - expect(v['scrollAdjustments']).toBe(0) - expect(v.scrollOffset).toBe(250) - - // Row 1 (layout 100..150, above the fold at 250) grows before the - // read-back: compensated from the flushed offset. - v.resizeItem(1, 80) - expect( - scrollToFn.mock.calls.at(-1)![0] + - (scrollToFn.mock.calls.at(-1)![1].adjustments ?? 0), - ).toBe(280) - expect(v.scrollOffset).toBe(280) - }) -}) - -test('iOS deferral: a write before the flush is read back carries no adjustment', () => { - // (Found by @phcorp's mutation run on #1316 in the old model, where the - // flush's delta lived in `scrollAdjustments` until its read-back.) The - // flush writes a plain offset, so nothing is pending afterwards. - withFakeIOSUserAgent(() => { - const { v, scroll, touch, expireTouchTail } = makeIOSTouchVirtualizer() - - touch('touchstart') - v.resizeItem(0, 100) // held under the finger - touch('touchend') - expireTouchTail() // settles: the flush writes 150 - expect(v.scrollAdjustments).toBe(0) - expect(v.scrollOffset).toBe(150) - - v.scrollToOffset(150) // a write with no adjustments of its own - expect(v.scrollAdjustments).toBe(0) - - scroll(150, false) // its read-back - expect(v.scrollAdjustments).toBe(0) - expect(v.scrollOffset).toBe(150) - }) -}) - -test('iOS deferral: a shrink above the viewport at the end is held, and the sizer does not shrink under the element', () => { - // (#1233 manifestation B in the old model: the sizer shrank under the - // element, the browser clamped scrollTop onto the new bottom, and the - // deferred negative delta had to be dropped at the flush.) With the - // correction held, the exposed total stays what it was, so the element is - // not clamped; the flush writes the layout position, 20 up, which is the - // same rows under the viewport. - withFakeIOSUserAgent(() => { - const { v, scrollToFn, scroll, touch, expireTouchTail } = - makeIOSTouchVirtualizer( - { scrollTop: 300 }, // pinned at the bottom: scrollHeight - clientHeight - { anchorTo: 'end' }, - ) - touch('touchstart') - touch('touchend') - scroll(300, true) // at the bottom, flinging - const totalBefore = v.getTotalSize() - - // A row above the viewport re-measures smaller while at the end. - v.resizeItem(0, 30) // 50 → 30: total shrinks by 20 - expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_holdOffset']).toBe(-20) - expect(v.scrollOffset).toBe(280) - expect(v.getTotalSize()).toBe(totalBefore) - - expireTouchTail() - expect(v['_holdOffset']).toBe(0) - expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(scrollToFn.mock.calls[0]![0]).toBe(280) - expect(v.getTotalSize()).toBe(totalBefore - 20) - }) -}) - -test('iOS deferral: a positive delta at the end clamp still replays (growth above does not clamp)', () => { - // Complement to manifestation B: content GROWING above the viewport does - // not clamp (the browser cannot shrink to fit growth), and the consumer's - // DOM sizer may not have grown yet, so a positive deferred delta must still - // flush — the end-clamp drop is negative-only. - withFakeIOSUserAgent(() => { - const { v, scrollToFn, scroll, touch, expireTouchTail } = - makeIOSTouchVirtualizer({ scrollTop: 300 }, { anchorTo: 'end' }) - touch('touchstart') - touch('touchend') - scroll(300, true) - - // A row above the viewport re-measures larger while at the end. - v.resizeItem(0, 70) // 50 → 70: total grows by 20 - expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_holdOffset']).toBe(20) - - // Settle. Growth doesn't clamp, so the positive delta must replay. - expireTouchTail() - expect(v['_holdOffset']).toBe(0) - expect(scrollToFn).toHaveBeenCalledTimes(1) - }) -}) - -// ─── #1307: orderings the hold has to get right, and ones to keep ─────────── -// -// Each case asserts what the user sees (row positions, the element) through -// one ordering of touches, scroll events, resizes and commands; the plain -// tests pin behaviour a redesign must not change by accident. 100 rows × -// 50px, 200px viewport, hand-driven rAF and scroll events. - -function makeDeferralHarness({ - ios = true, - ios27 = false, - start = 0, -}: { ios?: boolean; ios27?: boolean; start?: number } = {}) { - const rafCallbacks: Array = [] - let now = 0 - const timers = new Map void>() - let timerId = 0 - const mockWindow = { - requestAnimationFrame: (cb: FrameRequestCallback) => rafCallbacks.push(cb), - cancelAnimationFrame: () => { - rafCallbacks.length = 0 - }, - setTimeout: (fn: () => void) => { - timers.set(++timerId, fn) - return timerId - }, - clearTimeout: (id: number) => timers.delete(id), - performance: { now: () => now }, - } - const el = makeMockScrollElement({ - scrollTop: start, - scrollLeft: 0, - scrollHeight: 5000, - clientHeight: 200, - offsetHeight: 200, - ownerDocument: { defaultView: mockWindow }, - }) - let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = - null - const run = (fn: () => T) => (ios ? withFakeIOSUserAgent(fn, ios27) : fn()) - const writes: Array = [] - // Like iOS under a dragging finger: drop programmatic writes on the floor. - const browser = { ignoreWrites: false } - const v = run(() => { - const instance = new Virtualizer({ - count: 100, - estimateSize: () => 50, - initialOffset: start, - getScrollElement: () => el as any, - scrollToFn: (offset, { adjustments = 0 }) => { - writes.push(offset + adjustments) - if (browser.ignoreWrites) return - el.scrollTop = offset + adjustments - }, - observeElementRect: (_inst, cb) => { - cb({ width: 400, height: 200 }) - return () => {} - }, - observeElementOffset: (_inst, cb) => { - scrollCallback = cb - cb(start, false) - return () => {} - }, - }) - instance._willUpdate() - instance['getMeasurements']() - return instance - }) - return { - v, - el, - run, - writes, - browser, - // A scroll event; moves the element first when `offset` is given. - scroll: (isScrolling: boolean, offset = el.scrollTop) => { - el.scrollTop = offset - scrollCallback!(offset, isScrolling) - }, - frame: () => rafCallbacks.splice(0).forEach((cb) => cb(0)), - advance: (ms: number) => { - now += ms - }, - touch: (type: 'touchstart' | 'touchend') => run(() => el._dispatch(type)), - // The post-touchend tail times out: the gesture has settled. - expireTail: () => - run(() => { - const fns = [...timers.values()] - timers.clear() - fns.forEach((fn) => fn()) - }), - // Where a row sits relative to the top of the viewport. - rowTop: (index: number) => - v['getMeasurements']()[index]!.start - el.scrollTop, - } -} - -// scrollToIndex(20) has landed on 1000 and reconcile has seen it once; row 10 -// (above the target) then grows by 50 and reconcile has not run since. -function landedIndexScrollWithResizeAbove(ios = true) { - const h = makeDeferralHarness({ ios }) - h.run(() => { - h.v.scrollToIndex(20, { align: 'start' }) - h.scroll(true) - h.frame() - h.v.resizeItem(10, 100) - }) - return h -} - -test('iOS deferral (#1307): scrollToOffset, then a prepend during a tap tail moves the target with the held delta', () => { - // The options change finds the tail open and holds the prepend. The - // target used to stay 2000 while the flush wrote 2600, so reconcile ran - // until the safety valve. - const h = makeDeferralHarness() - h.run(() => { - h.v.setOptions({ ...h.v.options, anchorTo: 'end', getItemKey: (i) => i }) - h.v._willUpdate() - h.v.scrollToOffset(2000) - h.scroll(true) - }) - h.touch('touchstart') - h.touch('touchend') - h.run(() => { - h.v.setOptions({ ...h.v.options, count: 112, getItemKey: (i) => i - 12 }) - h.v._willUpdate() - }) - expect(h.el.scrollTop).toBe(2000) - h.expireTail() - expect(h.el.scrollTop).toBe(2600) - h.run(() => { - h.scroll(true) - for (let i = 0; i < 5; i++) h.frame() - h.scroll(false) - }) - - expect(h.el.scrollTop).toBe(2600) - expect(h.v.scrollState).toBeNull() -}) - -test('iOS deferral (#1307): a gesture before the index scroll retargets keeps the resize correction', () => { - const h = landedIndexScrollWithResizeAbove() - h.touch('touchstart') - h.run(() => { - h.scroll(true, h.el.scrollTop + 10) - h.frame() - }) - h.touch('touchend') - h.run(() => h.scroll(false)) - h.expireTail() - // The user dragged 10px, so row 20 belongs 10px above the viewport top. - expect(h.rowTop(20)).toBe(-10) -}) - -test('non-iOS: a gesture before the index scroll retargets keeps the resize correction', () => { - const h = landedIndexScrollWithResizeAbove(false) - h.scroll(true, h.el.scrollTop + 10) - h.frame() - h.scroll(false) - expect(h.rowTop(20)).toBe(-10) -}) - -test('iOS deferral (#1307): cancelScroll before the index scroll retargets keeps the resize correction', () => { - const h = landedIndexScrollWithResizeAbove() - h.run(() => { - h.v.cancelScroll() - h.frame() - h.scroll(false) - expect(h.rowTop(20)).toBe(0) - }) -}) - -test('iOS deferral (#1307): the reconcile safety valve keeps the resize correction', () => { - const h = landedIndexScrollWithResizeAbove() - h.run(() => { - h.advance(6000) - h.frame() - h.scroll(false) - expect(h.rowTop(20)).toBe(0) - }) -}) - -test('iOS deferral (#1307): scrollBy replacing an index scroll keeps the resize correction', () => { - const h = landedIndexScrollWithResizeAbove() - h.run(() => { - h.v.scrollBy(100) - h.scroll(true) - h.frame() - h.scroll(true) - h.frame() - h.scroll(false) - expect(h.rowTop(20)).toBe(-100) - }) -}) - -// The reverse flick. Written against the old deferral (#1306), this asserted -// `rowTop(20) === 0` after the flush: the rows were rendered in layout -// coordinates through the deferral, so the write at the end (+50) moved the -// screen, and the fix was to skip it once the fling had carried the viewport -// above row 30 (#1307, step 4). The hold (#1319) changes what the user sees: -// from the resize on, the rows are exposed shifted back by the held delta, the -// fling runs over that layout, and the flush moves the element and the rows -// together, so the row at the top of the viewport when the fling ends is -// still there after the flush. Skipping the correction would move the screen -// by the 50px instead, and the final position would differ from non-iOS, -// where the same gesture over the same content ends at 1050. The assertion -// is now on the screen and on that parity; `rowTop(20)` is -50 on both. -test('iOS deferral (#1307): the flush after a reverse flick moves nothing on screen and lands where non-iOS does', () => { - // The screen: the first rendered row and where the viewport cuts it. - const screen = (h: ReturnType) => { - const first = h.v.getVirtualItems()[0]! - return { index: first.index, top: first.start - h.el.scrollTop } - } - - const h = makeDeferralHarness({ start: 2000 }) - h.touch('touchstart') - h.run(() => { - h.scroll(true, 2010) - h.v.resizeItem(30, 100) - }) - h.touch('touchend') - // The fling carries the viewport above row 30, then settles. - h.run(() => { - h.scroll(true, 1000) - h.scroll(false) - }) - const before = screen(h) - h.expireTail() - expect(screen(h)).toEqual(before) - expect(h.el.scrollTop).toBe(1050) - - // Non-iOS: the correction is written at once and the same gesture (1010px - // of content) lands at the same place. - const web = makeDeferralHarness({ ios: false, start: 2000 }) - web.scroll(true, 2010) - web.v.resizeItem(30, 100) - expect(web.el.scrollTop).toBe(2060) - web.scroll(true, 2060 - 1010) - web.scroll(false) - expect(web.el.scrollTop).toBe(1050) - expect(screen(web)).toEqual(screen(h)) - expect(h.rowTop(20)).toBe(web.rowTop(20)) -}) - -test('scrollToIndex (#1307): a resize classified against the stale offset does not overwrite the index scroll', () => { - // Not iOS-specific. scrollOffset still holds the old position until the - // scroll event, so row 38 (below the target) reads as above the viewport; - // resizes before the read-back are left to reconcile. - const h = makeDeferralHarness({ ios: false, start: 2000 }) - h.v.scrollToIndex(10, { align: 'start' }) - expect(h.el.scrollTop).toBe(500) - h.v.resizeItem(38, 100) - h.scroll(true) - h.frame() - expect(h.el.scrollTop).toBe(500) -}) - -test('scrollToIndex (#1307): a drag that never reached the target is not pulled back when the target moves', () => { - // Seen in iOS Safari: a scrollToIndex issued while a finger is dragging is - // ignored by the browser, so the offset never gets near the target. - const h = makeDeferralHarness() - h.run(() => { - h.v.scrollToIndex(50, { align: 'start' }) - h.scroll(true, 20) - h.frame() - h.scroll(true, 60) - h.frame() - // A row measures and moves the target; the user is still at 60. - h.v.resizeItem(30, 100) - h.frame() - expect(h.el.scrollTop).toBe(60) - }) -}) - -test('scrollToIndex (#1307): a scroll away from a target that moved before landing is not pulled back, non-iOS', () => { - // Not specific to iOS ignoring writes under a finger: a target that moves - // before reconcile sees the scroll land leaves the state alive too. - const h = makeDeferralHarness({ ios: false }) - h.v.scrollToIndex(50, { align: 'start' }) - h.scroll(true) - h.v.resizeItem(10, 100) - h.frame() - h.scroll(true) - expect(h.el.scrollTop).toBe(2550) - // The user scrolls away, then a row above them measures. - h.scroll(true, 1000) - h.frame() - h.v.resizeItem(5, 100) - h.frame() - expect(h.el.scrollTop).toBe(1050) -}) - -test('iOS deferral: a deferred correction follows the viewport through momentum', () => { - const h = makeDeferralHarness({ start: 1000 }) - h.touch('touchstart') - h.run(() => { - h.scroll(true, 1010) - h.v.resizeItem(5, 100) - }) - h.touch('touchend') - h.run(() => { - h.scroll(true, 3000) // momentum - h.scroll(false) - }) - h.expireTail() - // Applied where the momentum ended, not where the resize was seen. - expect(h.el.scrollTop).toBe(3050) - expect(h.rowTop(60)).toBe(0) -}) - -// ─── #1307 step 3: iOS 27, where a fling carries on past a write ──────────── -// -// On the 27.0 simulator a scrollTop write mid-fling shows for one frame and -// the fling continues on its own path: it neither stops the momentum (iOS 26 -// and earlier) nor stays. A correction is held through the tail as on every -// version; an absolute command issued mid-fling waits for the tail to close -// instead of being lost. Told apart by `CSS.supports('overflow-anchor: -// auto')`, stubbed here. - -// A fling from 2000, now passing 1500. -function flingOnIOS27(ios27 = true) { - const h = makeDeferralHarness({ start: 2000, ios27 }) - h.touch('touchstart') - h.run(() => h.scroll(true, 2010)) - h.touch('touchend') - h.run(() => h.scroll(true, 1500)) - h.writes.length = 0 - return h -} - -test('iOS 27: a scrollToIndex mid-fling waits for the tail, and the fling does not retire it', () => { - const h = flingOnIOS27() - h.run(() => { - h.v.scrollToIndex(90, { align: 'start' }) - // Not written: the fling would carry on past it. - expect(h.writes).toEqual([]) - h.scroll(true, 1200) - h.frame() - expect(h.writes).toEqual([]) - expect(h.el.scrollTop).toBe(1200) - h.scroll(false) - }) - // The tail closes 150ms after the last fling frame: written now, once. - h.expireTail() - expect(h.writes).toEqual([4500]) - h.run(() => { - h.scroll(true, 4500) - h.frame() - h.frame() - h.scroll(false) - }) - expect(h.rowTop(90)).toBe(0) - expect(h.v.scrollState).toBeNull() -}) - -test('iOS 26: a scrollToIndex mid-fling is written at once, as before', () => { - const h = flingOnIOS27(false) - h.run(() => h.v.scrollToIndex(90, { align: 'start' })) - expect(h.writes).toEqual([4500]) -}) - -test('iOS 27: a waiting index target follows rows measured during the fling', () => { - const h = flingOnIOS27() - h.run(() => { - h.v.scrollToIndex(90, { align: 'start' }) - h.v.resizeItem(30, 100) - h.scroll(true, 1200) - h.scroll(false) - }) - h.expireTail() - // Row 30 grew by 50 while the command waited: the write targets the fresh - // start of row 90, not the 4500 computed at the call. - expect(h.writes).toEqual([4550]) -}) - -// A touch that lands on a scroller still in its fling fires no touch or -// pointer events on iOS (seen on the 26.5 and 27.0 simulators and an iPhone -// on 27.0.1, #1307). Touch events arrive only once the fling has stopped, -// inside the tail's 150ms. - -test('iOS 27: a drag after the fling stopped, while the command waits, cancels it', () => { - const h = flingOnIOS27() - h.run(() => { - h.v.scrollToIndex(90, { align: 'start' }) - h.scroll(false) - }) - h.touch('touchstart') - h.run(() => h.scroll(true, 1300)) - h.touch('touchend') - h.run(() => h.scroll(false)) - h.expireTail() - expect(h.writes).toEqual([]) - expect(h.el.scrollTop).toBe(1300) - expect(h.v.scrollState).toBeNull() -}) - -test('iOS 27: a tap after the fling stopped, while the command waits, does not cancel it', () => { - const h = flingOnIOS27() - h.run(() => { - h.v.scrollToIndex(90, { align: 'start' }) - h.scroll(false) - }) - h.touch('touchstart') - h.touch('touchend') - h.expireTail() - expect(h.writes).toEqual([4500]) -}) - -test('iOS 27: a tap that stops the fling at speed (no touch events) cancels the waiting command', () => { - const h = flingOnIOS27() // heading up, passing 1500 - h.run(() => { - h.v.scrollToIndex(90, { align: 'start' }) - // Moving ~20px a frame when the finger lands: the events just stop. - for (const top of [1480, 1460, 1441, 1422]) { - h.advance(16) - h.scroll(true, top) - } - h.scroll(false) - }) - h.expireTail() - // The finger is still down: iOS would undo a write now, and the user has - // taken over. - expect(h.writes).toEqual([]) - expect(h.v.scrollState).toBeNull() -}) - -test('iOS 27: a fling caught and dragged back (no touch events) cancels the waiting command', () => { - const h = flingOnIOS27() // heading up, passing 1500 - h.run(() => { - h.v.scrollToIndex(90, { align: 'start' }) - h.scroll(true, 1300) - // A finger catches the fling and drags the other way. Momentum never - // reverses inside the scroll range. - h.scroll(true, 1350) - h.scroll(true, 1600) - h.scroll(false) - }) - h.expireTail() - expect(h.writes).toEqual([]) - expect(h.el.scrollTop).toBe(1600) - expect(h.v.scrollState).toBeNull() -}) - -test('iOS 27: a fling bouncing off the top does not cancel the waiting command', () => { - const h = flingOnIOS27() - h.run(() => { - h.v.scrollToIndex(90, { align: 'start' }) - h.scroll(true, 100) - h.scroll(true, -40) - // Back from the bounce in one frame: a reversal, but not the user's. - h.scroll(true, 0) - h.scroll(false) - }) - h.expireTail() - expect(h.writes).toEqual([4500]) -}) - -test('iOS 27: a fling bouncing off the bottom does not cancel the waiting command', () => { - const h = makeDeferralHarness({ start: 4000, ios27: true }) - h.touch('touchstart') - h.run(() => h.scroll(true, 3990)) - h.touch('touchend') - h.run(() => h.scroll(true, 4500)) - h.writes.length = 0 - h.run(() => { - h.v.scrollToIndex(10, { align: 'start' }) - h.scroll(true, 4800) // max - h.scroll(true, 4850) - h.scroll(true, 4800) - h.scroll(false) - }) - h.expireTail() - expect(h.writes).toEqual([500]) -}) - -// Scroll events recorded on an iPhone (iOS 27.0.1), replayed with their -// timing and shifted into this list (`[time ms, scrollTop]`). -function replayOnIOS27(name: string, shift: number) { - const recorded = flings.find((fling) => fling.name === name)!.samples - const [t0, top0] = recorded[0]! - // The flick that started the fling, in the fling's direction. - const back = Math.sign(recorded[1]![1]! - top0!) * 10 - const h = makeDeferralHarness({ - start: top0! + shift - 2 * back, - ios27: true, - }) - h.touch('touchstart') - h.run(() => h.scroll(true, top0! + shift - back)) - h.touch('touchend') - h.run(() => h.scroll(true, top0! + shift)) - h.writes.length = 0 - h.run(() => h.v.scrollToIndex(90, { align: 'start' })) - let last = t0! - for (const [t, top] of recorded.slice(1)) { - h.advance(t! - last) - last = t! - h.run(() => h.scroll(true, top! + shift)) - } - h.run(() => h.scroll(false)) - h.expireTail() - return h -} - -test('iOS 27: a recorded fling alone does not cancel the waiting command', () => { - const h = replayOnIOS27('one flick, glide to rest (reflick run)', -9000) - expect(h.writes).toEqual([4500]) -}) - -test.each([ - 'glide stopped by a tap 1', - 'glide stopped by a tap 2', - 'glide stopped by a tap 3', -])( - 'iOS 27: a recorded tap that stops the fling cancels the waiting command: %s', - (name) => { - const h = replayOnIOS27(name, -5000) - expect(h.writes).toEqual([]) - expect(h.v.scrollState).toBeNull() - }, -) - -test('iOS 27: a second flick during the fling (no touch events) cancels the waiting command', () => { - const h = replayOnIOS27('glide, flicked again the same way (back run)', -5000) - expect(h.writes).toEqual([]) - expect(h.v.scrollState).toBeNull() -}) - -test('iOS 27: a fling caught, held, then dragged the same way (no touch events) cancels the waiting command', () => { - const h = flingOnIOS27() // heading up, passing 1500 - h.run(() => { - h.v.scrollToIndex(90, { align: 'start' }) - for (const top of [1480, 1462, 1446]) { - h.advance(16) - h.scroll(true, top) - } - // Caught: the finger rests, nothing moves. - h.advance(100) - h.scroll(true, 1445) - // It drags on, faster than the fling was going. - for (const top of [1400, 1350, 1300]) { - h.advance(16) - h.scroll(true, top) - } - h.scroll(false) - }) - h.expireTail() - expect(h.writes).toEqual([]) - expect(h.v.scrollState).toBeNull() -}) - -test('iOS 27: a command issued after a second flick waits for the new fling', () => { - const h = flingOnIOS27() // heading up, passing 1500 - const glide = (tops: Array) => { - for (const top of tops) { - h.advance(16) - h.scroll(true, top) - } - } - h.run(() => { - h.v.scrollToIndex(90, { align: 'start' }) - glide([1490, 1481, 1473, 1466]) - // A second flick: the command waiting is the user's to drop. - glide([1430, 1390, 1350]) - expect(h.v.scrollState).toBeNull() - // A new one waits for the new fling as it slows. - h.v.scrollToIndex(80, { align: 'start' }) - glide([1312, 1276, 1242, 1210, 1180]) - expect(h.v.scrollState?.phase).toBe('queued') - // It fades out on its own. - for (const top of [1176, 1174, 1173, 1172]) { - h.advance(50) - h.scroll(true, top) - } - h.scroll(false) - }) - h.expireTail() - expect(h.writes).toEqual([4000]) -}) - -test('iOS 27: a tap late in the fling (0.21px/ms) cancels the waiting command', () => { - const h = flingOnIOS27() // heading up, passing 1500 - h.run(() => { - h.v.scrollToIndex(90, { align: 'start' }) - // ~3.4px a frame when the finger lands (another iPhone, #1307). - for (const top of [1497, 1493, 1490, 1487, 1483, 1480]) { - h.advance(16) - h.scroll(true, top) - } - h.scroll(false) - }) - h.expireTail() - expect(h.writes).toEqual([]) - expect(h.v.scrollState).toBeNull() -}) - -test('iOS 27: a fling that went on with no scroll events is not read as caught', () => { - const h = flingOnIOS27() // heading up, passing 1500 - h.run(() => { - h.v.scrollToIndex(90, { align: 'start' }) - // 0.25px/ms when the events stop... - for (const top of [1496, 1492, 1488, 1484]) { - h.advance(16) - h.scroll(true, top) - } - }) - // ...but the element keeps moving, fading out, with none. - for (const top of [1478, 1476]) { - h.advance(150) - h.el.scrollTop = top - h.expireTail() - expect(h.writes).toEqual([]) - } - // It stopped: the speed it stopped at is the silent one, not 0.25px/ms. - h.advance(150) - h.expireTail() - expect(h.writes).toEqual([4500]) -}) - -test('iOS 27: the waiting command is not written while the fling still moves between scroll events', () => { - const h = flingOnIOS27() - h.run(() => { - h.v.scrollToIndex(90, { align: 'start' }) - h.scroll(true, 1490) - }) - // The end of a fling: under a pixel per frame, so the element moves with - // no scroll event for 150ms. - h.el.scrollTop = 1488 - h.expireTail() - expect(h.writes).toEqual([]) - expect(h.v.scrollState?.phase).toBe('queued') - // At rest now: written. - h.expireTail() - expect(h.writes).toEqual([4500]) -}) - -test("iOS 27: a command issued from a tap's tail is written at once", () => { - const h = makeDeferralHarness({ start: 2000, ios27: true }) - h.touch('touchstart') - h.touch('touchend') - h.writes.length = 0 - h.run(() => h.v.scrollToIndex(90, { align: 'start' })) - // No drag before the finger lifted, so no fling to wait for. - expect(h.writes).toEqual([4500]) -}) - -test('iOS 27: the hold is released before the waiting command is written', () => { - const h = flingOnIOS27() - h.run(() => { - h.v.scrollToIndex(90, { align: 'start' }) - // Row 5 (above the viewport) grows by 50: held, the rows exposed - // shifted back by it. - h.v.resizeItem(5, 100) - expect(h.writes).toEqual([]) - expect(h.v.getVirtualItems()[0]!.start).toBe( - h.v['getMeasurements']()[h.v.getVirtualItems()[0]!.index]!.start - 50, - ) - h.scroll(false) - }) - h.expireTail() - // The hold lands where the fling ended (1500 + 50), then the command. - expect(h.writes).toEqual([1550, 4550]) - expect(h.v.getVirtualItems()[0]!.start).toBe( - h.v['getMeasurements']()[h.v.getVirtualItems()[0]!.index]!.start, - ) -}) - -test('iOS 27: a command that waits past the safety valve is dropped', () => { - const h = flingOnIOS27() - h.run(() => { - h.v.scrollToIndex(90, { align: 'start' }) - h.advance(6000) - h.frame() - }) - h.expireTail() - expect(h.writes).toEqual([]) - expect(h.v.scrollState).toBeNull() -}) - -test('iOS 27: a correction mid-fling is held through the tail, as on 26', () => { - const h = makeDeferralHarness({ start: 1000, ios27: true }) - h.writes.length = 0 - h.touch('touchstart') - h.run(() => { - h.scroll(true, 1010) - h.v.resizeItem(5, 100) - }) - h.touch('touchend') - h.run(() => { - h.scroll(true, 3000) - h.scroll(false) - }) - expect(h.writes).toEqual([]) - h.expireTail() - expect(h.el.scrollTop).toBe(3050) - expect(h.rowTop(60)).toBe(0) -}) - -test('iOS deferral: a re-measure above the viewport is deferred scrolling forward, skipped scrolling backward', () => { - const h = makeDeferralHarness({ start: 2000 }) - h.run(() => { - h.v.resizeItem(5, 60) - h.scroll(false) - h.el._dispatch('touchstart') - h.scroll(true, 1990) - h.v.resizeItem(5, 100) - expect(h.v['_holdOffset']).toBe(0) - h.scroll(true, 2020) - h.v.resizeItem(5, 120) - expect(h.v['_holdOffset']).toBe(20) - }) -}) - -test('iOS deferral: a custom adjust predicate that opts out defers nothing', () => { - const h = makeDeferralHarness({ start: 2000 }) - h.run(() => { - h.v.shouldAdjustScrollPositionOnItemSizeChange = () => false - h.el._dispatch('touchstart') - h.scroll(true, 2010) - h.v.resizeItem(5, 100) - expect(h.v['_holdOffset']).toBe(0) - }) -}) - -test('iOS deferral: the first measure of the row spanning the viewport top is compensated', () => { - const h = makeDeferralHarness({ start: 2000 }) - h.touch('touchstart') - h.run(() => { - h.scroll(true, 2010) - expect(h.rowTop(41)).toBe(40) - // Row 40 starts at 2000, 10px above the viewport top. - h.v.resizeItem(40, 100) - expect(h.v['_holdOffset']).toBe(50) - }) - h.touch('touchend') - h.run(() => h.scroll(false)) - h.expireTail() - expect(h.rowTop(41)).toBe(40) -}) - -// ─── Phase 1: touch event distinction ──────────────────────────────────────── - -// Mock EventTarget that records listeners so tests can dispatch events -// without requiring a real DOM. Works in any environment, jsdom or not. -function makeMockScrollElement(props: Record) { - const listeners = new Map void>>() - return { - ...props, - addEventListener(name: string, fn: (e: Event) => void) { - let s = listeners.get(name) - if (!s) listeners.set(name, (s = new Set())) - s.add(fn) - }, - removeEventListener(name: string, fn: (e: Event) => void) { - listeners.get(name)?.delete(fn) - }, - _dispatch(name: string) { - listeners.get(name)?.forEach((fn) => fn({} as Event)) - }, - _hasListener(name: string) { - return (listeners.get(name)?.size ?? 0) > 0 - }, - } as any -} - -function makeIOSVirtualizerWithRealEl( - scrollToFn: ReturnType, - mockWindow: any, -) { - const el = makeMockScrollElement({ - scrollTop: 100, - scrollLeft: 0, - scrollHeight: 500, - clientHeight: 200, - offsetHeight: 200, - ownerDocument: { defaultView: mockWindow }, - }) - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => el as any, - scrollToFn, - observeElementRect: () => {}, - observeElementOffset: (_inst, cb) => { - cb(100, false) - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - return { v, el } -} - -function dispatchTouchEvent( - el: any, - type: 'touchstart' | 'touchend' | 'touchcancel', -) { - el._dispatch(type) -} - -test('iOS Phase 1: touchstart enters the `down` phase and ends a prior tail', () => { - withFakeIOSUserAgent(() => { - const mockWindow = { - setTimeout: globalThis.setTimeout.bind(globalThis), - clearTimeout: globalThis.clearTimeout.bind(globalThis), - } - const { v, el } = makeIOSVirtualizerWithRealEl(vi.fn(), mockWindow) - dispatchTouchEvent(el, 'touchstart') - dispatchTouchEvent(el, 'touchend') // leaves a tail armed - expect(v['_iosGesture'].touch?.phase).toBe('tail') - dispatchTouchEvent(el, 'touchstart') - expect(v['_iosGesture'].touch).toEqual({ phase: 'down', moved: false }) - }) -}) - -test('iOS Phase 1: touchcancel clears touching the same as touchend, and cleanup removes the listener', () => { - withFakeIOSUserAgent(() => { - let timerId = 0 - const timers = new Map void>() - const mockWindow = { - setTimeout: (fn: () => void, _ms: number) => { - const id = ++timerId - timers.set(id, fn) - return id - }, - clearTimeout: (id: number) => timers.delete(id), - } - const { v, el } = makeIOSVirtualizerWithRealEl(vi.fn(), mockWindow) - expect(el._hasListener('touchcancel')).toBe(true) - - dispatchTouchEvent(el, 'touchstart') - expect(v['_iosGesture'].touch).toEqual({ phase: 'down', moved: false }) - dispatchTouchEvent(el, 'touchcancel') - expect(v['_iosGesture'].touch?.phase).toBe('tail') - - const unmount = v._didMount() - unmount() - expect(el._hasListener('touchcancel')).toBe(false) - expect(el._hasListener('touchend')).toBe(false) - expect(el._hasListener('touchstart')).toBe(false) - }) -}) - -test('iOS Phase 1: touchend sets justTouchEnded + starts grace timer, then expires', async () => { - await withFakeIOSUserAgent(async () => { - let timerId = 0 - const timers = new Map void>() - const mockWindow = { - setTimeout: (fn: () => void, _ms: number) => { - const id = ++timerId - timers.set(id, fn) - return id - }, - clearTimeout: (id: number) => timers.delete(id), - } - const { v, el } = makeIOSVirtualizerWithRealEl(vi.fn(), mockWindow) - dispatchTouchEvent(el, 'touchstart') - dispatchTouchEvent(el, 'touchend') - const touch = v['_iosGesture'].touch - expect(touch?.phase).toBe('tail') - - // Fire the timer manually (simulating 150ms elapsing). - const fn = timers.get((touch as { timerId: number }).timerId)! - fn() - expect(v['_iosGesture'].touch).toBeNull() - }) -}) - -test('iOS Phase 1: resize during active touch defers (no scrollTop write)', () => { - withFakeIOSUserAgent(() => { - const scrollToFn = vi.fn() - const mockWindow = { - setTimeout: globalThis.setTimeout.bind(globalThis), - clearTimeout: globalThis.clearTimeout.bind(globalThis), - } - const { v, el } = makeIOSVirtualizerWithRealEl(scrollToFn, mockWindow) - // Bring scroll state to a typical "user touched the screen" pose. - dispatchTouchEvent(el, 'touchstart') - scrollToFn.mockClear() - - // Above-viewport item resizes mid-drag. Must defer. - v.resizeItem(0, 100) - expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_holdOffset']).toBe(50) - }) -}) - -test('iOS Phase 1: resize in post-touchend grace window defers; flushes when timer fires', () => { - withFakeIOSUserAgent(() => { - const scrollToFn = vi.fn() - let timerId = 0 - const timers = new Map void>() - const mockWindow = { - setTimeout: (fn: () => void, _ms: number) => { - const id = ++timerId - timers.set(id, fn) - return id - }, - clearTimeout: (id: number) => timers.delete(id), - } - const { v, el } = makeIOSVirtualizerWithRealEl(scrollToFn, mockWindow) - dispatchTouchEvent(el, 'touchstart') - dispatchTouchEvent(el, 'touchend') - const touch = v['_iosGesture'].touch - expect(touch?.phase).toBe('tail') - scrollToFn.mockClear() - - // Items measure during the grace window — must defer - v.resizeItem(0, 100) - v.resizeItem(1, 65) - expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_holdOffset']).toBe(50 + 15) - - // Expire the grace timer; the timer callback flushes the accumulated delta. - const fn = timers.get((touch as { timerId: number }).timerId)! - fn() - expect(v['_iosGesture'].touch).toBeNull() - expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(v['_holdOffset']).toBe(0) - }) -}) - -test('iOS Phase 1: momentum scroll events re-arm the post-touchend tail until the fling ends', () => { - withFakeIOSUserAgent(() => { - const { v, scrollToFn, scroll, touch, expireTouchTail } = - makeIOSTouchVirtualizer() - touch('touchstart') - touch('touchend') - const firstTimer = - (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null - expect(firstTimer).not.toBeNull() - - // iOS fires no touch events during momentum, only scroll events. Each - // one must push the tail out — otherwise it would expire 150 ms into a - // fling that lasts a second or more, and the next resize would write - // scrollTop and kill the momentum (#884). - scroll(120, true) - expect(v['_iosGesture'].touch?.phase).toBe('tail') - expect( - (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null, - ).not.toBe(firstTimer) - - v.resizeItem(0, 100) - expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_holdOffset']).toBe(50) - - scroll(140, true) // still flinging: re-armed again, still deferred - expect(v['_iosGesture'].touch?.phase).toBe('tail') - expect(scrollToFn).not.toHaveBeenCalled() - - // 150 ms after the last frame the tail expires and the delta flushes. - expireTouchTail() - expect(v['_iosGesture'].touch?.phase).not.toBe('tail') - expect( - (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null, - ).toBeNull() - expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(v['_holdOffset']).toBe(0) - }) -}) - -// The first frame of a fling can come 150–250ms after touchend when the main -// thread is busy (27.0 simulator, #1307): after the tail has closed. -function dragThenLateFling({ ios27 = false } = {}) { - const h = makeDeferralHarness({ start: 2000, ios27 }) - h.touch('touchstart') - h.run(() => h.scroll(true, 1990)) - h.touch('touchend') - h.advance(150) - h.expireTail() // no momentum frame yet - h.writes.length = 0 - return h -} - -test('iOS: a fling whose first frame comes after the tail closed reopens it', () => { - const h = dragThenLateFling() - h.advance(100) - h.run(() => { - h.scroll(true, 1960) - h.v.resizeItem(5, 100) - }) - // Held: a write would stop the fling (26) or be overridden (27). - expect(h.writes).toEqual([]) - h.run(() => { - h.scroll(true, 1900) - h.scroll(false) - }) - h.expireTail() - expect(h.writes).toEqual([1950]) -}) - -test('iOS 27: a command mid-fling waits when the first frame came late', () => { - const h = dragThenLateFling({ ios27: true }) - h.advance(100) - h.run(() => { - h.scroll(true, 1960) - h.v.scrollToIndex(90, { align: 'start' }) - }) - expect(h.writes).toEqual([]) - expect(h.v.scrollState?.phase).toBe('queued') -}) - -test('iOS: a scroll event over 1s after the tail closed does not reopen it', () => { - const h = dragThenLateFling() - h.advance(1100) - h.run(() => { - h.scroll(true, 1960) - h.v.resizeItem(5, 100) - }) - expect(h.writes).toEqual([2010]) -}) - -test('iOS: after a fling that reported in time, a later scroll event opens no tail', () => { - const h = makeDeferralHarness({ start: 2000 }) - h.touch('touchstart') - h.run(() => h.scroll(true, 1990)) - h.touch('touchend') - h.run(() => { - h.scroll(true, 1900) - h.scroll(false) - }) - h.expireTail() - h.writes.length = 0 - // Say the app scrolls the element itself. - h.advance(100) - h.run(() => { - h.scroll(true, 1800) - h.v.resizeItem(5, 100) - }) - expect(h.writes).toEqual([1850]) -}) - -test('iOS: a tap opens no late-fling window', () => { - const h = makeDeferralHarness({ start: 2000 }) - h.touch('touchstart') - h.touch('touchend') - h.expireTail() - h.writes.length = 0 - h.advance(100) - h.run(() => { - h.scroll(true, 1960) - h.v.resizeItem(5, 100) - }) - expect(h.writes).toEqual([2010]) -}) - -test('iOS Phase 1: new touchstart during grace window cancels pending flush timer', () => { - withFakeIOSUserAgent(() => { - const scrollToFn = vi.fn() - let timerId = 0 - const timers = new Map void>() - const mockWindow = { - setTimeout: (fn: () => void, _ms: number) => { - const id = ++timerId - timers.set(id, fn) - return id - }, - clearTimeout: (id: number) => timers.delete(id), - } - const { v, el } = makeIOSVirtualizerWithRealEl(scrollToFn, mockWindow) - dispatchTouchEvent(el, 'touchstart') - dispatchTouchEvent(el, 'touchend') - const firstTimerId = (v['_iosGesture'].touch as { timerId: number }).timerId - expect(timers.has(firstTimerId)).toBe(true) - - // User puts finger back down before grace window expired. - dispatchTouchEvent(el, 'touchstart') - // The pending timer must have been canceled. - expect(timers.has(firstTimerId)).toBe(false) - expect(v['_iosGesture'].touch).toEqual({ phase: 'down', moved: false }) - }) -}) - -// Helper for the element-swap tests below: like makeIOSVirtualizerWithRealEl -// but getScrollElement reads a mutable holder, so tests can swap the scroll -// element and re-run _willUpdate (which triggers cleanup + re-attach). -function makeIOSVirtualizerWithSwappableEl( - scrollToFn: ReturnType, - mockWindow: any, - { startScrolling = false }: { startScrolling?: boolean } = {}, -) { - const makeEl = () => - makeMockScrollElement({ - scrollTop: 100, - scrollLeft: 0, - scrollHeight: 500, - clientHeight: 200, - offsetHeight: 200, - ownerDocument: { defaultView: mockWindow }, - }) - const holder = { el: makeEl() } - let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = - null - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => holder.el as any, - scrollToFn, - observeElementRect: () => {}, - observeElementOffset: (_inst, cb) => { - scrollCallback = cb - cb(100, startScrolling) - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - return { - v, - holder, - makeEl, - getScrollCallback: () => scrollCallback!, - } -} - -test('iOS Phase 1: scroll-element swap does not replay a stale deferred adjustment', () => { - withFakeIOSUserAgent(() => { - const scrollToFn = vi.fn() - const mockWindow = { - setTimeout: globalThis.setTimeout.bind(globalThis), - clearTimeout: globalThis.clearTimeout.bind(globalThis), - } - const { v, holder, makeEl, getScrollCallback } = - makeIOSVirtualizerWithSwappableEl(scrollToFn, mockWindow) - scrollToFn.mockClear() - - // A resize above the viewport during an active touch defers its - // adjustment instead of writing scrollTop. - dispatchTouchEvent(holder.el, 'touchstart') - v.resizeItem(0, 100) - expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_holdOffset']).toBe(50) - - // The scroll element is swapped while the deferral is pending — the - // delta was computed against the old element's content and must not - // survive into the new element. - holder.el = makeEl() - v._willUpdate() - expect(v['_holdOffset']).toBe(0) - - // The new element's first quiescence must not write the stale delta. - scrollToFn.mockClear() - getScrollCallback()(100, false) - expect(scrollToFn).not.toHaveBeenCalled() - }) -}) - -test('iOS Phase 1: scroll-element swap mid-touch does not strand the `down` phase', () => { - withFakeIOSUserAgent(() => { - const mockWindow = { - setTimeout: globalThis.setTimeout.bind(globalThis), - clearTimeout: globalThis.clearTimeout.bind(globalThis), - } - const { v, holder, makeEl } = makeIOSVirtualizerWithSwappableEl( - vi.fn(), - mockWindow, - ) - dispatchTouchEvent(holder.el, 'touchstart') - expect(v['_iosGesture'].touch).toEqual({ phase: 'down', moved: false }) - - // The in-flight touch keeps targeting the old element (implicit touch - // capture), so the new element will never deliver its touchend. Without - // a reset, every adjustment on the new element would be deferred and - // the flush blocked until the user's next full touch cycle. - holder.el = makeEl() - v._willUpdate() - expect(v['_iosGesture'].touch).toBeNull() - }) -}) - -test('iOS Phase 1: scroll-element swap during grace window does not strand the tail', () => { - withFakeIOSUserAgent(() => { - let timerId = 0 - const timers = new Map void>() - const mockWindow = { - setTimeout: (fn: () => void, _ms: number) => { - const id = ++timerId - timers.set(id, fn) - return id - }, - clearTimeout: (id: number) => timers.delete(id), - } - const { v, holder, makeEl } = makeIOSVirtualizerWithSwappableEl( - vi.fn(), - mockWindow, - ) - dispatchTouchEvent(holder.el, 'touchstart') - dispatchTouchEvent(holder.el, 'touchend') - expect(v['_iosGesture'].touch?.phase).toBe('tail') - - // Swapping inside the grace window removes the listeners and clears the - // grace timer — which was the only pending reset of the record. Without - // the cleanup reset the tail would stay set indefinitely. - holder.el = makeEl() - v._willUpdate() - expect(v['_iosGesture'].touch).toBeNull() - expect(timers.size).toBe(0) - }) -}) - -// ─── Phase 2a: subpixel scrollTop reconciliation ───────────────────────────── - -test('Phase 2a: browser-rounded scrollTop after self-write is reconciled to intended value', () => { - let scrollCallback: ((o: number, s: boolean) => void) | null = null - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => - ({ - scrollTop: 0, - scrollLeft: 0, - scrollHeight: 500, - clientHeight: 200, - offsetHeight: 200, - scrollTo: vi.fn(), - }) as any, - scrollToFn: vi.fn(), - observeElementRect: () => {}, - observeElementOffset: (_inst, cb) => { - scrollCallback = cb - cb(0, false) - return () => {} - }, - }) - v._willUpdate() - - // Simulate a self-write to 123.5 (subpixel target). - v.scrollToOffset(123.5, { behavior: 'auto' }) - expect(v['pendingWrite']?.target ?? null).toBe(123.5) - - // Browser fires a scroll event reporting 123 (integer-rounded). - scrollCallback!(123, false) - - // We should have reconciled the offset back to the intended 123.5, - // not stored the browser's rounded 123. - expect(v.scrollOffset).toBe(123.5) - expect(v['pendingWrite']?.target ?? null).toBeNull() -}) - -test('Phase 2a: user-initiated scroll (large delta) is NOT reconciled to intended value', () => { - let scrollCallback: ((o: number, s: boolean) => void) | null = null - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => - ({ - scrollTop: 0, - scrollLeft: 0, - scrollHeight: 500, - clientHeight: 200, - offsetHeight: 200, - scrollTo: vi.fn(), - }) as any, - scrollToFn: vi.fn(), - observeElementRect: () => {}, - observeElementOffset: (_inst, cb) => { - scrollCallback = cb - cb(0, false) - return () => {} - }, - }) - v._willUpdate() - v.scrollToOffset(100, { behavior: 'auto' }) - expect(v['pendingWrite']?.target ?? null).toBe(100) - - // User then scrolls way past — browser reports 500. Diff (400) > 1.5 px - // tolerance, so we trust the browser-reported value. - scrollCallback!(500, true) - expect(v.scrollOffset).toBe(500) - expect(v['pendingWrite']?.target ?? null).toBeNull() -}) - -// ─── Phase 2b: scrollTopMax elastic-overscroll clamp ───────────────────────── - -test('Phase 2b: flush skipped when scrollTop is in elastic-overscroll zone (negative)', () => { - withFakeIOSUserAgent(() => { - const { v, el, scrollToFn, scroll, touch, expireTouchTail } = - makeIOSTouchVirtualizer() - touch('touchstart') - - // Resize during the drag: defers - v.resizeItem(0, 100) - expect(v['_holdOffset']).toBe(50) - - // User rubber-bands past the top and lets go: scrollTop is negative - // when the gesture settles. The elastic-zone check blocks the flush so - // we don't snap back to a clamped position. - el.scrollTop = -25 - scroll(-25, true) - touch('touchend') - expireTouchTail() - expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_holdOffset']).toBe(50) // still deferred - - // Bounce-back resolves in-bounds. The next scroll event flushes. - el.scrollTop = 100 - scroll(100, false) - expect(scrollToFn).toHaveBeenCalled() - expect(v['_holdOffset']).toBe(0) - }) -}) - -test('Phase 2b: flush skipped when scrollTop > scrollHeight-clientHeight (overscroll bottom)', () => { - withFakeIOSUserAgent(() => { - const { v, el, scrollToFn, scroll, touch, expireTouchTail } = - makeIOSTouchVirtualizer() // clientHeight 200 → max valid scrollTop = 300 - touch('touchstart') - - v.resizeItem(0, 100) - expect(v['_holdOffset']).toBe(50) - - // User pulls past the bottom and lets go: scrollTop is 350 (> max 300) - // when the tail expires, so the flush is skipped. - el.scrollTop = 350 - scroll(350, true) - touch('touchend') - expireTouchTail() - expect(scrollToFn).not.toHaveBeenCalled() - - // Bounce-back resolves - el.scrollTop = 300 - scroll(300, false) - expect(scrollToFn).toHaveBeenCalled() - }) -}) - -test('Phase 2b: in-bounds flush proceeds normally (no regression)', () => { - withFakeIOSUserAgent(() => { - const scrollToFn = vi.fn() - let scrollCb: ((o: number, s: boolean) => void) | null = null - const el = makeMockScrollElement({ - scrollTop: 100, - scrollLeft: 0, - scrollHeight: 500, - clientHeight: 200, - offsetHeight: 200, - ownerDocument: { - defaultView: { - setTimeout: globalThis.setTimeout.bind(globalThis), - clearTimeout: globalThis.clearTimeout.bind(globalThis), - }, - }, - }) - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => el as any, - scrollToFn, - observeElementRect: () => {}, - observeElementOffset: (_inst, cb) => { - scrollCb = cb - cb(100, true) - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - scrollToFn.mockClear() - - v.resizeItem(0, 100) - el.scrollTop = 150 - scrollCb!(150, false) // in-bounds (0..300) - expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(v['_holdOffset']).toBe(0) - }) -}) - -test('Phase 2a: a second self-write replaces the intended target', () => { - let scrollCallback: ((o: number, s: boolean) => void) | null = null - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => - ({ - scrollTop: 0, - scrollLeft: 0, - scrollHeight: 500, - clientHeight: 200, - offsetHeight: 200, - scrollTo: vi.fn(), - }) as any, - scrollToFn: vi.fn(), - observeElementRect: () => {}, - observeElementOffset: (_inst, cb) => { - scrollCallback = cb - cb(0, false) - return () => {} - }, - }) - v._willUpdate() - v.scrollToOffset(100, { behavior: 'auto' }) - v.scrollToOffset(200.7, { behavior: 'auto' }) - expect(v['pendingWrite']?.target ?? null).toBe(200.7) - // First scrollTo's offset (100) was overwritten — a scroll event near it - // would NOT reconcile. - scrollCallback!(101, true) - // 101 is not within 1.5px of 200.7, so browser value wins. - expect(v.scrollOffset).toBe(101) -}) - -test('iOS Phase 1: non-iOS still does NOT install touch state machine', () => { - // On non-iOS, touchend should not arm the grace timer. - _resetIOSDetectionForTests() - const scrollToFn = vi.fn() - const mockWindow = { - setTimeout: globalThis.setTimeout.bind(globalThis), - clearTimeout: globalThis.clearTimeout.bind(globalThis), - } - const { v, el } = makeIOSVirtualizerWithRealEl(scrollToFn, mockWindow) - - dispatchTouchEvent(el, 'touchstart') - expect(v['_iosGesture'].touch).toEqual({ phase: 'down', moved: false }) // touchstart still records (cheap) - dispatchTouchEvent(el, 'touchend') - // Non-iOS path returns before arming a tail - expect(v['_iosGesture'].touch).toBeNull() -}) - -test('non-iOS: adjustment is applied immediately during scroll (no regression)', () => { - // Without the iOS user-agent, the normal flow should run unchanged. - _resetIOSDetectionForTests() - const scrollToFn = vi.fn() - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => - ({ - scrollTop: 100, - scrollLeft: 0, - scrollHeight: 500, - clientHeight: 200, - offsetHeight: 200, - }) as any, - scrollToFn, - observeElementRect: () => {}, - observeElementOffset: (_inst, cb) => { - cb(100, true) - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - scrollToFn.mockClear() - - v.resizeItem(0, 100) - - // Should have fired immediately - expect(scrollToFn).toHaveBeenCalled() - expect(v['_holdOffset']).toBe(0) -}) - -test('scroll-up jank: backward-scroll skips adjustment on re-measurement by default', () => { - // Default behavior: when an already-measured above-viewport item resizes - // AGAIN while the user is scrolling BACKWARD, we no longer write to - // scrollTop. This avoids the well-known "items jump while scrolling up" jank. - const scrollToFn = vi.fn() - let scrollCb: ((o: number, s: boolean) => void) | null = null - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => - ({ - scrollTop: 200, - scrollLeft: 0, - scrollHeight: 500, - clientHeight: 200, - offsetHeight: 200, - }) as any, - scrollToFn, - observeElementRect: () => {}, - observeElementOffset: (_inst, cb) => { - scrollCb = cb - // Simulate user starting at scrollTop=200, then scrolling up to 100. - cb(200, false) - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - // Seed item 0's size so the backward resize below is a RE-measurement. - v.resizeItem(0, 80) - // Now simulate backward scroll: from 200 to 100 (offset decreases). - scrollCb!(100, true) - expect(v.scrollDirection).toBe('backward') - scrollToFn.mockClear() - - // Re-measure an above-viewport item while scrolling backward. - v.resizeItem(0, 100) // item 0 grows by 20px - - // Default behavior: no scroll-position adjustment fires for re-measurements. - expect(scrollToFn).not.toHaveBeenCalled() -}) - -test('scroll-up jank: backward-scroll still applies adjustment on first measurement', () => { - // First measurement is special: the item was rendered at its estimate and is - // now reporting its actual size. That estimate→actual delta lives above the - // viewport and MUST be compensated, or the anchored content jumps when - // scrolling up into never-measured rows. - const scrollToFn = vi.fn() - let scrollCb: ((o: number, s: boolean) => void) | null = null - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => - ({ - scrollTop: 200, - scrollLeft: 0, - scrollHeight: 500, - clientHeight: 200, - offsetHeight: 200, - }) as any, - scrollToFn, - observeElementRect: () => {}, - observeElementOffset: (_inst, cb) => { - scrollCb = cb - cb(200, false) - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - // Backward scroll: 200 → 100. - scrollCb!(100, true) - expect(v.scrollDirection).toBe('backward') - scrollToFn.mockClear() - - // First measurement of an above-viewport item while scrolling backward. - v.resizeItem(0, 100) // never measured before → estimate(50)→actual(100) - - // Adjustment still fires for the first measurement. - expect(scrollToFn).toHaveBeenCalled() -}) - -test('scroll-up jank: forward-scroll still applies adjustment (no regression)', () => { - const scrollToFn = vi.fn() - let scrollCb: ((o: number, s: boolean) => void) | null = null - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => - ({ - scrollTop: 100, - scrollLeft: 0, - scrollHeight: 500, - clientHeight: 200, - offsetHeight: 200, - }) as any, - scrollToFn, - observeElementRect: () => {}, - observeElementOffset: (_inst, cb) => { - scrollCb = cb - cb(100, false) - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - // Forward scroll: 100 → 200 - scrollCb!(200, true) - expect(v.scrollDirection).toBe('forward') - scrollToFn.mockClear() - - v.resizeItem(0, 100) - - // Forward scroll: adjustment still fires. - expect(scrollToFn).toHaveBeenCalled() -}) - -test('scroll-up jank: idle (scrollDirection=null) still applies adjustment', () => { - // When not actively scrolling, adjustment still fires — needed for the - // mount-time measurement storm where items measure before any scroll. - const scrollToFn = vi.fn() - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => - ({ - scrollTop: 100, - scrollLeft: 0, - scrollHeight: 500, - clientHeight: 200, - offsetHeight: 200, - }) as any, - scrollToFn, - observeElementRect: () => {}, - observeElementOffset: (_inst, cb) => { - cb(100, false) // not scrolling - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - expect(v.scrollDirection).toBeNull() - scrollToFn.mockClear() - - v.resizeItem(0, 100) - expect(scrollToFn).toHaveBeenCalled() -}) - -// ─── scroll direction vs. self-write read-backs ───────────────────────────── -// -// `applyScrollAdjustment` folds the pending adjustment into `scrollOffset` -// eagerly, so the browser's scroll event for our own `scrollTop` write -// arrives at exactly the offset we already hold (directly, or snapped there -// by the `pendingWrite` sub-pixel reconciliation). That equality -// carries no directional information — it must not be classified as a -// `'backward'` gesture, or the default -// `shouldAdjustScrollPositionOnItemSizeChange` skips above-viewport -// compensation for the whole `isScrollingResetDelay` window (see the -// multi-frame reflow test below). - -function createAdjustmentVirtualizer({ - count = 30, - offset, - onChange, -}: { - count?: number - offset: number - onChange?: (instance: any, sync: boolean) => void -}) { - const scrollToFn = vi.fn() - let scrollCb: ((o: number, s: boolean) => void) | null = null - const v = new Virtualizer({ - count, - estimateSize: () => 50, - getScrollElement: () => - ({ - scrollTop: offset, - scrollLeft: 0, - scrollHeight: count * 50, - clientHeight: 200, - offsetHeight: 200, - }) as any, - scrollToFn, - onChange, - observeElementRect: (_inst: any, cb: any) => { - cb({ width: 400, height: 200 }) - return () => {} - }, - observeElementOffset: (_inst: any, cb: any) => { - scrollCb = cb - cb(offset, false) - return () => {} - }, - }) - v._didMount() - v._willUpdate() - v['getMeasurements']() - scrollToFn.mockClear() - return { - v, - scrollToFn, - emitScroll: (o: number, isScrolling: boolean) => scrollCb!(o, isScrolling), - } -} - -test('self-write read-back of a positive adjustment does not latch a scroll direction', () => { - const { v, scrollToFn, emitScroll } = createAdjustmentVirtualizer({ - offset: 600, - }) - - v['applyScrollAdjustment'](40) - expect(scrollToFn).toHaveBeenCalledTimes(1) - // The adjustment is already folded into `scrollOffset`. - expect(v.scrollOffset).toBe(640) - - // Browser read-back of our own write: a scroll event at exactly the - // offset we already hold. - emitScroll(640, true) - - // The event traversed the listener (not swallowed by a no-op guard)... - expect(v.isScrolling).toBe(true) - // ...but an echo of our own write carries no direction information. - expect(v.scrollDirection).toBeNull() -}) - -test('self-write read-back of a negative adjustment does not latch a scroll direction', () => { - const { v, emitScroll } = createAdjustmentVirtualizer({ offset: 600 }) - - v['applyScrollAdjustment'](-40) - expect(v.scrollOffset).toBe(560) - - emitScroll(560, true) - - expect(v.isScrolling).toBe(true) - expect(v.scrollDirection).toBeNull() -}) - -test('self-write read-back rounded by the browser still does not latch a direction', () => { - const { v, emitScroll } = createAdjustmentVirtualizer({ offset: 600 }) - - v['applyScrollAdjustment'](40.5) - expect(v.scrollOffset).toBe(640.5) - - // The browser rounds the write to an integer; the `pendingWrite` - // reconciliation snaps the read-back to the held value. - emitScroll(640, true) - - expect(v.isScrolling).toBe(true) - expect(v.scrollDirection).toBeNull() - expect(v.scrollOffset).toBe(640.5) -}) - -describe('a scrollToFn that reports its write back synchronously', () => { - // The read-back runs inside `applyScrollAdjustment`, before it folds the - // delta into `scrollOffset`; the read-back has already put `scrollOffset` - // at the target, so the delta must not be added a second time. - const setup = (offset: number) => { - const harness = createAdjustmentVirtualizer({ offset }) - harness.scrollToFn.mockImplementation( - (to: number, { adjustments }: { adjustments?: number }) => - harness.emitScroll(to + (adjustments ?? 0), true), - ) - return harness - } - - test('a positive adjustment lands on its target once', () => { - const { v, scrollToFn } = setup(600) - v['applyScrollAdjustment'](40) - expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(v.scrollOffset).toBe(640) - expect(v['pendingWrite']).toBeNull() - }) - - test('a negative adjustment lands on its target once', () => { - const { v } = setup(600) - v['applyScrollAdjustment'](-40) - expect(v.scrollOffset).toBe(560) - }) - - test('a read-back above the top is still clamped to 0', () => { - const { v } = setup(20) - v['applyScrollAdjustment'](-50) - expect(v.scrollOffset).toBe(0) - }) -}) - -test('real scroll gestures still latch forward/backward and reset to null', () => { - const { v, emitScroll } = createAdjustmentVirtualizer({ offset: 600 }) - - emitScroll(650, true) - expect(v.scrollDirection).toBe('forward') - emitScroll(700, true) - expect(v.scrollDirection).toBe('forward') - - emitScroll(620, true) - expect(v.scrollDirection).toBe('backward') - emitScroll(540, true) - expect(v.scrollDirection).toBe('backward') - - // Debounced reset emission after the gesture ends. - emitScroll(540, false) - expect(v.scrollDirection).toBeNull() - expect(v.isScrolling).toBe(false) -}) - -test('adjustment read-back during a real backward gesture preserves the direction', () => { - const { v, emitScroll } = createAdjustmentVirtualizer({ offset: 600 }) - - // Real backward gesture: the user is scrolling up. - emitScroll(500, true) - expect(v.scrollDirection).toBe('backward') - - // A compensation write lands mid-gesture; its read-back arrives at - // exactly the held offset. The user is still scrolling up, so the - // direction must survive, not be erased. - v['applyScrollAdjustment'](30) - expect(v.scrollOffset).toBe(530) - emitScroll(530, true) - - expect(v.isScrolling).toBe(true) - expect(v.scrollDirection).toBe('backward') - - // After the reset emission, a fresh adjustment read-back must yield - // null — not a stale 'backward' from before the reset. - emitScroll(530, false) - expect(v.scrollDirection).toBeNull() - - v['applyScrollAdjustment'](30) - expect(v.scrollOffset).toBe(560) - emitScroll(560, true) - expect(v.isScrolling).toBe(true) - expect(v.scrollDirection).toBeNull() -}) - -test('multi-frame reflow: above-viewport re-measure compensation is never skipped by self-write read-backs', () => { - // The end-to-end shape of the bug, with the DEFAULT - // shouldAdjustScrollPositionOnItemSizeChange: a side pane opens and its - // width animation re-wraps rows for several frames while the user sits - // scrolled up. Each frame re-measures above-viewport rows; the browser - // echoes the compensation write back as a scroll event before the next - // frame's ResizeObserver callbacks (scroll fires before RO in the - // rendering steps). If that echo latches 'backward', every later frame's - // re-measure is skipped until the isScrollingResetDelay reset and the - // viewport drifts. - const { v, scrollToFn, emitScroll } = createAdjustmentVirtualizer({ - offset: 600, - }) - - // Pre-measure the rows we'll re-wrap so they're in itemSizeCache — the - // backward-skip leg of the default predicate only applies to re-measures. - for (let i = 0; i < 5; i++) { - v.resizeItem(i, 55) - v['getMeasurements']() - } - // 5 first measurements × +5 each were compensated and folded. - expect(v.scrollOffset).toBe(625) - // Settle: the browser caught up and the scrolling window expired. - emitScroll(625, false) - expect(v.isScrolling).toBe(false) - expect(v.scrollDirection).toBeNull() - scrollToFn.mockClear() - - // The reflow: 5 frames, each re-measures one above-viewport row +20px, - // then the browser delivers the scroll event for the compensation write - // the way it would between frames. - for (let i = 0; i < 5; i++) { - const before = v.scrollOffset! - v.resizeItem(i, 75) - v['getMeasurements']() - if (v.scrollOffset! !== before) { - // A compensation write happened — deliver its read-back. - emitScroll(v.scrollOffset!, true) - } - } - - // Every frame's above-viewport delta must have been compensated: - // 625 + 5 × 20 = 725. If the first frame's read-back latched 'backward', - // frames 2-5 were skipped and the offset is stuck at 645. - expect(scrollToFn).toHaveBeenCalledTimes(5) - expect(v.scrollOffset).toBe(725) -}) - -test('above-viewport compensation notifies synchronously so the scroll write and transforms commit in one paint (#1227)', () => { - // applyScrollAdjustment writes scrollTop synchronously inside the resize - // (ResizeObserver) callback. If the follow-up notify were async, the - // browser could paint one frame with the new scrollTop but the old item - // transforms — the viewport visibly jumps by `delta` and snaps back. The - // compensating resize must notify synchronously (sync=true) so the render - // flushes in the same callback. - const onChange = vi.fn() - const { v, scrollToFn } = createAdjustmentVirtualizer({ - offset: 600, - onChange, - }) - v['getMeasurements']() - onChange.mockClear() - scrollToFn.mockClear() - - // Item 0 sits entirely above the fold (offset 600) — a first measurement - // there compensates regardless of scroll direction, moving scrollTop. - v.resizeItem(0, 90) - - // The compensation write happened... - expect(scrollToFn).toHaveBeenCalledTimes(1) - // ...and its notify was synchronous so transforms flush in this same frame. - expect(onChange).toHaveBeenCalled() - expect(onChange.mock.calls.at(-1)![1]).toBe(true) -}) - -test('a resize that moves no scroll position keeps the cheaper async notify (#1227)', () => { - // No scroll write → no same-frame constraint → the notify stays async so we - // don't force a synchronous render on every below-fold measurement. - const onChange = vi.fn() - const { v, scrollToFn } = createAdjustmentVirtualizer({ - offset: 0, - onChange, - }) - v['getMeasurements']() - onChange.mockClear() - scrollToFn.mockClear() - - // Item 10 (start 500) sits below the fold at offset 0 — measuring it does - // not compensate, so scrollTop is untouched. - v.resizeItem(10, 90) - - expect(scrollToFn).not.toHaveBeenCalled() - expect(onChange).toHaveBeenCalled() - expect(onChange.mock.calls.at(-1)![1]).toBe(false) -}) - -// ─── end anchoring / chat-style reverse virtualization ────────────────────── - -function createChatVirtualizer({ - messages, - offset, - viewportSize = 200, - itemSize = 50, - followOnAppend = false, - threshold = 1, - paddingEnd = 0, -}: { - messages: Array<{ id: string }> - offset: number - viewportSize?: number - itemSize?: number - followOnAppend?: boolean | 'auto' | 'smooth' | 'instant' - threshold?: number - paddingEnd?: number -}) { - let currentMessages = messages - const scrollToFn = vi.fn() - let offsetCb: ((offset: number, isScrolling: boolean) => void) | null = null - const scrollElement = { - scrollTop: offset, - scrollLeft: 0, - // The sizer is `getTotalSize()`, which includes paddingEnd. - scrollHeight: messages.length * itemSize + paddingEnd, - scrollWidth: 1000, - clientHeight: viewportSize, - clientWidth: 400, - offsetHeight: viewportSize, - ownerDocument: { - defaultView: { - requestAnimationFrame: (_cb: FrameRequestCallback) => { - return 1 - }, - cancelAnimationFrame: vi.fn(), - performance: { now: () => Date.now() }, - ResizeObserver: vi.fn(function () { - return { - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), - } - }), - }, - }, - } as unknown as HTMLDivElement - - const makeOptions = () => { - const messagesSnapshot = currentMessages - - return { - count: messagesSnapshot.length, - estimateSize: () => itemSize, - getItemKey: (index: number) => messagesSnapshot[index]!.id, - getScrollElement: () => scrollElement, - scrollToFn, - observeElementRect: ( - _instance: any, - cb: (rect: { width: number; height: number }) => void, - ) => { - cb({ width: 400, height: viewportSize }) - return () => {} - }, - observeElementOffset: ( - _instance: any, - cb: (offset: number, isScrolling: boolean) => void, - ) => { - offsetCb = cb - cb(scrollElement.scrollTop, false) - return () => {} - }, - anchorTo: 'end' as const, - followOnAppend, - scrollEndThreshold: threshold, - paddingEnd, - } - } - - const virtualizer = new Virtualizer(makeOptions()) - virtualizer._willUpdate() - virtualizer['getMeasurements']() - scrollToFn.mockClear() - - return { - virtualizer, - scrollElement, - scrollToFn, - setMessages(nextMessages: Array<{ id: string }>) { - currentMessages = nextMessages - virtualizer.setOptions(makeOptions()) - ;(scrollElement as any).scrollHeight = - nextMessages.length * itemSize + paddingEnd - virtualizer._willUpdate() - }, - // Simulate the browser's scroll event after a write (or a gesture). - emitScroll(nextOffset: number, isScrolling = false) { - scrollElement.scrollTop = nextOffset - offsetCb?.(nextOffset, isScrolling) - }, - } -} - -test('anchorTo:end keeps visible content stable when older items are prepended', () => { - const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) - const { setMessages, scrollToFn } = createChatVirtualizer({ - messages, - offset: 100, - }) - - setMessages([{ id: 'm--2' }, { id: 'm--1' }, ...messages]) - - expect(scrollToFn).toHaveBeenCalledTimes(1) - const [offset, options] = scrollToFn.mock.calls[0]! - expect(offset).toBe(200) - expect(options.adjustments).toBeUndefined() -}) - -test('anchorTo:end does not yank a scrolled-up user when items append', () => { - const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) - const { setMessages, scrollToFn } = createChatVirtualizer({ - messages, - offset: 100, - followOnAppend: true, - }) - - setMessages([...messages, { id: 'm-8' }]) - - expect(scrollToFn).not.toHaveBeenCalled() -}) - -test('anchorTo:end preserves a reading anchor with a stable key callback', () => { - const messages = Array.from({ length: 20 }, (_, i) => ({ id: `m-${i}` })) - const { virtualizer, scrollToFn, emitScroll } = createChatVirtualizer({ - messages, - offset: 400, - followOnAppend: false, - }) - const readingKey = virtualizer.getVirtualItemForOffset(400)!.key - virtualizer.getVirtualItems() - - for (let step = 1; step <= 2; step++) { - scrollToFn.mockClear() - messages.splice(0, 1) - messages.push({ id: `m-${19 + step}` }) - // Keep the same callback; the old edge keys have not been read yet. - virtualizer.setOptions(virtualizer.options) - virtualizer._willUpdate() - - const target = 400 - step * 50 - expect(scrollToFn.mock.calls.at(-1)?.[0]).toBe(target) - emitScroll(target) - expect(virtualizer.getVirtualItemForOffset(target)?.key).toBe(readingKey) - for (const item of virtualizer.getVirtualItems()) { - expect(item.key).toBe(messages[item.index]!.id) - } - } -}) - -test('followOnAppend keeps an end-pinned user at the end when items append', () => { - const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) - const { setMessages, scrollToFn } = createChatVirtualizer({ - messages, - offset: 50, - followOnAppend: true, - }) - - setMessages([...messages, { id: 'm-5' }]) - - expect(scrollToFn).toHaveBeenCalledTimes(1) - const [offset, options] = scrollToFn.mock.calls[0]! - expect(offset).toBe(100) - expect(options.behavior).toBe('auto') -}) - -test('followOnAppend accepts smooth behavior', () => { - const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) - const { setMessages, scrollToFn } = createChatVirtualizer({ - messages, - offset: 50, - followOnAppend: 'smooth', - }) - - setMessages([...messages, { id: 'm-5' }]) - - expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(scrollToFn.mock.calls[0]![1].behavior).toBe('smooth') -}) - -test.each([ - { removed: 1, appended: 1, behavior: true as const }, - { removed: 1, appended: 2, behavior: true as const }, - { removed: 3, appended: 3, behavior: true as const }, - { removed: 3, appended: 1, behavior: true as const }, - { removed: 1, appended: 1, behavior: 'smooth' as const }, -])( - 'followOnAppend follows a sliding window removing $removed and appending $appended with $behavior', - ({ removed, appended, behavior }) => { - const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) - const { setMessages, scrollToFn } = createChatVirtualizer({ - messages, - offset: 200, - followOnAppend: behavior, - }) - const nextMessages = [ - ...messages.slice(removed), - ...Array.from({ length: appended }, (_, i) => ({ id: `m-${8 + i}` })), - ] - - setMessages(nextMessages) - - expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(scrollToFn.mock.calls[0]![0]).toBe(nextMessages.length * 50 - 200) - expect(scrollToFn.mock.calls[0]![1].behavior).toBe( - behavior === true ? 'auto' : behavior, - ) - }, -) - -test.each([ - { offset: 100, followOnAppend: true, threshold: 1, target: 50 }, - { offset: 200, followOnAppend: false, threshold: 1, target: 150 }, - { offset: 195, followOnAppend: true, threshold: 4, target: 145 }, - { offset: 196, followOnAppend: true, threshold: 4, target: 200 }, -])( - 'followOnAppend respects offset $offset, enabled $followOnAppend and threshold $threshold for a sliding window', - ({ offset, followOnAppend, threshold, target }) => { - const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) - const { setMessages, scrollToFn } = createChatVirtualizer({ - messages, - offset, - followOnAppend, - threshold, - }) - - setMessages([...messages.slice(1), { id: 'm-8' }]) - - expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(scrollToFn.mock.calls[0]![0]).toBe(target) - }, -) - -test('followOnAppend stays pinned across sliding updates without scroll events', () => { - const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) - const { virtualizer, setMessages, scrollToFn } = createChatVirtualizer({ - messages, - offset: 200, - followOnAppend: true, - }) - - // Equal-size append + trim leaves the DOM offset unchanged, so no scroll event fires. - setMessages([...messages.slice(1), { id: 'm-8' }]) - expect(virtualizer.isAtEnd()).toBe(true) - - setMessages([...messages.slice(2), { id: 'm-8' }, { id: 'm-9' }]) - expect(virtualizer.isAtEnd()).toBe(true) - expect(scrollToFn.mock.calls.at(-1)![0]).toBe(200) -}) - -test('followOnAppend stays pinned with a stable key callback', () => { - const messages = Array.from({ length: 20 }, (_, i) => ({ id: `m-${i}` })) - const { virtualizer, scrollToFn } = createChatVirtualizer({ - messages, - offset: 800, - followOnAppend: true, - }) - virtualizer.getVirtualItems() - - for (let step = 1; step <= 2; step++) { - scrollToFn.mockClear() - messages.splice(0, 1) - messages.push({ id: `m-${19 + step}` }) - virtualizer.setOptions(virtualizer.options) - virtualizer._willUpdate() - - expect(scrollToFn.mock.calls.at(-1)?.[0]).toBe(800) - // A no-op end scroll produces no browser scroll event. - expect(virtualizer.isAtEnd()).toBe(true) - virtualizer.getVirtualItems() - } -}) - -test.each([ - { name: 'replacement', ids: [8, 9, 10, 11, 12, 13, 14, 15], target: null }, - { name: 'reorder', ids: [1, 0, 2, 3, 4, 5, 7, 6], target: null }, - { name: 'rotation', ids: [1, 2, 3, 4, 5, 6, 7, 0], target: 150 }, - { name: 'rotation with append', ids: [2, 3, 4, 5, 6, 7, 0, 8], target: 100 }, - { name: 'reordered overlap', ids: [1, 3, 2, 4, 5, 6, 7, 8], target: 150 }, - { name: 'trim only', ids: [1, 2, 3, 4, 5, 6, 7], target: 150 }, - { - name: 'prepend and trim tail', - ids: [-1, 0, 1, 2, 3, 4, 5, 6], - target: 250, - }, -])('followOnAppend does not follow $name', ({ ids, target }) => { - const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) - const { setMessages, scrollToFn } = createChatVirtualizer({ - messages, - offset: 200, - followOnAppend: true, - }) - - setMessages(ids.map((id) => ({ id: `m-${id}` }))) - - if (target === null) { - expect(scrollToFn).not.toHaveBeenCalled() - } else { - expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(scrollToFn.mock.calls[0]![0]).toBe(target) - expect(scrollToFn.mock.calls[0]![1].behavior).toBeUndefined() - } -}) - -test('anchorTo:end keeps a pinned streaming message pinned as it grows', () => { - const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) - const { virtualizer, scrollElement, scrollToFn } = createChatVirtualizer({ - messages, - offset: 50, - }) - - ;(scrollElement as any).scrollHeight = 320 - virtualizer.resizeItem(4, 120) - - expect(scrollToFn).toHaveBeenCalledTimes(1) - const [offset, options] = scrollToFn.mock.calls[0]! - expect(offset).toBe(50) - expect(options.adjustments).toBe(70) -}) - -test('anchorTo:end stays pinned across consecutive resizes when the scrollTop write is clamped', () => { - const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) - const { virtualizer, scrollToFn } = createChatVirtualizer({ - messages, - offset: 50, - }) - - // First growth tick. The DOM `scrollTop` write may be clamped because the - // consumer hasn't grown the sizer yet (`notify()` runs after the - // adjustment in `resizeItem`), so no scroll event fires — `scrollToFn` - // here is a no-op mock, mirroring that. - virtualizer.resizeItem(4, 120) - expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(scrollToFn.mock.calls[0]![1].adjustments).toBe(70) - scrollToFn.mockClear() - - // Second growth tick with no scroll event in between. Before the fix, - // `getVirtualDistanceFromEnd()` would compute against the stale - // `scrollOffset` (50) and a grown `getTotalSize()` (320), conclude we had - // drifted 70 px from the end (> `scrollEndThreshold: 1`), and skip the - // adjustment — drifting forever from tick 2 onward. - virtualizer.resizeItem(4, 200) - expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(scrollToFn.mock.calls[0]![0]).toBe(120) - expect(scrollToFn.mock.calls[0]![1].adjustments).toBe(80) -}) - -test('anchorTo:end does not follow streaming growth when user is away from end', () => { - const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) - const { virtualizer, scrollToFn } = createChatVirtualizer({ - messages, - offset: 50, - }) - - virtualizer.resizeItem(7, 120) - - expect(scrollToFn).not.toHaveBeenCalled() -}) - -test('isAtEnd and getDistanceFromEnd use the scroll element max offset', () => { - const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) - const { virtualizer } = createChatVirtualizer({ - messages, - offset: 40, - threshold: 15, - }) - - expect(virtualizer.getDistanceFromEnd()).toBe(10) - expect(virtualizer.isAtEnd()).toBe(true) - expect(virtualizer.isAtEnd(5)).toBe(false) -}) - -test('takeSnapshot: returns measured items only, restorable via initialMeasurementsCache', () => { - const v1 = new Virtualizer({ - count: 20, - estimateSize: () => 50, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - v1['getMeasurements']() - - // No measurements yet → empty snapshot - expect(v1.takeSnapshot()).toEqual([]) - - // Measure a few items - v1.resizeItem(0, 80) - v1.resizeItem(1, 60) - v1.resizeItem(2, 100) - - const snapshot = v1.takeSnapshot() - expect(snapshot.length).toBe(3) - expect(snapshot[0]!.size).toBe(80) - expect(snapshot[1]!.size).toBe(60) - expect(snapshot[2]!.size).toBe(100) - // snapshot entries are plain objects (not Proxy refs) - expect(Object.keys(snapshot[0]!).sort()).toEqual([ - 'end', - 'index', - 'key', - 'lane', - 'size', - 'start', - ]) - - // Restore: pass snapshot to a fresh virtualizer - const v2 = new Virtualizer({ - count: 20, - estimateSize: () => 50, - initialMeasurementsCache: snapshot, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - const m2 = v2['getMeasurements']() - // Restored sizes match the snapshot - expect(m2[0]!.size).toBe(80) - expect(m2[1]!.size).toBe(60) - expect(m2[2]!.size).toBe(100) - // Unmeasured items fall back to estimateSize - expect(m2[5]!.size).toBe(50) -}) - -test('takeSnapshot: works with lanes>1 too', () => { - const v = new Virtualizer({ - count: 6, - lanes: 2, - estimateSize: () => 50, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - v['getMeasurements']() - v.resizeItem(0, 80) - v.resizeItem(1, 90) - const snap = v.takeSnapshot() - expect(snap.length).toBe(2) - expect(snap[0]!.size).toBe(80) - expect(snap[1]!.size).toBe(90) -}) - -test('reconcileScroll: smooth scroll retargets remain smooth while distance > viewport', () => { - // When target drifts during a smooth scroll (because newly visible items - // measured in and shifted positions), the prior behavior snapped to - // behavior:'auto' on the first retarget. New behavior: keep smooth while - // we're still more than a viewport away, snap only on final approach. - const { rafCallbacks, mockScrollElement, scrollToFn } = - createMockEnvironment() - const virtualizer = new Virtualizer({ - count: 10000, - estimateSize: () => 50, - getScrollElement: () => mockScrollElement, - scrollToFn, - observeElementRect: (_inst, cb) => { - cb({ width: 400, height: 600 }) - return () => {} - }, - observeElementOffset: (_inst, cb) => { - cb(0, false) - return () => {} - }, - }) - virtualizer._willUpdate() - scrollToFn.mockClear() - - virtualizer.scrollToIndex(5000, { behavior: 'smooth' }) - // First call: smooth, with our best estimate target - const firstCall = scrollToFn.mock.calls[0] - expect(firstCall![1].behavior).toBe('smooth') - - // Simulate a measurement that moved the target. Force resizeItem at a - // visible-enough position so getOffsetForIndex(5000) returns a different - // value than what scrollState.lastTargetOffset has. - virtualizer.resizeItem(0, 80) - - // Now trigger the reconcile RAF - rafCallbacks.forEach((cb) => cb(0)) - - // The reconcile retarget should be smooth (we're far from target). - const lastCall = scrollToFn.mock.calls[scrollToFn.mock.calls.length - 1] - expect(lastCall![1].behavior).toBe('smooth') -}) - -test('lazy fast path: lanes>1 still uses eager path (regression guard)', () => { - const v = new Virtualizer({ - count: 10, - lanes: 2, - estimateSize: () => 50, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - }) - const m = v['getMeasurements']() - // Eager array, so m is a real Array; both lanes present - const lanes = new Set(m.map((x) => x.lane)) - expect(lanes.has(0)).toBe(true) - expect(lanes.has(1)).toBe(true) -}) - -test('setOptions: explicit value overrides default', () => { - const virtualizer = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => null, - scrollToFn: vi.fn(), - observeElementRect: vi.fn(), - observeElementOffset: vi.fn(), - overscan: 7, - gap: 12, - lanes: 3, - }) - - expect(virtualizer.options.overscan).toBe(7) - expect(virtualizer.options.gap).toBe(12) - expect(virtualizer.options.lanes).toBe(3) -}) - -// ─── elementScroll / windowScroll public exports ───────────────────────────── - -function makeBaseInstance(scrollEl: any, opts: any = {}) { - return { - scrollElement: scrollEl, - options: { - horizontal: false, - ...opts, - }, - } as any -} - -test('elementScroll: calls scrollTo with top + behavior on the scroll element', () => { - const scrollTo = vi.fn() - const scrollEl = { scrollTo } - elementScroll(100, { behavior: 'smooth' }, makeBaseInstance(scrollEl) as any) - expect(scrollTo).toHaveBeenCalledWith({ top: 100, behavior: 'smooth' }) -}) - -test('elementScroll: applies adjustments offset', () => { - const scrollTo = vi.fn() - const scrollEl = { scrollTo } - elementScroll( - 100, - { adjustments: 50, behavior: 'auto' }, - makeBaseInstance(scrollEl) as any, - ) - expect(scrollTo).toHaveBeenCalledWith({ top: 150, behavior: 'auto' }) -}) - -test('elementScroll: uses left when horizontal is true', () => { - const scrollTo = vi.fn() - const scrollEl = { scrollTo } - elementScroll( - 100, - { behavior: 'auto' }, - makeBaseInstance(scrollEl, { horizontal: true }) as any, - ) - expect(scrollTo).toHaveBeenCalledWith({ left: 100, behavior: 'auto' }) -}) - -test('windowScroll: calls scrollTo with top + behavior on the window', () => { - const scrollTo = vi.fn() - const win = { scrollTo } - windowScroll(250, { behavior: 'smooth' }, makeBaseInstance(win) as any) - expect(scrollTo).toHaveBeenCalledWith({ top: 250, behavior: 'smooth' }) -}) - -test('windowScroll: applies adjustments + horizontal', () => { - const scrollTo = vi.fn() - const win = { scrollTo } - windowScroll( - 250, - { adjustments: -10, behavior: 'auto' }, - makeBaseInstance(win, { horizontal: true }) as any, - ) - expect(scrollTo).toHaveBeenCalledWith({ left: 240, behavior: 'auto' }) -}) - -test('elementScroll / windowScroll: no-op when scrollElement is null', () => { - expect(() => - elementScroll(100, {}, makeBaseInstance(null) as any), - ).not.toThrow() - expect(() => - windowScroll(100, {}, makeBaseInstance(null) as any), - ).not.toThrow() -}) - -// ─── observeElementOffset / observeWindowOffset ────────────────────────────── - -function makeObserveInstance( - element: any, - opts: { - horizontal?: boolean - isRtl?: boolean - useScrollendEvent?: boolean - isScrollingResetDelay?: number - } = {}, - targetWindow: any = { - setTimeout: globalThis.setTimeout.bind(globalThis), - clearTimeout: globalThis.clearTimeout.bind(globalThis), - }, -) { - return { - scrollElement: element, - targetWindow, - options: { - horizontal: false, - isRtl: false, - useScrollendEvent: false, - isScrollingResetDelay: 150, - ...opts, - }, - } as any -} - -test('observeElementOffset: returns undefined when scrollElement is null', () => { - const cb = vi.fn() - expect( - observeElementOffset(makeObserveInstance(null) as any, cb), - ).toBeUndefined() - expect(cb).not.toHaveBeenCalled() -}) - -test('observeElementOffset: attaches scroll listener and fires callback with scrollTop', () => { - const cb = vi.fn() - const listeners = new Map() - const el: any = { - scrollTop: 50, - scrollLeft: 0, - addEventListener: (name: string, fn: any) => listeners.set(name, fn), - removeEventListener: (name: string) => listeners.delete(name), - } - const cleanup = observeElementOffset(makeObserveInstance(el) as any, cb) - expect(listeners.has('scroll')).toBe(true) - // No scrollend listener by default - expect(listeners.has('scrollend')).toBe(false) - // Trigger scroll - listeners.get('scroll')!({} as Event) - expect(cb).toHaveBeenCalledWith(50, true) - cleanup?.() - expect(listeners.has('scroll')).toBe(false) -}) - -test('observeElementOffset: fallback reads offset after programmatic changes', () => { - vi.useFakeTimers() - try { - const cb = vi.fn() - const listeners = new Map() - const el: any = { - scrollTop: 50, - scrollLeft: 0, - addEventListener: (name: string, fn: any) => listeners.set(name, fn), - removeEventListener: (name: string) => listeners.delete(name), - } - observeElementOffset(makeObserveInstance(el) as any, cb) - - listeners.get('scroll')!({} as Event) - el.scrollTop = 125 - vi.advanceTimersByTime(150) - - expect(cb).toHaveBeenLastCalledWith(125, false) - } finally { - vi.useRealTimers() - } -}) - -// ─── cleanup resets the scroll flags ───────────────────────────────────────── -// The cancelled debounce is the only writer of `isScrolling = false`, and -// `cleanup()` also runs while the instance stays alive (element swap, -// `enabled: false`), so it has to reset the flags itself. - -const makeScrollFlagsVirtualizer = () => { - const MockResizeObserver = vi.fn(function () { - return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() } - }) - const mockWindow = { - requestAnimationFrame: vi.fn(), - cancelAnimationFrame: vi.fn(), - ResizeObserver: MockResizeObserver, - } - const makeElement = () => - ({ - scrollTop: 0, - scrollLeft: 0, - scrollWidth: 1000, - scrollHeight: 5000, - offsetWidth: 400, - offsetHeight: 600, - ownerDocument: { defaultView: mockWindow }, - }) as unknown as HTMLDivElement - - const first = makeElement() - const second = makeElement() - let element: HTMLDivElement | null = first - let emit: ((offset: number, isScrolling: boolean) => void) | null = null - - const virtualizer = new Virtualizer({ - count: 100, - estimateSize: () => 50, - getScrollElement: () => element, - scrollToFn: vi.fn(), - observeElementRect: (_instance, cb) => { - cb({ width: 400, height: 600 }) - return () => {} - }, - observeElementOffset: (_instance, cb) => { - emit = cb - return () => {} - }, - }) - - virtualizer._willUpdate() - - // Mid-scroll: this is the state the debounce used to clear on its own. - emit!(500, true) - - return { - virtualizer, - swapElement: () => { - element = second - virtualizer._willUpdate() - }, - disable: () => { - element = null - virtualizer._willUpdate() - }, - } -} - -test('cleanup resets the scroll flags when the scroll element is swapped', () => { - const { virtualizer, swapElement } = makeScrollFlagsVirtualizer() - - expect(virtualizer.isScrolling).toBe(true) - - swapElement() - - expect(virtualizer.isScrolling).toBe(false) - expect(virtualizer.scrollDirection).toBe(null) -}) - -test('cleanup resets the scroll flags when the scroll element goes away', () => { - const { virtualizer, disable } = makeScrollFlagsVirtualizer() - - expect(virtualizer.isScrolling).toBe(true) - - disable() - - expect(virtualizer.isScrolling).toBe(false) - expect(virtualizer.scrollDirection).toBe(null) -}) - -test('cleanup resets the scroll flags on unmount', () => { - const { virtualizer } = makeScrollFlagsVirtualizer() - - expect(virtualizer.isScrolling).toBe(true) - - virtualizer._didMount()() - - expect(virtualizer.isScrolling).toBe(false) - expect(virtualizer.scrollDirection).toBe(null) -}) - -test('observeElementOffset: cleanup drops the queued isScrolling reset', () => { - vi.useFakeTimers() - try { - const cb = vi.fn() - const listeners = new Map() - const el: any = { - scrollTop: 50, - scrollLeft: 0, - addEventListener: (name: string, fn: any) => listeners.set(name, fn), - removeEventListener: (name: string) => listeners.delete(name), - } - const cleanup = observeElementOffset(makeObserveInstance(el) as any, cb) - - // Each scroll arms a debounce that resets isScrolling to false. - listeners.get('scroll')!({} as Event) - expect(cb).toHaveBeenCalledWith(50, true) - cb.mockClear() - - // Tearing down inside that window must not leave the reset queued — - // it would arrive after the consumer stopped listening. - cleanup?.() - vi.advanceTimersByTime(1000) - - expect(cb).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } -}) - -test('observeElementOffset: reads scrollLeft + applies isRtl when horizontal', () => { - const cb = vi.fn() - const listeners = new Map() - const el: any = { - scrollTop: 0, - scrollLeft: 80, - addEventListener: (name: string, fn: any) => listeners.set(name, fn), - removeEventListener: (name: string) => listeners.delete(name), - } - observeElementOffset( - makeObserveInstance(el, { horizontal: true, isRtl: true }) as any, - cb, - ) - listeners.get('scroll')!({} as Event) - // isRtl flips sign - expect(cb).toHaveBeenCalledWith(-80, true) -}) - -test('observeWindowOffset: returns undefined when scrollElement is null', () => { - const cb = vi.fn() - expect( - observeWindowOffset(makeObserveInstance(null) as any, cb), - ).toBeUndefined() -}) - -test('observeWindowOffset: attaches scroll listener and fires callback with scrollY', () => { - const cb = vi.fn() - const listeners = new Map() - const win: any = { - scrollX: 0, - scrollY: 120, - addEventListener: (name: string, fn: any) => listeners.set(name, fn), - removeEventListener: (name: string) => listeners.delete(name), - } - const cleanup = observeWindowOffset(makeObserveInstance(win) as any, cb) - expect(listeners.has('scroll')).toBe(true) - listeners.get('scroll')!({} as Event) - expect(cb).toHaveBeenCalledWith(120, true) - cleanup?.() - expect(listeners.has('scroll')).toBe(false) -}) - -// ─── Public-exports lockdown ───────────────────────────────────────────────── -// If any of these go missing the next minor bump silently breaks consumers. - -test('public runtime exports from @tanstack/virtual-core', async () => { - const mod = await import('../src/index') - // Class + helpers - expect(typeof mod.Virtualizer).toBe('function') - expect(typeof mod.defaultKeyExtractor).toBe('function') - expect(typeof mod.defaultRangeExtractor).toBe('function') - // Observers - expect(typeof mod.observeElementRect).toBe('function') - expect(typeof mod.observeWindowRect).toBe('function') - expect(typeof mod.observeElementOffset).toBe('function') - expect(typeof mod.observeWindowOffset).toBe('function') - // Scrollers - expect(typeof mod.elementScroll).toBe('function') - expect(typeof mod.windowScroll).toBe('function') - // Measurement - expect(typeof mod.measureElement).toBe('function') - // Utilities (historically re-exported from utils) - expect(typeof mod.memo).toBe('function') - expect(typeof mod.debounce).toBe('function') - expect(typeof mod.notUndefined).toBe('function') - expect(typeof mod.approxEqual).toBe('function') -}) - -test('observeWindowOffset: reads scrollX when horizontal', () => { - const cb = vi.fn() - const listeners = new Map() - const win: any = { - scrollX: 75, - scrollY: 0, - addEventListener: (name: string, fn: any) => listeners.set(name, fn), - removeEventListener: (name: string) => listeners.delete(name), - } - observeWindowOffset(makeObserveInstance(win, { horizontal: true }) as any, cb) - listeners.get('scroll')!({} as Event) - expect(cb).toHaveBeenCalledWith(75, true) -}) - -// ─── #1229: negative tracked scrollOffset must not survive compensation ───── -// anchorTo: 'end' + element scrolling + items measuring smaller than their -// estimates + content shorter than the viewport. The end-anchor compensation -// applies a negative delta; the DOM clamps the scrollTop write to 0 and an -// unscrollable element never fires a scroll event, so an unclamped tracked -// offset would stay negative forever — phantom getDistanceFromEnd(), wedged -// _flushIosDeferredIfReady. - -const makeUnscrollableElement = () => { - // Content fits the viewport: the browser clamps scrollHeight to - // clientHeight, so maxScrollOffset = 0 and no scroll event can fire. - const el = { - scrollTop: 0, - scrollLeft: 0, - scrollWidth: 400, - scrollHeight: 600, - clientWidth: 400, - clientHeight: 600, - scrollTo: ({ top }: { top: number }) => { - el.scrollTop = Math.max(0, Math.min(top, 0)) - }, - } - return el as unknown as HTMLDivElement -} - -const unscrollableOptions = ( - scrollElement: HTMLDivElement, - offsetCbRef: { - current: ((offset: number, isScrolling: boolean) => void) | null - }, -) => ({ - count: 5, - // Estimates larger than the real measured sizes - estimateSize: () => 100, - anchorTo: 'end' as const, - getScrollElement: () => scrollElement, - scrollToFn: ( - offset: number, - { - adjustments = 0, - behavior, - }: { adjustments?: number; behavior?: ScrollBehavior }, - instance: Virtualizer, - ) => { - instance.scrollElement?.scrollTo?.({ top: offset + adjustments, behavior }) - }, - observeElementRect: ( - _instance: unknown, - cb: (rect: { width: number; height: number }) => void, - ) => { - cb({ width: 400, height: 600 }) - return () => {} - }, - observeElementOffset: ( - _instance: unknown, - cb: (offset: number, isScrolling: boolean) => void, - ) => { - offsetCbRef.current = cb - cb(0, false) - return () => {} - }, -}) - -test('anchorTo end: shrink compensation clamps tracked scrollOffset at 0 when content fits the viewport (#1229)', () => { - const scrollElement = makeUnscrollableElement() - const offsetCbRef = { current: null as any } - const virtualizer = new Virtualizer( - unscrollableOptions(scrollElement, offsetCbRef), - ) - - virtualizer._willUpdate() - virtualizer.getVirtualItems() - - // Real sizes come in smaller than the estimates (98 vs 100) - for (let i = 0; i < 5; i++) { - virtualizer.resizeItem(i, 98) - } - - // The element is trivially at its end: 490px of content in a 600px - // viewport, pinned at scrollTop 0. - expect(scrollElement.scrollTop).toBe(0) - expect(virtualizer.scrollOffset).toBe(0) - expect(virtualizer.getDistanceFromEnd()).toBe(0) - expect(virtualizer.isAtEnd()).toBe(true) -}) - -test('anchorTo end: setOptions re-anchor clamps tracked scrollOffset at 0 (#1229)', () => { - const scrollElement = makeUnscrollableElement() - const offsetCbRef = { current: null as any } - const options = unscrollableOptions(scrollElement, offsetCbRef) - const virtualizer = new Virtualizer(options) - - virtualizer._willUpdate() - virtualizer.getVirtualItems() - - // Simulate a transiently negative offset reported by a real scroll event - // (elastic overscroll) landing right before an options update. - offsetCbRef.current!(-10, false) - expect(virtualizer.scrollOffset).toBe(-10) - - // Trim the last item: edge keys change, triggering the end-anchor - // re-resolution in setOptions. The anchor item (index 0) still starts at - // 0, so the unclamped offset would be written back as -10. - virtualizer.setOptions({ ...options, count: 4 }) - - expect(virtualizer.scrollOffset).toBe(0) -}) - -// ─── #1218: viewport-spanning item growth must not drift the viewport ───────── -// A streaming chat message whose top is scrolled above the fold but whose -// bottom extends below it grows at its bottom. That growth happens *below* the -// anchor point (the fold), so scrollTop must stay put. Only an item that is -// ENTIRELY above the fold should shift scrollTop on re-measure. -function makeAdjustmentVirtualizer(scrollTop: number) { - const scrollToFn = vi.fn(elementScroll) - let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = - null - const el = { - scrollTop, - scrollLeft: 0, - scrollHeight: 100000, - clientHeight: 200, - offsetHeight: 200, - scrollTo: vi.fn(({ top }: { top: number }) => { - el.scrollTop = top - }), - addEventListener: () => {}, - removeEventListener: () => {}, - } - const v = new Virtualizer({ - count: 50, - estimateSize: () => 50, - getScrollElement: () => el as any, - scrollToFn, - observeElementRect: (_i, cb) => { - cb({ width: 300, height: 200 }) - }, - observeElementOffset: (_i, cb) => { - scrollCallback = cb - cb(scrollTop, false) // settled, not scrolling - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - return { v, scrollToFn } -} - -test('#1218: re-measuring a viewport-spanning item does not drift scroll', () => { - // Viewport 175..375, items 50px by estimate. Item 3: start=150, end=200 — - // spans the fold at 175. - const { v, scrollToFn } = makeAdjustmentVirtualizer(175) - v.resizeItem(3, 60) // seed the size cache (item still spans the fold) - scrollToFn.mockClear() - - const before = v.scrollOffset - v.resizeItem(3, 160) // stream: grow at the bottom by 100 (below the fold) - - expect(v.scrollOffset).toBe(before) - expect(scrollToFn).not.toHaveBeenCalled() -}) - -test('#1218: re-measuring an entirely-above item still anchors (no regression)', () => { - // Item 1: start=50, end=100 — fully above the fold at 175. - const { v } = makeAdjustmentVirtualizer(175) - v.resizeItem(1, 60) - const before = v.scrollOffset! - - v.resizeItem(1, 160) // grows by 100 entirely above the fold - - // scrollTop shifts by the delta so content below the fold stays put. - expect(v.scrollOffset).toBe(before + 100) -}) - -test('#1218: first measurement of a spanning item still compensates', () => { - // First measurement (estimate→actual) always compensates an above-fold top, - // regardless of spanning — the estimated block sat above the fold. Item 3 - // starts at 150 (above fold 175) and has never been measured. - const { v } = makeAdjustmentVirtualizer(175) - const before = v.scrollOffset! - - v.resizeItem(3, 120) // first measure: 50 -> 120, delta +70 - - expect(v.scrollOffset).toBe(before + 70) -}) - -// ─── #1258: compensation write clamped by a not-yet-grown sizer ───────────── -// 5 items × 50px + paddingEnd 80 = 330px sizer, 200px viewport → the bottom is -// scrollTop 130. When the last item grows by 70, resizeItem writes 200, but -// the consumer has not committed the 400px sizer yet, so the browser clamps -// the write to the current max (130) — exactly paddingEnd short once the -// overflowing item alone has extended scrollHeight. The write must be -// re-issued once the sizer has grown. - -function clampedGrowthSetup() { - const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) - const setup = createChatVirtualizer({ - messages, - offset: 130, - paddingEnd: 80, - threshold: 0, - }) - setup.virtualizer.resizeItem(4, 120) - // The end-anchor compensation fired against the not-yet-grown sizer. - expect(setup.scrollToFn).toHaveBeenCalledTimes(1) - expect(setup.scrollToFn.mock.calls[0]![1].adjustments).toBe(70) - return setup -} - -test('#1258: clamped end-anchor write is re-issued after the read-back once the sizer grows', () => { - const { virtualizer, scrollElement, scrollToFn, emitScroll } = - clampedGrowthSetup() - - // Browser read-back of the clamped write: it landed on the old max. - emitScroll(130) - // Consumer commits the grown sizer, then the layout effect runs. - ;(scrollElement as any).scrollHeight = 400 - virtualizer._willUpdate() - - expect(scrollToFn).toHaveBeenCalledTimes(2) - expect(scrollToFn.mock.calls[1]![0]).toBe(200) -}) - -test('#1258: clamped end-anchor write is re-issued in a sync render before the read-back', () => { - const { virtualizer, scrollElement, scrollToFn } = clampedGrowthSetup() - - // flushSync path: the layout effect runs before the scroll event arrives. - ;(scrollElement as any).scrollHeight = 400 - virtualizer._willUpdate() - - expect(scrollToFn).toHaveBeenCalledTimes(2) - expect(scrollToFn.mock.calls[1]![0]).toBe(200) -}) - -test('#1258: a render without sizer growth does not re-issue; a later one does', () => { - const { virtualizer, scrollElement, scrollToFn, emitScroll } = - clampedGrowthSetup() - - emitScroll(130) - virtualizer._willUpdate() - expect(scrollToFn).toHaveBeenCalledTimes(1) - ;(scrollElement as any).scrollHeight = 400 - virtualizer._willUpdate() - expect(scrollToFn).toHaveBeenCalledTimes(2) -}) - -test('#1258: a real gesture after the clamp cancels the pending re-issue', () => { - const { virtualizer, scrollElement, scrollToFn, emitScroll } = - clampedGrowthSetup() - - emitScroll(130) - // The user scrolls up to read history. - emitScroll(60, true) - ;(scrollElement as any).scrollHeight = 400 - virtualizer._willUpdate() - - expect(scrollToFn).toHaveBeenCalledTimes(1) -}) - -test('#1258: an unclamped compensation write is not re-issued when the sizer later grows', () => { - // 8 × 50 = 400px, viewport 200, user reading at 100. Item 0 (above the - // fold) re-measures +10 → compensation writes 110, well within max 200. - const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) - const { virtualizer, scrollElement, scrollToFn, emitScroll } = - createChatVirtualizer({ messages, offset: 100 }) - - virtualizer.resizeItem(0, 60) - expect(scrollToFn).toHaveBeenCalledTimes(1) - emitScroll(110) - ;(scrollElement as any).scrollHeight = 450 - virtualizer._willUpdate() - - expect(scrollToFn).toHaveBeenCalledTimes(1) -}) - -test('#1266: a consumer that grows the sizer synchronously in onChange gets the clamped write re-issued without a re-render', () => { - // Direct DOM updates: the adapter sets the container height inside - // onChange, and with an unchanged range nothing re-renders afterwards. - const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) - const { virtualizer, scrollElement, scrollToFn } = createChatVirtualizer({ - messages, - offset: 130, - paddingEnd: 80, - threshold: 0, - }) - virtualizer.options.onChange = (instance) => { - ;(scrollElement as any).scrollHeight = instance.getTotalSize() - } - - virtualizer.resizeItem(4, 120) - - expect(scrollToFn).toHaveBeenCalledTimes(2) - expect(scrollToFn.mock.calls[0]![1].adjustments).toBe(70) - expect(scrollToFn.mock.calls[1]![0]).toBe(200) - expect(virtualizer['_clampedAdjustment']).toBeNull() -}) - -test('#1258: both retry sites firing in one sync pass write exactly once', () => { - // flushSync consumer: onChange grows the sizer AND runs the layout effect - // (_willUpdate) synchronously inside notify. The retry after notify must then - // find nothing pending — no double write. - const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) - const { virtualizer, scrollElement, scrollToFn } = createChatVirtualizer({ - messages, - offset: 130, - paddingEnd: 80, - threshold: 0, - }) - virtualizer.options.onChange = (instance) => { - ;(scrollElement as any).scrollHeight = instance.getTotalSize() - instance._willUpdate() - } - - virtualizer.resizeItem(4, 120) - - expect(scrollToFn).toHaveBeenCalledTimes(2) - expect(scrollToFn.mock.calls[1]![0]).toBe(200) - expect(virtualizer['_clampedAdjustment']).toBeNull() -}) - -test('#1258: a partially grown sizer re-issues and stays pending until the target fits', () => { - const { virtualizer, scrollElement, scrollToFn, emitScroll } = - clampedGrowthSetup() - emitScroll(130) - - // Sizer grew only to 360 → max 160, still short of the 200 target. - ;(scrollElement as any).scrollHeight = 360 - virtualizer._willUpdate() - expect(scrollToFn).toHaveBeenCalledTimes(2) - expect(scrollToFn.mock.calls[1]![0]).toBe(200) - expect(virtualizer['_clampedAdjustment']).toEqual({ - target: 200, - maxAtWrite: 160, - }) - - // The browser clamps that write to the new max; its read-back keeps it pending. - emitScroll(160) - expect(virtualizer['_clampedAdjustment']).not.toBeNull() - ;(scrollElement as any).scrollHeight = 400 - virtualizer._willUpdate() - expect(scrollToFn).toHaveBeenCalledTimes(3) - expect(scrollToFn.mock.calls[2]![0]).toBe(200) - expect(virtualizer['_clampedAdjustment']).toBeNull() -}) - -test('#1258: cleanup drops a pending clamped write', () => { - const { virtualizer } = clampedGrowthSetup() - expect(virtualizer['_clampedAdjustment']).not.toBeNull() - - virtualizer['cleanup']() - - expect(virtualizer['_clampedAdjustment']).toBeNull() -}) - -// ─── a prepend must not strand a travelling smooth scroll ──────────────────── -// The end-anchor prepend sync in _willUpdate writes scrollTop instantly. That -// cancels a smooth scrollToIndex the browser is still animating, and Chromium -// drops a smooth request re-issued in the frame right after that cancel, so -// re-driving from reconcileScroll cannot recover it. While a smooth -// programmatic scroll is travelling, the sync is skipped: its target is -// index-based and recomputes against the new layout, so the animation simply -// continues. Browser coverage: react-virtual e2e/app/test/smooth-prepend.spec.ts. - -function createSmoothPrependVirtualizer() { - const mockWindow = { - requestAnimationFrame: vi.fn(() => 1), - cancelAnimationFrame: vi.fn(), - performance: { now: () => Date.now() }, - ResizeObserver: vi.fn(function () { - return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() } - }), - } - const el = { - scrollTop: 0, - scrollLeft: 0, - scrollWidth: 400, - scrollHeight: 10000, // 200 x 50 - clientWidth: 400, - clientHeight: 300, - offsetWidth: 400, - offsetHeight: 300, - ownerDocument: { defaultView: mockWindow }, - scrollTo: vi.fn(), - } as unknown as HTMLDivElement - - const scrollToFn = vi.fn() - let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = - null - const messages = Array.from({ length: 200 }, (_, i) => `m-${i}`) - const v = new Virtualizer({ - count: messages.length, - estimateSize: () => 50, - anchorTo: 'end', - getItemKey: (i) => messages[i]!, - getScrollElement: () => el, - scrollToFn, - observeElementRect: (_inst, cb) => { - cb({ width: 400, height: 300 }) - return () => {} - }, - observeElementOffset: (_inst, cb) => { - scrollCallback = cb - cb(9700, false) - return () => {} - }, - }) - v._willUpdate() - v.getVirtualItems() - scrollToFn.mockClear() // drop the mount sync write - - return { - v, - scrollToFn, - scroll: (offset: number, isScrolling = true) => { - scrollCallback!(offset, isScrolling) - v.getVirtualItems() - }, - // 5 messages x 50px land above the reader. - prepend: () => { - messages.unshift('m--5', 'm--4', 'm--3', 'm--2', 'm--1') - ;(el as any).scrollHeight = 10250 - v.setOptions({ - ...v.options, - count: messages.length, - getItemKey: (i: number) => messages[i]!, - }) - v._willUpdate() - }, - } -} - -test('a prepend mid-flight leaves a travelling smooth scrollToIndex alone', () => { - const { v, scrollToFn, scroll, prepend } = createSmoothPrependVirtualizer() - - v.scrollToIndex(0, { behavior: 'smooth' }) - expect(scrollToFn.mock.calls[0]![0]).toBe(0) - scroll(3500) // the browser is mid-animation - scrollToFn.mockClear() - - prepend() - - // No instant scrollTop write that would cancel the animation... - expect(scrollToFn).not.toHaveBeenCalled() - // ...and the journey is still live. - expect(v['scrollState']).toMatchObject({ index: 0, phase: 'smooth' }) -}) - -test('a prepend right after a smooth scroll landed still syncs the anchor', () => { - const { v, scrollToFn, scroll, prepend } = createSmoothPrependVirtualizer() - - v.scrollToIndex(100, { behavior: 'smooth' }) - const target = scrollToFn.mock.calls[0]![0] as number - expect(target).toBe(5000) - // Arrived; reconcileScroll has not retired scrollState yet. - scroll(target, false) - scrollToFn.mockClear() - - prepend() - - // The reader's position is preserved: the DOM is synced to the shifted offset. - expect(scrollToFn.mock.calls.at(-1)?.[0]).toBe(target + 250) -}) - -// ─── #1257: paddingEnd must not make scrollToIndex(last) overshoot the last item ───────────── -// When paddingEnd > 0, getOffsetForIndex(last, 'end') was returning the raw -// DOM max scroll offset (scrollHeight - clientHeight), which equals -// (content + paddingEnd - clientHeight). This caused scrollToIndex(last) to -// scroll past the rendered end of the last item. The fix uses -// getMaxScrollOffset() - paddingEnd + scrollPaddingEnd (clamped to -// [0, getMaxScrollOffset()]), which with scrollPaddingEnd = 0 equals -// (content - clientHeight) — the offset that keeps the last item flush with -// the bottom of the viewport. - -test('#1257: scrollToIndex(last) with paddingEnd keeps the last item flush with the viewport bottom', () => { - // 5 items × 50px = 250px content, paddingEnd = 80, scrollMargin = 0 - // viewport = 200px → total scrollHeight = 330px (250 + 80) - // Expected virtual max scroll offset: - // getMaxScrollOffset() - paddingEnd = (330 - 200) - 80 = 50 - // Without the fix (using raw scrollHeight - clientHeight): - // 330 - 200 = 130 → overshoots by 80px (exactly the paddingEnd) - const mockScrollElement = { - scrollTop: 0, - scrollLeft: 0, - scrollWidth: 200, - scrollHeight: 330, // 250 (content) + 80 (paddingEnd) - clientWidth: 200, - clientHeight: 200, - offsetWidth: 200, - offsetHeight: 200, - ownerDocument: { defaultView: globalThis }, - scrollTo: vi.fn(), - } as unknown as HTMLDivElement - - const scrollToFn = vi.fn() - const virtualizer = new Virtualizer({ - count: 5, - estimateSize: () => 50, - paddingEnd: 80, - getScrollElement: () => mockScrollElement, - scrollToFn, - observeElementRect: (_instance, cb) => { - cb({ width: 200, height: 200 }) - return () => {} - }, - observeElementOffset: (_instance, cb) => { - cb(0, false) - return () => {} - }, - }) - - virtualizer._willUpdate() - scrollToFn.mockClear() - - // Scroll to last item with 'end' alignment - virtualizer.scrollToIndex(4, { align: 'end' }) - - // getMaxScrollOffset() = 330 - 200 = 130; minus paddingEnd(80) = 50 - expect(scrollToFn).toHaveBeenCalledWith( - 50, - expect.any(Object), - expect.any(Object), - ) -}) - -// ─── #1001: getMaxScrollOffset() preserves lane-max for multi-lane layouts ───────────── -// The fix uses getMaxScrollOffset() directly (rather than getTotalSize()), which -// preserves the lane-max behavior added in #1105 (#1001 fix). In multi-lane layouts -// where the last item lives in a shorter lane, getMaxScrollOffset() still absorbs -// DOM extras that aren't in our measurements. - -test('#1001: scrollToIndex(last) in a shorter lane uses getMaxScrollOffset() lane-max', () => { - // 2 lanes: lane 0 = [0] @300px (ends at 300), lane 1 = [1..3] @50px - // (last item ends at 150). getTotalSize() = 300 (lane-max), - // getMaxScrollOffset() = 300 - 200 = 100. - // The fix uses getMaxScrollOffset() - paddingEnd + scrollPaddingEnd = 100 - 0 + 0 = 100. - // Aligning to the last item's own end would give max(150 - 200, 0) = 0. - const mockScrollElement = { - scrollTop: 0, - scrollLeft: 0, - scrollWidth: 200, - scrollHeight: 300, - clientWidth: 200, - clientHeight: 200, - offsetWidth: 200, - offsetHeight: 200, - ownerDocument: { defaultView: globalThis }, - scrollTo: vi.fn(), - } as unknown as HTMLDivElement - - const scrollToFn = vi.fn() - const virtualizer = new Virtualizer({ - count: 4, - estimateSize: (index) => (index === 0 ? 300 : 50), - lanes: 2, - getScrollElement: () => mockScrollElement, - scrollToFn, - observeElementRect: (_instance, cb) => { - cb({ width: 200, height: 200 }) - return () => {} - }, - observeElementOffset: (_instance, cb) => { - cb(0, false) - return () => {} - }, - }) - - virtualizer._willUpdate() - scrollToFn.mockClear() - - expect(virtualizer.measurementsCache[3]).toMatchObject({ lane: 1, end: 150 }) - - virtualizer.scrollToIndex(3, { align: 'end' }) - - // getMaxScrollOffset() = 100; minus paddingEnd(0) = 100 - expect(scrollToFn).toHaveBeenCalledWith( - 100, - expect.any(Object), - expect.any(Object), - ) -}) - -// ─── #1257: paddingEnd=0 is a no-op ───────────── -test('#1257: scrollToIndex(last) with paddingEnd=0 uses getMaxScrollOffset() unchanged', () => { - const mockScrollElement = { - scrollTop: 0, - scrollLeft: 0, - scrollWidth: 200, - scrollHeight: 250, // 5 × 50px content, no paddingEnd - clientWidth: 200, - clientHeight: 200, - offsetWidth: 200, - offsetHeight: 200, - ownerDocument: { defaultView: globalThis }, - scrollTo: vi.fn(), - } as unknown as HTMLDivElement - - const scrollToFn = vi.fn() - const virtualizer = new Virtualizer({ - count: 5, - estimateSize: () => 50, - paddingEnd: 0, - getScrollElement: () => mockScrollElement, - scrollToFn, - observeElementRect: (_instance, cb) => { - cb({ width: 200, height: 200 }) - return () => {} - }, - observeElementOffset: (_instance, cb) => { - cb(0, false) - return () => {} - }, - }) - - virtualizer._willUpdate() - scrollToFn.mockClear() - - virtualizer.scrollToIndex(4, { align: 'end' }) - - // getMaxScrollOffset() = 250 - 200 = 50; minus paddingEnd(0) = 50 - expect(scrollToFn).toHaveBeenCalledWith( - 50, - expect.any(Object), - expect.any(Object), - ) -}) - -// ─── #1257: scrollPaddingEnd still applies to the last item ───────────── -// A sticky footer is usually reserved with paddingEnd and kept clear with -// scrollPaddingEnd. The last item must end above the footer, like any other -// end-aligned item. -test('#1257: scrollToIndex(last) with paddingEnd and scrollPaddingEnd keeps the last item above the footer', () => { - const mockScrollElement = { - scrollTop: 0, - scrollLeft: 0, - scrollWidth: 200, - scrollHeight: 310, // 250 (content) + 60 (paddingEnd) - clientWidth: 200, - clientHeight: 200, - offsetWidth: 200, - offsetHeight: 200, - ownerDocument: { defaultView: globalThis }, - scrollTo: vi.fn(), - } as unknown as HTMLDivElement - - const scrollToFn = vi.fn() - const virtualizer = new Virtualizer({ - count: 5, - estimateSize: () => 50, - paddingEnd: 60, - scrollPaddingEnd: 40, - getScrollElement: () => mockScrollElement, - scrollToFn, - observeElementRect: (_instance, cb) => { - cb({ width: 200, height: 200 }) - return () => {} - }, - observeElementOffset: (_instance, cb) => { - cb(0, false) - return () => {} - }, - }) - - virtualizer._willUpdate() - scrollToFn.mockClear() - - virtualizer.scrollToIndex(4, { align: 'end' }) - - // item.end(250) + scrollPaddingEnd(40) - viewport(200) = 90, below - // getMaxScrollOffset() = 110 (main used 110, paddingEnd alone gives 50) - expect(scrollToFn).toHaveBeenCalledWith( - 90, - expect.any(Object), - expect.any(Object), - ) - - scrollToFn.mockClear() - virtualizer.scrollToIndex(3, { align: 'end' }) - - // The item before it lands the same way: 200 + 40 - 200 = 40 - expect(scrollToFn).toHaveBeenCalledWith( - 40, - expect.any(Object), - expect.any(Object), - ) -}) - -// ─── #1257: scrollToEnd() still reaches the DOM bottom ───────────── -// scrollToEnd() must include paddingEnd so isAtEnd() holds afterwards -// (followOnAppend and "jump to latest" UI rely on it), even though -// scrollToIndex(last, 'end') now stops at the last item. -test('#1257: scrollToEnd() with paddingEnd scrolls to getMaxScrollOffset()', () => { - const mockScrollElement = { - scrollTop: 0, - scrollLeft: 0, - scrollWidth: 200, - scrollHeight: 310, // 250 (content) + 60 (paddingEnd) - clientWidth: 200, - clientHeight: 200, - offsetWidth: 200, - offsetHeight: 200, - ownerDocument: { defaultView: globalThis }, - scrollTo: vi.fn(), - } as unknown as HTMLDivElement - - const scrollToFn = vi.fn() - const virtualizer = new Virtualizer({ - count: 5, - estimateSize: () => 50, - paddingEnd: 60, - getScrollElement: () => mockScrollElement, - scrollToFn, - observeElementRect: (_instance, cb) => { - cb({ width: 200, height: 200 }) - return () => {} - }, - observeElementOffset: (_instance, cb) => { - cb(0, false) - return () => {} - }, - }) - - virtualizer._willUpdate() - scrollToFn.mockClear() - - virtualizer.scrollToEnd() - - // getMaxScrollOffset() = 310 - 200 = 110, paddingEnd included - expect(scrollToFn).toHaveBeenCalledWith( - 110, - expect.any(Object), - expect.any(Object), - ) -}) - -// ─── ResizeObserver batching ───────────────────────────────────────────────── - -function setupResizeBatch(onChange: (instance: any, sync: boolean) => void) { - let roCallback: ResizeObserverCallback | null = null - const mockWindow = { - requestAnimationFrame: vi.fn(), - cancelAnimationFrame: vi.fn(), - performance: { now: () => Date.now() }, - ResizeObserver: vi.fn(function (cb: ResizeObserverCallback) { - roCallback = cb - return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() } - }), - } - const el = { - scrollTop: 0, - scrollLeft: 0, - scrollWidth: 400, - scrollHeight: 100000, - offsetWidth: 400, - offsetHeight: 300, - clientHeight: 300, - ownerDocument: { defaultView: mockWindow }, - } as unknown as HTMLDivElement - const sizes = new Map() - let setOffset: (offset: number, isScrolling: boolean) => void = () => {} - const virtualizer = new Virtualizer({ - count: 20, - estimateSize: () => 90, - overscan: 0, - getScrollElement: () => el, - scrollToFn: (offset, { adjustments }) => { - el.scrollTop = offset + (adjustments ?? 0) - }, - measureElement: (node) => - sizes.get(Number((node as any).getAttribute('data-index')))!, - observeElementRect: (_instance, cb) => { - cb({ width: 400, height: 300 }) - return () => {} - }, - observeElementOffset: (_instance, cb) => { - setOffset = cb - cb(0, false) - return () => {} - }, - onChange, - }) - virtualizer._willUpdate() - virtualizer.getVirtualItems() - - const nodes = new Map() - const node = (i: number) => { - if (!nodes.has(i)) { - nodes.set(i, { - getAttribute: (name: string) => - name === 'data-index' ? String(i) : null, - isConnected: true, - }) - } - return nodes.get(i) - } - // Measure items 0..9 at 100px while at offset 0, then scroll to 450: - // item 3 spans 300..400 (entirely above), item 4 spans 400..500 (across - // the fold). - for (let i = 0; i < 10; i++) { - sizes.set(i, 100) - virtualizer.measureElement(node(i)) - } - el.scrollTop = 450 - setOffset(450, false) - virtualizer._willUpdate() - virtualizer.getVirtualItems() - - const deliver = (indexes: Array) => - roCallback!( - indexes.map( - (i) => ({ target: node(i) }) as unknown as ResizeObserverEntry, - ), - {} as ResizeObserver, - ) - return { virtualizer, sizes, node, deliver } -} - -test('ResizeObserver batch: later entries see starts updated by earlier ones (#1218)', () => { - const { virtualizer, sizes, deliver } = setupResizeBatch((instance) => { - instance.getVirtualItems() - }) - expect(virtualizer.getMeasurements()[4]!.start).toBe(400) - - // Item 3 grows above the fold: compensated (+100). Item 4 spans the fold, - // so its growth happens below the anchor point and must not move scroll. - sizes.set(3, 200) - sizes.set(4, 150) - deliver([3, 4]) - - expect(virtualizer.scrollOffset).toBe(550) -}) - -test('ResizeObserver batch: a sync re-render does not drop queued entries (#1036)', () => { - const notifies: Array = [] - const { virtualizer, sizes, node, deliver } = setupResizeBatch( - (instance, sync) => { - notifies.push(sync) - // A synchronous render that moves the range unmounts item 5. - if (sync) node(5).isConnected = false - instance.getVirtualItems() - }, - ) - - notifies.length = 0 - sizes.set(3, 200) - sizes.set(5, 140) - deliver([3, 5]) - - // Both entries are measured before the single (sync) notify. - expect(virtualizer.getMeasurements()[5]!.size).toBe(140) - expect(notifies).toEqual([true]) -}) - -// 100 rows estimated at 50px in a 200px viewport. scrollToFn writes the -// element and clamps like a browser; scroll events are delivered by hand, so a -// test can resize between a programmatic write and its read-back. -function setupHandDrivenScroll( - extra: Partial> = {}, -) { - const rafCallbacks: Array = [] - const mockWindow = { - requestAnimationFrame: (cb: FrameRequestCallback) => rafCallbacks.push(cb), - cancelAnimationFrame: () => {}, - } - const el = { - scrollTop: 0, - scrollLeft: 0, - scrollHeight: 5000, - clientHeight: 200, - offsetHeight: 200, - ownerDocument: { defaultView: mockWindow }, - } - let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = - null - // Like iOS during a touch: drop programmatic writes on the floor. - const browser = { ignoreWrites: false } - const v = new Virtualizer({ - count: 100, - estimateSize: () => 50, - getScrollElement: () => el as any, - scrollToFn: (offset, { adjustments = 0 }) => { - if (browser.ignoreWrites) return - el.scrollTop = Math.max( - 0, - Math.min(offset + adjustments, el.scrollHeight - el.clientHeight), - ) - }, - observeElementRect: (_inst, cb) => { - cb({ width: 400, height: 200 }) - return () => {} - }, - observeElementOffset: (_inst, cb) => { - scrollCallback = cb - cb(0, false) - return () => {} - }, - ...extra, - }) - v._willUpdate() - v['getMeasurements']() - const scroll = (offset: number, isScrolling: boolean) => - scrollCallback!(offset, isScrolling) - const frame = () => rafCallbacks.splice(0).forEach((cb) => cb(0)) - const settle = () => { - for (let i = 0; i < 10 && rafCallbacks.length > 0; i++) { - frame() - scroll(el.scrollTop, true) - } - scroll(el.scrollTop, false) - } - // Runs frames without scroll events, as a browser does when a write leaves - // the element where it was. - const frames = (n = 10) => { - for (let i = 0; i < n && rafCallbacks.length > 0; i++) { - rafCallbacks.splice(0).forEach((cb) => cb(0)) - } - } - return { v, el, scroll, frame, settle, frames, browser } -} - -test('a resize before the read-back of a scrollToIndex write is left to reconcile', () => { - // Scrolling up from 2000 to row 20 (1000), row 30 grows before the scroll - // event reports 1000. Compensating it against the stale 2000 used to write - // 2050, and reconcile (target unchanged) never rewrote it until the 5s - // safety valve. - const { v, el, scroll, settle } = setupHandDrivenScroll() - el.scrollTop = 2000 - scroll(2000, false) - - v.scrollToIndex(20, { align: 'start' }) - expect(el.scrollTop).toBe(1000) - v.resizeItem(30, 100) - expect(el.scrollTop).toBe(1000) - settle() - - expect(el.scrollTop).toBe(1000) - expect(v.scrollState).toBeNull() -}) - -test('a resize above the target before the read-back is picked up by reconcile', () => { - const { v, el, scroll, settle } = setupHandDrivenScroll() - el.scrollTop = 2000 - scroll(2000, false) - - v.scrollToIndex(20, { align: 'start' }) - v.resizeItem(10, 100) - // No compensation write before the read-back... - expect(el.scrollTop).toBe(1000) - settle() - // ...reconcile retargets to the row's new start. - expect(el.scrollTop).toBe(1050) - expect(v.scrollState).toBeNull() -}) - -test('rows at the old position measured during scrollToEnd do not push it off the end', () => { - // scrollToEnd writes 4800, but until the read-back the rendered rows are - // the ones at the top. They measure 20px smaller each; compensating them - // against the written offset would walk the view up from the end while - // the end target (the DOM max, sizer not re-rendered) stays put. - const { v, el, settle } = setupHandDrivenScroll() - - v.scrollToEnd() - expect(el.scrollTop).toBe(4800) - for (let i = 0; i < 5; i++) v.resizeItem(i, 30) - expect(el.scrollTop).toBe(4800) - settle() - - expect(el.scrollTop).toBe(4800) - expect(v.scrollState).toBeNull() -}) - -test('scrollBy right after a scrollToIndex builds on the written offset', () => { - const { v, el, settle } = setupHandDrivenScroll() - - v.scrollToIndex(20, { align: 'start' }) - v.scrollBy(100) - settle() - - expect(el.scrollTop).toBe(1100) -}) - -test('scrollBy after the user scrolled builds on the user position, not an old write', () => { - const { v, el, scroll, settle } = setupHandDrivenScroll() - - v.scrollToIndex(20, { align: 'start' }) - scroll(el.scrollTop, false) // read-back of 1000 - el.scrollTop = 1300 - scroll(1300, false) // the user moved on - v.scrollBy(100) - settle() - - expect(el.scrollTop).toBe(1400) -}) - -test('scrollBy during a smooth scroll builds on the current position', () => { - // The element is still travelling, so the destination isn't where it is. - const { v, el, settle } = setupHandDrivenScroll() - - v.scrollToIndex(20, { align: 'start', behavior: 'smooth' }) - v.scrollBy(100) - settle() - - expect(el.scrollTop).toBe(100) -}) - -test('an anchorTo end list scrolled up by scrollToIndex is not pinned back by a resize before the read-back', () => { - // At the end, scrollToIndex(20) writes 1000. The last row grows before the - // read-back; against the stale offset the list still looks pinned, and the - // end-anchoring compensation used to write 4850 over the index scroll. - const { v, el, scroll, settle } = setupHandDrivenScroll({ anchorTo: 'end' }) - el.scrollTop = 4800 - scroll(4800, false) - - v.scrollToIndex(20, { align: 'start' }) - v.resizeItem(99, 100) - settle() - - expect(el.scrollTop).toBe(1000) - expect(v.scrollState).toBeNull() -}) - -test('scrollBy past the end does not leave the next scrollBy building on an unreachable offset', () => { - // A write the browser clamps to where the element already is fires no - // scroll event, so nothing reads it back. - const { v, el, scroll, frames } = setupHandDrivenScroll() - el.scrollTop = 4800 - scroll(4800, false) - - v.scrollBy(100) - frames() - expect(el.scrollTop).toBe(4800) - v.scrollBy(-100) - - expect(el.scrollTop).toBe(4700) -}) - -// ─── #1307 step 5: an offset target follows the corrections made after its -// read-back, and a prepend before a command's read-back is left to reconcile - -test('scrollToOffset: a row above growing after the read-back moves the target with it, and the command retires', () => { - // The compensation moved the element to 2050 while the target stayed 2000: - // reconcile saw neither a moved target nor a reached one and kept running - // until the 5s safety valve. - const { v, el, scroll, settle } = setupHandDrivenScroll() - - v.scrollToOffset(2000) - scroll(2000, true) - v.resizeItem(10, 100) - expect(el.scrollTop).toBe(2050) - settle() - - expect(el.scrollTop).toBe(2050) - expect(v.scrollState).toBeNull() -}) - -test('scrollBy: a row above growing after the read-back moves the target with it, and the command retires', () => { - const { v, el, scroll, settle } = setupHandDrivenScroll() - el.scrollTop = 1500 - scroll(1500, false) - - v.scrollBy(500) - scroll(2000, true) - v.resizeItem(10, 100) - expect(el.scrollTop).toBe(2050) - settle() - - expect(el.scrollTop).toBe(2050) - expect(v.scrollState).toBeNull() -}) - -test('scrollToOffset: a prepend after the read-back moves the target with the anchor sync, and the command retires', () => { - const { v, el, scroll, settle } = setupHandDrivenScroll({ - anchorTo: 'end', - getItemKey: (i) => i, - }) - - v.scrollToOffset(2000) - scroll(2000, true) - v.setOptions({ ...v.options, count: 112, getItemKey: (i) => i - 12 }) - v._willUpdate() - expect(el.scrollTop).toBe(2600) - settle() - - expect(el.scrollTop).toBe(2600) - expect(v.scrollState).toBeNull() -}) - -test('scrollToOffset: a prepend held by a bounce before the commit moves the target with it', () => { - // At the max (4800). The options change finds the element writable, a - // bounce past the max lands before the commit, and `_willUpdate` holds - // the prepend instead; the target follows there too, so the command - // retires once the hold lands. - const { v, el, scroll, settle } = setupHandDrivenScroll({ - anchorTo: 'end', - getItemKey: (i) => i, - }) - - v.scrollToOffset(4800) - scroll(4800, true) - v.setOptions({ ...v.options, count: 112, getItemKey: (i) => i - 12 }) - el.scrollTop = 4810 - scroll(4810, true) - v._willUpdate() - expect(el.scrollTop).toBe(4810) - // The bounce returns; the sizer has grown by the prepend. - el.scrollHeight = 5600 - el.scrollTop = 4800 - scroll(4800, true) - expect(el.scrollTop).toBe(5400) - settle() - - expect(el.scrollTop).toBe(5400) - expect(v.scrollState).toBeNull() -}) - -test('scrollToOffset: a prepend held by a bounce before the options change moves the target with it', () => { - // As above, but the bounce lands first, so `setOptions` itself holds the - // prepend. The target stayed 4800 and reconcile ran until the safety valve. - const { v, el, scroll, settle } = setupHandDrivenScroll({ - anchorTo: 'end', - getItemKey: (i) => i, - }) - - v.scrollToOffset(4800) - scroll(4800, true) - el.scrollTop = 4810 - scroll(4810, true) - v.setOptions({ ...v.options, count: 112, getItemKey: (i) => i - 12 }) - v._willUpdate() - expect(el.scrollTop).toBe(4810) - el.scrollHeight = 5600 - el.scrollTop = 4800 - scroll(4800, true) - expect(el.scrollTop).toBe(5400) - settle() - - expect(el.scrollTop).toBe(5400) - expect(v.scrollState).toBeNull() -}) - -test('a prepend before the read-back of a scrollToIndex write is left to reconcile', () => { - // scrollToIndex(20) wrote 1000 while `scrollOffset` still holds 2000. The - // anchor sync used to write the stale offset plus the prepend (2600) over - // the command, and reconcile (index target unchanged at 1000) never wrote - // again until the safety valve. - const { v, el, scroll, settle } = setupHandDrivenScroll({ - anchorTo: 'end', - getItemKey: (i) => i, - }) - el.scrollTop = 2000 - scroll(2000, false) - - v.scrollToIndex(20, { align: 'start' }) - expect(el.scrollTop).toBe(1000) - v.setOptions({ ...v.options, count: 112, getItemKey: (i) => i - 12 }) - v._willUpdate() - // No anchor write before the read-back... - expect(el.scrollTop).toBe(1000) - scroll(1000, true) - settle() - // ...the index scroll lands on index 20 of the new list. - expect(el.scrollTop).toBe(1000) - expect(v.scrollState).toBeNull() -}) - -test('a prepend before the read-back of a scrollToOffset write is left to reconcile', () => { - const { v, el, scroll, settle } = setupHandDrivenScroll({ - anchorTo: 'end', - getItemKey: (i) => i, - }) - el.scrollTop = 2000 - scroll(2000, false) - - v.scrollToOffset(1000) - v.setOptions({ ...v.options, count: 112, getItemKey: (i) => i - 12 }) - v._willUpdate() - expect(el.scrollTop).toBe(1000) - scroll(1000, true) - settle() - - expect(el.scrollTop).toBe(1000) - expect(v.scrollState).toBeNull() -}) - -test('scrollBy past the start does not leave the next scrollBy building on an unreachable offset', () => { - const { v, el, frames } = setupHandDrivenScroll() - - v.scrollBy(-100) - frames() - expect(el.scrollTop).toBe(0) - v.scrollBy(100) - - expect(el.scrollTop).toBe(100) -}) - -test('scrollBy after the safety valve dropped an ignored write builds on the real position', () => { - const { v, el, scroll, frames, browser } = setupHandDrivenScroll() - el.scrollTop = 2000 - scroll(2000, false) - let now = 0 - v['now'] = () => now - - browser.ignoreWrites = true - v.scrollToIndex(20, { align: 'start' }) - now = 6000 - frames() - expect(v.scrollState).toBeNull() - browser.ignoreWrites = false - v.scrollBy(100) - - expect(el.scrollTop).toBe(2100) -}) - -test('scrollBy after cancelScroll dropped an ignored write builds on the real position', () => { - const { v, el, scroll, browser } = setupHandDrivenScroll() - el.scrollTop = 2000 - scroll(2000, false) - - browser.ignoreWrites = true - v.scrollToIndex(20, { align: 'start' }) - v.cancelScroll() - browser.ignoreWrites = false - v.scrollBy(100) - - expect(el.scrollTop).toBe(2100) -}) - -test('a smooth scrollToIndex switched to auto near the end is not cancelled by a late animation event', () => { - // Reconcile switches a smooth scroll to 'auto' for the final approach when - // the target moves within a viewport. WebKit can still deliver a scroll - // event from the smooth animation after that instant write; it is not the - // user and must not end the scroll. - const { v, el, scroll, frame, settle } = setupHandDrivenScroll() - - v.scrollToIndex(50, { align: 'start', behavior: 'smooth' }) - el.scrollTop = 2450 // the animation is almost there - scroll(2450, true) - v.resizeItem(10, 100) // the target moves to 2550, within a viewport - frame() // reconcile switches to 'auto' and writes 2550 - expect(el.scrollTop).toBe(2550) - - el.scrollTop = 2470 // a late event from the smooth animation - scroll(2470, true) - expect(v.scrollState).not.toBeNull() - - el.scrollTop = 2550 - scroll(2550, true) - settle() - expect(el.scrollTop).toBe(2550) - expect(v.scrollState).toBeNull() -}) - -test('a scrollToIndex write clamped by a sizer that shrank below it still reaches the fresh target', () => { - // The sizer re-renders before the scroll event reports the write (a - // same-task render after the command). Rows above the target shrink by - // 600px, so the max drops from 4800 to 4200 and the target from 4500 to - // 3900. The read-back at the new max is our write, clamped: it used to - // read as a user scroll and the index scroll stopped at 4200. - const { v, el, scroll, settle } = setupHandDrivenScroll() - - v.scrollToIndex(90, { align: 'start' }) - expect(el.scrollTop).toBe(4500) - for (let i = 0; i < 20; i++) v.resizeItem(i, 20) - // The re-render that shrinks the sizer also rebuilds the measurements. - v.getVirtualItems() - el.scrollHeight = 4400 - el.scrollTop = 4200 - scroll(4200, true) - settle() - - expect(el.scrollTop).toBe(3900) - expect(v.scrollState).toBeNull() -}) - -test('a user scroll to the max while a write above it is pending is still the user', () => { - // The clamped read-back is only a read-back when the pending write sits - // past the max. A write within the range that the user overtakes to the - // bottom reads as the user's and retires the command. - const { v, el, scroll, frame, settle } = setupHandDrivenScroll() - - v.scrollToIndex(20, { align: 'start' }) - expect(el.scrollTop).toBe(1000) - el.scrollTop = 4800 - scroll(4800, true) - // A row above grows while the user is still scrolling: a command still - // alive would retarget to 1050 and pull the user back. - v.resizeItem(5, 100) - frame() - expect(el.scrollTop).toBe(4800) - settle() - - expect(el.scrollTop).toBe(4800) - expect(v.scrollState).toBeNull() -}) - -test('a write to a scroll element that went away is not adopted by the next one', () => { - // A write is matched to its read-back within 1.5px. After the element is - // swapped (or the virtualizer disabled), the old write is never read back, - // so the new element's first report is taken as it is. - const mk = (top: number) => ({ - scrollTop: top, - scrollLeft: 0, - scrollHeight: 5000, - clientHeight: 200, - offsetHeight: 200, - }) - let el = mk(0) - const v = new Virtualizer({ - count: 100, - estimateSize: () => 50, - getScrollElement: () => el as any, - scrollToFn: () => {}, - observeElementRect: (_inst, cb) => { - cb({ width: 400, height: 200 }) - return () => {} - }, - observeElementOffset: (_inst, cb) => { - cb(el.scrollTop, false) - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - - v.scrollToOffset(1000.5) // written, never read back on the old element - el = mk(1001) - v._willUpdate() - - expect(v.scrollOffset).toBe(1001) -}) - -// ─── useAnimationFrameWithResizeObserver: frames of the scroll element's window - -// A window other than the global one, as for a list rendered into a -// `window.open` popup of the same realm. Its frames run only on `flush`. -function createOwnWindow() { - const frames = new Map() - let nextId = 0 - let roCallback: ResizeObserverCallback | null = null - const ownWindow = { - requestAnimationFrame: vi.fn((cb: FrameRequestCallback) => { - frames.set(++nextId, cb) - return nextId - }), - cancelAnimationFrame: vi.fn((id: number) => { - frames.delete(id) - }), - performance: { now: () => Date.now() }, - ResizeObserver: vi.fn(function (cb: ResizeObserverCallback) { - roCallback = cb - return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() } - }), - } - const flush = () => { - const queued = [...frames.values()] - frames.clear() - queued.forEach((cb) => cb(0)) - } - const resize = (target: Element, blockSize: number) => - roCallback!( - [ - { - target, - borderBoxSize: [{ blockSize, inlineSize: 400 }], - } as unknown as ResizeObserverEntry, - ], - {} as ResizeObserver, - ) - const el = { - scrollTop: 0, - scrollLeft: 0, - scrollWidth: 400, - scrollHeight: 5000, - offsetWidth: 400, - offsetHeight: 600, - ownerDocument: { defaultView: ownWindow }, - } as unknown as HTMLDivElement - return { ownWindow, frames, flush, resize, el } -} - -function withGlobalRaf(fn: (globalRaf: ReturnType) => void) { - const globalRaf = vi.fn() - vi.stubGlobal('requestAnimationFrame', globalRaf) - try { - fn(globalRaf) - } finally { - vi.unstubAllGlobals() - } -} - -function createDeferredVirtualizer(el: HTMLDivElement) { - const virtualizer = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => el, - scrollToFn: vi.fn(), - useAnimationFrameWithResizeObserver: true, - observeElementRect: (_instance, cb) => { - cb({ width: 400, height: 600 }) - return () => {} - }, - observeElementOffset: (_instance, cb) => { - cb(0, false) - return () => {} - }, - }) - virtualizer._willUpdate() - virtualizer.getVirtualItems() - const node = { - getAttribute: (name: string) => (name === 'data-index' ? '3' : null), - getBoundingClientRect: () => ({ height: 50, width: 400 }), - isConnected: true, - } as unknown as HTMLElement - virtualizer.measureElement(node) - return { virtualizer, node } -} - -test('deferred item measurements wait for a frame of the scroll element window', () => { - withGlobalRaf((globalRaf) => { - const { ownWindow, flush, resize, el } = createOwnWindow() - const { virtualizer, node } = createDeferredVirtualizer(el) - expect(virtualizer.getTotalSize()).toBe(500) - - resize(node, 80) - expect(ownWindow.requestAnimationFrame).toHaveBeenCalledTimes(1) - expect(globalRaf).not.toHaveBeenCalled() - expect(virtualizer.getTotalSize()).toBe(500) - - flush() - expect(virtualizer.getTotalSize()).toBe(530) - }) -}) - -test('cleanup cancels a deferred item measurement still waiting for its frame', () => { - const { ownWindow, frames, flush, resize, el } = createOwnWindow() - const { virtualizer, node } = createDeferredVirtualizer(el) - const unmount = virtualizer._didMount() - - resize(node, 80) - expect(frames.size).toBe(1) - - unmount() - expect(ownWindow.cancelAnimationFrame).toHaveBeenCalledTimes(1) - expect(frames.size).toBe(0) - flush() - expect(virtualizer.getTotalSize()).toBe(500) -}) - -test('observeElementRect defers to a frame of the scroll element window and cancels it on unsubscribe', () => { - withGlobalRaf((globalRaf) => { - const { frames, flush, resize, el } = createOwnWindow() - const instance = { - scrollElement: el, - targetWindow: el.ownerDocument.defaultView, - options: { useAnimationFrameWithResizeObserver: true }, - } as unknown as Virtualizer - const cb = vi.fn() - - const unsubscribe = observeElementRect(instance, cb)! - expect(cb).toHaveBeenLastCalledWith({ width: 400, height: 600 }) - - resize(el, 700) - expect(cb).toHaveBeenCalledTimes(1) - flush() - expect(cb).toHaveBeenCalledTimes(2) - expect(cb).toHaveBeenLastCalledWith({ width: 400, height: 700 }) - - resize(el, 800) - unsubscribe() - expect(frames.size).toBe(0) - flush() - expect(cb).toHaveBeenCalledTimes(2) - expect(globalRaf).not.toHaveBeenCalled() - }) -}) - -// #1250: the deferral is gated on touch provenance only. Any scroll event, -// including the read-back of our own write, sets `isScrolling`, so a -// programmatic landing used to defer its compensation past a paint. - -test('#1250: a programmatic scrollToIndex landing compensates synchronously with no touch', () => { - withFakeIOSUserAgent(() => { - const { v, scrollToFn, scroll } = makeIOSTouchVirtualizer() - - v.scrollToIndex(8) - expect(scrollToFn).toHaveBeenCalledTimes(1) - scroll(250, true) // the write's own scroll-event echo: isScrolling=true - expect(v.isScrolling).toBe(true) - expect(v['_iosGesture'].touch?.phase).not.toBe('tail') // an echo never opens the tail - scrollToFn.mockClear() - - // Newly mounted rows measure. Item 0 sits above the fold, so its first - // measurement must compensate pre-paint — not defer past it. - v.resizeItem(0, 100) - expect(v['_holdOffset']).toBe(0) - expect(scrollToFn).toHaveBeenCalledTimes(1) - }) -}) - -test('#1250: a tap that never scrolls does not latch the deferral gate', () => { - withFakeIOSUserAgent(() => { - const { v, scrollToFn, touch, expireTouchTail } = makeIOSTouchVirtualizer() - - // Tap a row: touchstart + touchend, no scroll events at all. - touch('touchstart') - touch('touchend') - expireTouchTail() - expect(v['_iosGesture'].touch?.phase).not.toBe('down') - expect(v['_iosGesture'].touch?.phase).not.toBe('tail') - expect( - (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null, - ).toBeNull() - - // Every piece of touch state is timer-bounded, so the next compensation - // is applied synchronously rather than deferred by a stuck gate. - v.resizeItem(0, 100) - expect(v['_holdOffset']).toBe(0) - expect(scrollToFn).toHaveBeenCalledTimes(1) - }) -}) - -test('#1250: a scrollToIndex issued from a tap handler lands compensated', () => { - withFakeIOSUserAgent(() => { - const { v, scrollToFn, scroll, touch } = makeIOSTouchVirtualizer() - - // Tapping a search result: its click handler calls scrollToIndex inside - // the post-touchend window. - touch('touchstart') - touch('touchend') - expect(v['_iosGesture'].touch?.phase).toBe('tail') - - // The command's own write cancels any momentum, so it closes the tail. - v.scrollToIndex(8) - expect(v['_iosGesture'].touch?.phase).not.toBe('tail') - expect( - (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null, - ).toBeNull() - scroll(250, true) // the echo must not re-open it - expect(v['_iosGesture'].touch?.phase).not.toBe('tail') - scrollToFn.mockClear() - - v.resizeItem(0, 100) - expect(v['_holdOffset']).toBe(0) - expect(scrollToFn).toHaveBeenCalledTimes(1) - }) -}) - -test('#1250: an absolute command under a dragging finger keeps deferring', () => { - withFakeIOSUserAgent(() => { - const { v, scrollToFn, scroll, touch } = makeIOSTouchVirtualizer() - - touch('touchstart') - scroll(90, true) // the finger drags - v.scrollToIndex(8) // the dragging finger still owns the scroll - expect(v['_iosGesture'].touch?.phase).toBe('down') - scrollToFn.mockClear() - - v.resizeItem(0, 100) - expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_holdOffset']).toBe(50) - }) -}) - -test('#1250: a scrollToIndex with no target leaves the fling running and the held correction', () => { - withFakeIOSUserAgent(() => { - const { v, scrollToFn, scroll, touch, expireTouchTail } = - makeIOSTouchVirtualizer() - touch('touchstart') - touch('touchend') - scroll(120, true) // momentum - v.resizeItem(0, 100) - expect(v['_holdOffset']).toBe(50) - const timer = - (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null - expect(timer).not.toBeNull() - - // getOffsetForIndex finds no measurement for the index (e.g. the list was - // emptied), so scrollToIndex writes nothing. Nothing stopped the fling, - // so the tail stays open. The held correction is already in - // `scrollOffset` and stays for the flush. - v.measurementsCache = [] - v.scrollToIndex(5) - expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_holdOffset']).toBe(50) - expect(v['_iosGesture'].touch?.phase).toBe('tail') - expect( - (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null, - ).toBe(timer) - - // A resize during the remaining momentum is held too rather than - // written into the fling. - v.resizeItem(1, 80) - expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_holdOffset']).toBe(80) - - expireTouchTail() - expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(scrollToFn.mock.calls[0]![0]).toBe(200) - expect(v['_holdOffset']).toBe(0) - }) -}) - -test('iOS Phase 1: touchcancel releases the touch like touchend', () => { - withFakeIOSUserAgent(() => { - const { v, scrollToFn, touch, expireTouchTail } = makeIOSTouchVirtualizer() - - // A system gesture steals the touch: iOS fires touchcancel, never - // touchend. Without handling it the touch would stay `down` forever. - touch('touchstart') - expect(v['_iosGesture'].touch?.phase).toBe('down') - touch('touchcancel') - expect(v['_iosGesture'].touch?.phase).not.toBe('down') - expect(v['_iosGesture'].touch?.phase).toBe('tail') - - expireTouchTail() - expect(v['_iosGesture'].touch?.phase).not.toBe('tail') - - v.resizeItem(0, 100) - expect(scrollToFn).toHaveBeenCalledTimes(1) - }) -}) - -test.each([ - ['scrollToOffset', (v: any) => v.scrollToOffset(250)], - ['scrollToEnd', (v: any) => v.scrollToEnd()], -] as const)( - '#1250: a %s issued from a tap handler lands compensated', - (_name, command) => { - withFakeIOSUserAgent(() => { - const { v, el, scrollToFn, scroll, touch } = makeIOSTouchVirtualizer() - - touch('touchstart') - touch('touchend') - expect(v['_iosGesture'].touch?.phase).toBe('tail') - - // The command's own write stops any momentum, so it closes the tail. - command(v) - expect(v['_iosGesture'].touch?.phase).not.toBe('tail') - scroll(el.scrollTop, true) // the read-back must not re-open it - scrollToFn.mockClear() - - v.resizeItem(0, 100) - expect(v['_holdOffset']).toBe(0) - expect(scrollToFn).toHaveBeenCalledTimes(1) - }) - }, -) - -test('iOS deferral: under a resting finger the retarget lands and the correction is applied once', () => { - // scrollToIndex issued with a finger resting on the list (a touchstart - // handler, a long press). iOS ignores writes only under a dragging finger, - // so the command and reconcile's retarget from the grown row both land, - // and that retarget already contains the correction: the flush after the - // lift must not apply it again (@phcorp on #1318: row 20 ended 210px off). - const h = makeDeferralHarness({ start: 2000 }) - h.touch('touchstart') - h.run(() => { - h.v.scrollToIndex(20, { align: 'start' }) - h.v.resizeItem(10, 100) - for (let i = 0; i < 5; i++) { - h.frame() - h.scroll(true) - } - }) - h.touch('touchend') - h.run(() => h.scroll(false)) - h.expireTail() - expect(h.el.scrollTop).toBe(1050) - expect(h.rowTop(20)).toBe(0) -}) - -test('iOS deferral: a scroll report at rest under a resting finger is not a drag', () => { - // The command lands under a resting finger and the browser reports it, - // then reports the scroll at rest without the position moving. That is no - // drag, so a later retarget still lands and the correction is applied once - // (CodeRabbit on #1318). - const h = makeDeferralHarness({ start: 2000 }) - h.touch('touchstart') - h.run(() => { - h.v.scrollToIndex(20, { align: 'start' }) - h.scroll(true) // the write's read-back - h.scroll(false) // at rest, same position - h.v.resizeItem(10, 100) - for (let i = 0; i < 5; i++) { - h.frame() - h.scroll(true) - } - }) - h.touch('touchend') - h.run(() => h.scroll(false)) - h.expireTail() - expect(h.el.scrollTop).toBe(1050) - expect(h.rowTop(20)).toBe(0) -}) - -test('iOS deferral: a retarget iOS ignores under a finger keeps the deferred correction', () => { - // Same start, but the finger drags and reconcile retargets under it: iOS - // ignores that write, so the correction must survive it. The finger - // then scrolls, which ends the index scroll, and the flush applies it. - const h = makeDeferralHarness({ start: 2000 }) - const write = h.v.options.scrollToFn - h.v.setOptions({ - ...h.v.options, - scrollToFn: (offset, opts, inst) => { - if (!h.v['_iosGesture'].dragging()) write(offset, opts, inst) - }, - }) - h.touch('touchstart') - h.run(() => h.scroll(true, 1990)) // the finger drags - h.run(() => { - h.v.scrollToIndex(20, { align: 'start' }) - h.v.resizeItem(10, 100) - h.frame() // reconcile retargets to 1050; ignored under the finger - h.scroll(true, 2000) // the finger scrolls: the index scroll ends - }) - h.touch('touchend') - h.run(() => h.scroll(false)) - h.expireTail() - // Row 10 grew above the reader, so the content they were on moved down 50. - expect(h.el.scrollTop).toBe(2050) -}) - -test('iOS touch: a row that stops touch propagation does not hide the gesture', () => { - // The listeners run in the capture phase on the scroll element, before a - // descendant's handler can stop the event (@phcorp on #1318: a fling was - // cut short and a drag lost a correction when touches went unseen). - // Another test leaves a non-constructor ResizeObserver on window; this one - // runs on the real window, so give it a working one for its lifetime. - const prevRO = window.ResizeObserver - window.ResizeObserver = class { - observe() {} - unobserve() {} - disconnect() {} - } as unknown as typeof ResizeObserver - try { - withFakeIOSUserAgent(() => { - const scrollEl = document.createElement('div') - const row = document.createElement('div') - scrollEl.appendChild(row) - document.body.appendChild(scrollEl) - const stop = (e: Event) => e.stopPropagation() - row.addEventListener('touchstart', stop) - row.addEventListener('touchend', stop) - - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => scrollEl, - scrollToFn: () => {}, - observeElementRect: () => () => {}, - observeElementOffset: (_inst, cb) => { - cb(0, false) - return () => {} - }, - }) - const unmount = v._didMount() - v._willUpdate() - - row.dispatchEvent(new Event('touchstart', { bubbles: true })) - expect(v['_iosGesture'].touch).toEqual({ phase: 'down', moved: false }) - row.dispatchEvent(new Event('touchend', { bubbles: true })) - expect(v['_iosGesture'].touch?.phase).toBe('tail') - - unmount() - row.dispatchEvent(new Event('touchstart', { bubbles: true })) - expect(v['_iosGesture'].touch).toBeNull() - scrollEl.remove() - }) - } finally { - window.ResizeObserver = prevRO - } -}) - -// `scrollToOffset` closes the tail, so `_canWriteScroll()` is true while the -// command's write (270) has no read-back yet. The flush in the next -// `_willUpdate` must write the command's target (`getCommandWrittenOffset`), -// not the pre-command layout offset (250), which the read-back would then -// retire the command with as an external scroll. -test('iOS hold: a command that closes the tail is not overwritten by a flush before its read-back', () => { - withFakeIOSUserAgent(() => { - const { v, scrollToFn, scroll, touch } = makeIOSTouchVirtualizer({ - scrollTop: 200, - }) - touch('touchstart') - touch('touchend') - scroll(200, true) // momentum tail - v.resizeItem(0, 100) // above the viewport: held - expect(v['_holdOffset']).toBe(50) - expect(v.scrollOffset).toBe(250) - - // The command closes the tail and writes its target the hold short. - v.scrollToOffset(320) - expect(scrollToFn.mock.calls.at(-1)![0]).toBe(270) - scrollToFn.mockClear() - - // The adapter renders (a layout effect) before the scroll event of that - // write arrives. Whatever is written now must not undo the command. - v._willUpdate() - for (const [offset, opts] of scrollToFn.mock.calls) { - expect(offset + (opts.adjustments ?? 0)).toBe(320) - } - // The element reports the command's position; the read-back must not be - // taken for a user scroll away from the target. - scroll(scrollToFn.mock.calls.at(-1)?.[0] ?? 270, false) - expect(v.scrollOffset).toBe(320) - }) -}) +// ─── Public-exports lockdown ───────────────────────────────────────────────── +// If any of these go missing the next minor bump silently breaks consumers. -// The same invariant through the flush in `resizeItem`, which runs even when -// `awaitingCommandReadBack` skips the compensation. -test('iOS hold: a resize landing between a command and its read-back does not flush the stale offset', () => { - withFakeIOSUserAgent(() => { - const { v, scrollToFn, scroll, touch } = makeIOSTouchVirtualizer({ - scrollTop: 200, - }) - touch('touchstart') - touch('touchend') - scroll(200, true) - v.resizeItem(0, 100) // held: 50 - v.scrollToOffset(320) // closes the tail, writes 270 - scrollToFn.mockClear() - // A row below the viewport re-measures before the read-back: no - // compensation, but `resizeItem` runs the flush. - v.resizeItem(9, 80) - for (const [offset, opts] of scrollToFn.mock.calls) { - expect(offset + (opts.adjustments ?? 0)).toBe(320) - } - scroll(scrollToFn.mock.calls.at(-1)?.[0] ?? 270, false) - expect(v.scrollOffset).toBe(320) - }) +test('public runtime exports from @tanstack/virtual-core', async () => { + const mod = await import('../src/index') + // Class + helpers + expect(typeof mod.Virtualizer).toBe('function') + expect(typeof mod.defaultKeyExtractor).toBe('function') + expect(typeof mod.defaultRangeExtractor).toBe('function') + // Observers + expect(typeof mod.observeElementRect).toBe('function') + expect(typeof mod.observeWindowRect).toBe('function') + expect(typeof mod.observeElementOffset).toBe('function') + expect(typeof mod.observeWindowOffset).toBe('function') + // Scrollers + expect(typeof mod.elementScroll).toBe('function') + expect(typeof mod.windowScroll).toBe('function') + // Measurement + expect(typeof mod.measureElement).toBe('function') + // Utilities (historically re-exported from utils) + expect(typeof mod.memo).toBe('function') + expect(typeof mod.debounce).toBe('function') + expect(typeof mod.notUndefined).toBe('function') + expect(typeof mod.approxEqual).toBe('function') }) diff --git a/packages/virtual-core/tests/ios-deferral.test.ts b/packages/virtual-core/tests/ios-deferral.test.ts new file mode 100644 index 000000000..d26baf7c8 --- /dev/null +++ b/packages/virtual-core/tests/ios-deferral.test.ts @@ -0,0 +1,1686 @@ +import { expect, test, vi } from 'vitest' +import { + Virtualizer, + _resetIOSDetectionForTests, + observeElementOffset, + observeElementRect, +} from '../src/index' +import { + withFakeIOSUserAgent, + makeDeferralHarness, + makeMockScrollElement, +} from './helpers' + +// ─── iOS momentum-safe scroll adjustments ─────────────────────────────────── + +function makeIOSTouchVirtualizer( + props: Record = {}, + options: Record = {}, +) { + const timers = new Map void>() + let timerId = 0 + const mockWindow = { + setTimeout: (fn: () => void, _ms: number) => { + const id = ++timerId + timers.set(id, fn) + return id + }, + clearTimeout: (id: number) => timers.delete(id), + requestAnimationFrame: () => 1, + cancelAnimationFrame: () => {}, + performance: { now: () => Date.now() }, + } + const el = makeMockScrollElement({ + scrollTop: 100, + scrollLeft: 0, + scrollHeight: 500, + clientHeight: 200, + offsetHeight: 200, + ...props, + ownerDocument: { defaultView: mockWindow }, + }) + const scrollToFn = vi.fn() + let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = + null + const v = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => el as any, + scrollToFn, + observeElementRect: () => {}, + observeElementOffset: (_inst, cb) => { + scrollCallback = cb + cb(el.scrollTop, false) + return () => {} + }, + ...options, + }) + v._willUpdate() + v['getMeasurements']() + scrollToFn.mockClear() + return { + v, + el, + scrollToFn, + // A scroll event; the element is where it reports. + scroll: (offset: number, isScrolling: boolean) => { + el.scrollTop = offset + scrollCallback!(offset, isScrolling) + }, + touch: (type: 'touchstart' | 'touchend' | 'touchcancel') => + el._dispatch(type), + expireTouchTail: () => { + const id = + (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null + expect(id, 'a touch tail timer is armed').not.toBeNull() + const fn = timers.get(id!)! + fn() + }, + } +} + +test('iOS deferral: compensation is deferred during a touch-driven scroll and flushed once the gesture settles', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer() + // Finger down, drag, release into momentum. + touch('touchstart') + scroll(120, true) + touch('touchend') + scroll(140, true) // momentum frame + + // Resize an item above the viewport mid-fling. The default predicate + // (item.start < scrollOffset) would write scrollTop immediately, and on + // iOS that cancels momentum (#884) — so it must defer. + v.resizeItem(0, 100) // item 0 was at start=0; now 50→100 grows by 50 + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_holdOffset']).toBe(50) + + // The tail armed by the last momentum frame expires: gesture settled. + expireTouchTail() + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(v['_holdOffset']).toBe(0) + }) +}) + +test('iOS deferral: multiple resizes during a fling accumulate and flush as one', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer({ scrollTop: 200 }) + touch('touchstart') + touch('touchend') + scroll(200, true) + + // Three resizes during the fling: 10 + 15 + 20 = 45 total + v.resizeItem(0, 60) + v.resizeItem(1, 65) + v.resizeItem(2, 70) + + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_holdOffset']).toBe(45) + + expireTouchTail() + // Single flush call + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(v['_holdOffset']).toBe(0) + }) +}) + +test('iOS deferral: scrollToIndex with no target keeps the held correction for the flush', () => { + // A held correction is a shift between the layout and the element, not a + // pending replay: `scrollOffset` already carries it. A command with no + // target (an empty list) writes nothing and leaves it; the flush writes + // the layout position once the list has refilled (cf. @phcorp's + // differential run on #1316, which pinned the replay of the old model). + withFakeIOSUserAgent(() => { + const { v, scrollToFn, touch, expireTouchTail } = makeIOSTouchVirtualizer() + + touch('touchstart') + v.resizeItem(0, 90) // above the fold under a finger: held + expect(v['_holdOffset']).toBe(40) + expect(v.scrollOffset).toBe(140) + v.setOptions({ ...v.options, count: 0 }) + v['getMeasurements']() // the render for the empty list + v.scrollToIndex(0) // no target, nothing to write + expect(scrollToFn).not.toHaveBeenCalled() + v.setOptions({ ...v.options, count: 10 }) + v['getMeasurements']() + touch('touchend') + expireTouchTail() // settles: the flush writes the layout position + + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(140) + expect(v['_holdOffset']).toBe(0) + }) +}) + +test('iOS deferral: an absolute scroll command targets the layout and the hold moves the element short of it', () => { + // (#1233 manifestation A in the old model: a deferred delta replayed on + // top of a command's target.) A held correction is not dropped by a + // command; the command's target is a layout offset, the element is + // written the hold short of it under the shifted rows, and the flush + // moves both to the target together once a write is possible. + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer({ scrollTop: 200 }) + touch('touchstart') + scroll(190, true) // the finger drags: the user owns the scroll + + // Hold a correction during the drag. + v.resizeItem(0, 100) + expect(v['_holdOffset']).toBe(50) + expect(v.scrollOffset).toBe(240) + + // The command writes the element 50 short of its layout target. + v.scrollToOffset(300) + expect(v['_holdOffset']).toBe(50) + expect(scrollToFn.mock.calls.at(-1)![0]).toBe(250) + + v.resizeItem(1, 100) // held too (finger still down) + expect(v['_holdOffset']).toBe(100) + v.scrollToIndex(5) + expect(scrollToFn.mock.calls.at(-1)![0]).toBe(250 - 100 + 50) + scrollToFn.mockClear() + + // The finger lifts and the writes it ignored have no read-back, so the + // last command still owns the target: the flush writes it (300, which + // the command wrote 100 short of), not the pre-command offset (290). + touch('touchend') + expireTouchTail() + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(300) + expect(v['_holdOffset']).toBe(0) + scroll(scrollToFn.mock.calls[0]![0], false) + expect(v['_holdOffset']).toBe(0) + }) +}) + +test('iOS deferral: a resize during an in-flight scrollToIndex is not replayed after it lands', () => { + // reconcileScroll retargets the index scroll from fresh measurements, so a + // size correction deferred while it travels is already accounted for — + // replaying it on the settle flush would push the list past the target. + withFakeIOSUserAgent(() => { + const rafCallbacks: Array = [] + const mockWindow = { + requestAnimationFrame: (cb: FrameRequestCallback) => + rafCallbacks.push(cb), + cancelAnimationFrame: () => {}, + } + const el = { + scrollTop: 0, + scrollLeft: 0, + scrollHeight: 5000, + clientHeight: 200, + offsetHeight: 200, + ownerDocument: { defaultView: mockWindow }, + } + let scrollCallback: + | ((offset: number, isScrolling: boolean) => void) + | null = null + const v = new Virtualizer({ + count: 100, + estimateSize: () => 50, + getScrollElement: () => el as any, + scrollToFn: (offset, { adjustments = 0 }) => { + el.scrollTop = offset + adjustments + }, + observeElementRect: (_inst, cb) => { + cb({ width: 400, height: 200 }) + return () => {} + }, + observeElementOffset: (_inst, cb) => { + scrollCallback = cb + cb(0, false) + return () => {} + }, + }) + v._willUpdate() + v['getMeasurements']() + const flushFrames = () => { + for (let i = 0; i < 10 && rafCallbacks.length > 0; i++) { + rafCallbacks.splice(0).forEach((cb) => cb(0)) + scrollCallback!(el.scrollTop, true) + } + } + + v.scrollToIndex(20, { align: 'start' }) + scrollCallback!(el.scrollTop, true) + expect(el.scrollTop).toBe(1000) + + // An item above the target measures 50px taller while the index scroll + // is still settling. + // No render runs in between (onChange is not set), so reconcile must + // pick the resize up itself. + v.resizeItem(10, 100) + flushFrames() + expect(el.scrollTop).toBe(1050) + + // The scroll settles: the landing must stay on the item. + scrollCallback!(el.scrollTop, false) + expect(el.scrollTop).toBe(v.getOffsetForIndex(20, 'start')![0]) + expect(el.scrollTop).toBe(1050) + }) +}) + +test('iOS deferral: a held correction is in scrollOffset, so back-to-back resizes stay consistent', () => { + // (In the old model the flush wrote `adjustments: delta` and had to roll + // it into `scrollAdjustments` for a resize landing before the read-back.) + // A held correction is folded into `scrollOffset` at once, like a written + // one, so the flush writes the plain layout position and a resize before + // its read-back compares against the right offset. + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer({ scrollTop: 200 }) + touch('touchstart') + touch('touchend') + scroll(200, true) + + v.resizeItem(0, 100) + expect(v['_holdOffset']).toBe(50) + expect(v.scrollOffset).toBe(250) + expect(v['scrollAdjustments']).toBe(0) + + expireTouchTail() + + expect(scrollToFn).toHaveBeenCalledTimes(1) + const [offset, opts] = scrollToFn.mock.calls[0]! + expect(offset).toBe(250) + expect(opts.adjustments).toBeUndefined() + expect(v['scrollAdjustments']).toBe(0) + expect(v.scrollOffset).toBe(250) + + // Row 1 (layout 100..150, above the fold at 250) grows before the + // read-back: compensated from the flushed offset. + v.resizeItem(1, 80) + expect( + scrollToFn.mock.calls.at(-1)![0] + + (scrollToFn.mock.calls.at(-1)![1].adjustments ?? 0), + ).toBe(280) + expect(v.scrollOffset).toBe(280) + }) +}) + +test('iOS deferral: a write before the flush is read back carries no adjustment', () => { + // (Found by @phcorp's mutation run on #1316 in the old model, where the + // flush's delta lived in `scrollAdjustments` until its read-back.) The + // flush writes a plain offset, so nothing is pending afterwards. + withFakeIOSUserAgent(() => { + const { v, scroll, touch, expireTouchTail } = makeIOSTouchVirtualizer() + + touch('touchstart') + v.resizeItem(0, 100) // held under the finger + touch('touchend') + expireTouchTail() // settles: the flush writes 150 + expect(v.scrollAdjustments).toBe(0) + expect(v.scrollOffset).toBe(150) + + v.scrollToOffset(150) // a write with no adjustments of its own + expect(v.scrollAdjustments).toBe(0) + + scroll(150, false) // its read-back + expect(v.scrollAdjustments).toBe(0) + expect(v.scrollOffset).toBe(150) + }) +}) + +test('iOS deferral: a shrink above the viewport at the end is held, and the sizer does not shrink under the element', () => { + // (#1233 manifestation B in the old model: the sizer shrank under the + // element, the browser clamped scrollTop onto the new bottom, and the + // deferred negative delta had to be dropped at the flush.) With the + // correction held, the exposed total stays what it was, so the element is + // not clamped; the flush writes the layout position, 20 up, which is the + // same rows under the viewport. + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer( + { scrollTop: 300 }, // pinned at the bottom: scrollHeight - clientHeight + { anchorTo: 'end' }, + ) + touch('touchstart') + touch('touchend') + scroll(300, true) // at the bottom, flinging + const totalBefore = v.getTotalSize() + + // A row above the viewport re-measures smaller while at the end. + v.resizeItem(0, 30) // 50 → 30: total shrinks by 20 + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_holdOffset']).toBe(-20) + expect(v.scrollOffset).toBe(280) + expect(v.getTotalSize()).toBe(totalBefore) + + expireTouchTail() + expect(v['_holdOffset']).toBe(0) + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(280) + expect(v.getTotalSize()).toBe(totalBefore - 20) + }) +}) + +test('iOS deferral: a positive delta at the end clamp still replays (growth above does not clamp)', () => { + // Complement to manifestation B: content GROWING above the viewport does + // not clamp (the browser cannot shrink to fit growth), and the consumer's + // DOM sizer may not have grown yet, so a positive deferred delta must still + // flush — the end-clamp drop is negative-only. + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer({ scrollTop: 300 }, { anchorTo: 'end' }) + touch('touchstart') + touch('touchend') + scroll(300, true) + + // A row above the viewport re-measures larger while at the end. + v.resizeItem(0, 70) // 50 → 70: total grows by 20 + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_holdOffset']).toBe(20) + + // Settle. Growth doesn't clamp, so the positive delta must replay. + expireTouchTail() + expect(v['_holdOffset']).toBe(0) + expect(scrollToFn).toHaveBeenCalledTimes(1) + }) +}) + +// ─── #1307: orderings the hold has to get right, and ones to keep ─────────── +// +// Each case asserts what the user sees (row positions, the element) through +// one ordering of touches, scroll events, resizes and commands; the plain +// tests pin behaviour a redesign must not change by accident. 100 rows × +// 50px, 200px viewport, hand-driven rAF and scroll events. + +// scrollToIndex(20) has landed on 1000 and reconcile has seen it once; row 10 +// (above the target) then grows by 50 and reconcile has not run since. +function landedIndexScrollWithResizeAbove(ios = true) { + const h = makeDeferralHarness({ ios }) + h.run(() => { + h.v.scrollToIndex(20, { align: 'start' }) + h.scroll(true) + h.frame() + h.v.resizeItem(10, 100) + }) + return h +} + +test('iOS deferral (#1307): scrollToOffset, then a prepend during a tap tail moves the target with the held delta', () => { + // The options change finds the tail open and holds the prepend. The + // target used to stay 2000 while the flush wrote 2600, so reconcile ran + // until the safety valve. + const h = makeDeferralHarness() + h.run(() => { + h.v.setOptions({ ...h.v.options, anchorTo: 'end', getItemKey: (i) => i }) + h.v._willUpdate() + h.v.scrollToOffset(2000) + h.scroll(true) + }) + h.touch('touchstart') + h.touch('touchend') + h.run(() => { + h.v.setOptions({ ...h.v.options, count: 112, getItemKey: (i) => i - 12 }) + h.v._willUpdate() + }) + expect(h.el.scrollTop).toBe(2000) + h.expireTail() + expect(h.el.scrollTop).toBe(2600) + h.run(() => { + h.scroll(true) + for (let i = 0; i < 5; i++) h.frame() + h.scroll(false) + }) + + expect(h.el.scrollTop).toBe(2600) + expect(h.v.scrollState).toBeNull() +}) + +test('iOS deferral (#1307): a gesture before the index scroll retargets keeps the resize correction', () => { + const h = landedIndexScrollWithResizeAbove() + h.touch('touchstart') + h.run(() => { + h.scroll(true, h.el.scrollTop + 10) + h.frame() + }) + h.touch('touchend') + h.run(() => h.scroll(false)) + h.expireTail() + // The user dragged 10px, so row 20 belongs 10px above the viewport top. + expect(h.rowTop(20)).toBe(-10) +}) + +test('non-iOS: a gesture before the index scroll retargets keeps the resize correction', () => { + const h = landedIndexScrollWithResizeAbove(false) + h.scroll(true, h.el.scrollTop + 10) + h.frame() + h.scroll(false) + expect(h.rowTop(20)).toBe(-10) +}) + +test('iOS deferral (#1307): cancelScroll before the index scroll retargets keeps the resize correction', () => { + const h = landedIndexScrollWithResizeAbove() + h.run(() => { + h.v.cancelScroll() + h.frame() + h.scroll(false) + expect(h.rowTop(20)).toBe(0) + }) +}) + +test('iOS deferral (#1307): the reconcile safety valve keeps the resize correction', () => { + const h = landedIndexScrollWithResizeAbove() + h.run(() => { + h.advance(6000) + h.frame() + h.scroll(false) + expect(h.rowTop(20)).toBe(0) + }) +}) + +test('iOS deferral (#1307): scrollBy replacing an index scroll keeps the resize correction', () => { + const h = landedIndexScrollWithResizeAbove() + h.run(() => { + h.v.scrollBy(100) + h.scroll(true) + h.frame() + h.scroll(true) + h.frame() + h.scroll(false) + expect(h.rowTop(20)).toBe(-100) + }) +}) + +// The reverse flick. Written against the old deferral (#1306), this asserted +// `rowTop(20) === 0` after the flush: the rows were rendered in layout +// coordinates through the deferral, so the write at the end (+50) moved the +// screen, and the fix was to skip it once the fling had carried the viewport +// above row 30 (#1307, step 4). The hold (#1319) changes what the user sees: +// from the resize on, the rows are exposed shifted back by the held delta, the +// fling runs over that layout, and the flush moves the element and the rows +// together, so the row at the top of the viewport when the fling ends is +// still there after the flush. Skipping the correction would move the screen +// by the 50px instead, and the final position would differ from non-iOS, +// where the same gesture over the same content ends at 1050. The assertion +// is now on the screen and on that parity; `rowTop(20)` is -50 on both. +test('iOS deferral (#1307): the flush after a reverse flick moves nothing on screen and lands where non-iOS does', () => { + // The screen: the first rendered row and where the viewport cuts it. + const screen = (h: ReturnType) => { + const first = h.v.getVirtualItems()[0]! + return { index: first.index, top: first.start - h.el.scrollTop } + } + + const h = makeDeferralHarness({ start: 2000 }) + h.touch('touchstart') + h.run(() => { + h.scroll(true, 2010) + h.v.resizeItem(30, 100) + }) + h.touch('touchend') + // The fling carries the viewport above row 30, then settles. + h.run(() => { + h.scroll(true, 1000) + h.scroll(false) + }) + const before = screen(h) + h.expireTail() + expect(screen(h)).toEqual(before) + expect(h.el.scrollTop).toBe(1050) + + // Non-iOS: the correction is written at once and the same gesture (1010px + // of content) lands at the same place. + const web = makeDeferralHarness({ ios: false, start: 2000 }) + web.scroll(true, 2010) + web.v.resizeItem(30, 100) + expect(web.el.scrollTop).toBe(2060) + web.scroll(true, 2060 - 1010) + web.scroll(false) + expect(web.el.scrollTop).toBe(1050) + expect(screen(web)).toEqual(screen(h)) + expect(h.rowTop(20)).toBe(web.rowTop(20)) +}) + +test('scrollToIndex (#1307): a resize classified against the stale offset does not overwrite the index scroll', () => { + // Not iOS-specific. scrollOffset still holds the old position until the + // scroll event, so row 38 (below the target) reads as above the viewport; + // resizes before the read-back are left to reconcile. + const h = makeDeferralHarness({ ios: false, start: 2000 }) + h.v.scrollToIndex(10, { align: 'start' }) + expect(h.el.scrollTop).toBe(500) + h.v.resizeItem(38, 100) + h.scroll(true) + h.frame() + expect(h.el.scrollTop).toBe(500) +}) + +test('scrollToIndex (#1307): a drag that never reached the target is not pulled back when the target moves', () => { + // Seen in iOS Safari: a scrollToIndex issued while a finger is dragging is + // ignored by the browser, so the offset never gets near the target. + const h = makeDeferralHarness() + h.run(() => { + h.v.scrollToIndex(50, { align: 'start' }) + h.scroll(true, 20) + h.frame() + h.scroll(true, 60) + h.frame() + // A row measures and moves the target; the user is still at 60. + h.v.resizeItem(30, 100) + h.frame() + expect(h.el.scrollTop).toBe(60) + }) +}) + +test('scrollToIndex (#1307): a scroll away from a target that moved before landing is not pulled back, non-iOS', () => { + // Not specific to iOS ignoring writes under a finger: a target that moves + // before reconcile sees the scroll land leaves the state alive too. + const h = makeDeferralHarness({ ios: false }) + h.v.scrollToIndex(50, { align: 'start' }) + h.scroll(true) + h.v.resizeItem(10, 100) + h.frame() + h.scroll(true) + expect(h.el.scrollTop).toBe(2550) + // The user scrolls away, then a row above them measures. + h.scroll(true, 1000) + h.frame() + h.v.resizeItem(5, 100) + h.frame() + expect(h.el.scrollTop).toBe(1050) +}) + +test('iOS deferral: a deferred correction follows the viewport through momentum', () => { + const h = makeDeferralHarness({ start: 1000 }) + h.touch('touchstart') + h.run(() => { + h.scroll(true, 1010) + h.v.resizeItem(5, 100) + }) + h.touch('touchend') + h.run(() => { + h.scroll(true, 3000) // momentum + h.scroll(false) + }) + h.expireTail() + // Applied where the momentum ended, not where the resize was seen. + expect(h.el.scrollTop).toBe(3050) + expect(h.rowTop(60)).toBe(0) +}) + +test('iOS deferral: a re-measure above the viewport is deferred scrolling forward, skipped scrolling backward', () => { + const h = makeDeferralHarness({ start: 2000 }) + h.run(() => { + h.v.resizeItem(5, 60) + h.scroll(false) + h.el._dispatch('touchstart') + h.scroll(true, 1990) + h.v.resizeItem(5, 100) + expect(h.v['_holdOffset']).toBe(0) + h.scroll(true, 2020) + h.v.resizeItem(5, 120) + expect(h.v['_holdOffset']).toBe(20) + }) +}) + +test('iOS deferral: a custom adjust predicate that opts out defers nothing', () => { + const h = makeDeferralHarness({ start: 2000 }) + h.run(() => { + h.v.shouldAdjustScrollPositionOnItemSizeChange = () => false + h.el._dispatch('touchstart') + h.scroll(true, 2010) + h.v.resizeItem(5, 100) + expect(h.v['_holdOffset']).toBe(0) + }) +}) + +test('iOS deferral: the first measure of the row spanning the viewport top is compensated', () => { + const h = makeDeferralHarness({ start: 2000 }) + h.touch('touchstart') + h.run(() => { + h.scroll(true, 2010) + expect(h.rowTop(41)).toBe(40) + // Row 40 starts at 2000, 10px above the viewport top. + h.v.resizeItem(40, 100) + expect(h.v['_holdOffset']).toBe(50) + }) + h.touch('touchend') + h.run(() => h.scroll(false)) + h.expireTail() + expect(h.rowTop(41)).toBe(40) +}) + +// ─── Phase 1: touch event distinction ──────────────────────────────────────── + +function makeIOSVirtualizerWithRealEl( + scrollToFn: ReturnType, + mockWindow: any, +) { + const el = makeMockScrollElement({ + scrollTop: 100, + scrollLeft: 0, + scrollHeight: 500, + clientHeight: 200, + offsetHeight: 200, + ownerDocument: { defaultView: mockWindow }, + }) + const v = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => el as any, + scrollToFn, + observeElementRect: () => {}, + observeElementOffset: (_inst, cb) => { + cb(100, false) + return () => {} + }, + }) + v._willUpdate() + v['getMeasurements']() + return { v, el } +} + +function dispatchTouchEvent( + el: any, + type: 'touchstart' | 'touchend' | 'touchcancel', +) { + el._dispatch(type) +} + +test('iOS Phase 1: touchstart enters the `down` phase and ends a prior tail', () => { + withFakeIOSUserAgent(() => { + const mockWindow = { + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + } + const { v, el } = makeIOSVirtualizerWithRealEl(vi.fn(), mockWindow) + dispatchTouchEvent(el, 'touchstart') + dispatchTouchEvent(el, 'touchend') // leaves a tail armed + expect(v['_iosGesture'].touch?.phase).toBe('tail') + dispatchTouchEvent(el, 'touchstart') + expect(v['_iosGesture'].touch).toEqual({ phase: 'down', moved: false }) + }) +}) + +test('iOS Phase 1: touchcancel clears touching the same as touchend, and cleanup removes the listener', () => { + withFakeIOSUserAgent(() => { + let timerId = 0 + const timers = new Map void>() + const mockWindow = { + setTimeout: (fn: () => void, _ms: number) => { + const id = ++timerId + timers.set(id, fn) + return id + }, + clearTimeout: (id: number) => timers.delete(id), + } + const { v, el } = makeIOSVirtualizerWithRealEl(vi.fn(), mockWindow) + expect(el._hasListener('touchcancel')).toBe(true) + + dispatchTouchEvent(el, 'touchstart') + expect(v['_iosGesture'].touch).toEqual({ phase: 'down', moved: false }) + dispatchTouchEvent(el, 'touchcancel') + expect(v['_iosGesture'].touch?.phase).toBe('tail') + + const unmount = v._didMount() + unmount() + expect(el._hasListener('touchcancel')).toBe(false) + expect(el._hasListener('touchend')).toBe(false) + expect(el._hasListener('touchstart')).toBe(false) + }) +}) + +test('iOS Phase 1: touchend sets justTouchEnded + starts grace timer, then expires', async () => { + await withFakeIOSUserAgent(async () => { + let timerId = 0 + const timers = new Map void>() + const mockWindow = { + setTimeout: (fn: () => void, _ms: number) => { + const id = ++timerId + timers.set(id, fn) + return id + }, + clearTimeout: (id: number) => timers.delete(id), + } + const { v, el } = makeIOSVirtualizerWithRealEl(vi.fn(), mockWindow) + dispatchTouchEvent(el, 'touchstart') + dispatchTouchEvent(el, 'touchend') + const touch = v['_iosGesture'].touch + expect(touch?.phase).toBe('tail') + + // Fire the timer manually (simulating 150ms elapsing). + const fn = timers.get((touch as { timerId: number }).timerId)! + fn() + expect(v['_iosGesture'].touch).toBeNull() + }) +}) + +test('iOS Phase 1: resize during active touch defers (no scrollTop write)', () => { + withFakeIOSUserAgent(() => { + const scrollToFn = vi.fn() + const mockWindow = { + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + } + const { v, el } = makeIOSVirtualizerWithRealEl(scrollToFn, mockWindow) + // Bring scroll state to a typical "user touched the screen" pose. + dispatchTouchEvent(el, 'touchstart') + scrollToFn.mockClear() + + // Above-viewport item resizes mid-drag. Must defer. + v.resizeItem(0, 100) + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_holdOffset']).toBe(50) + }) +}) + +test('iOS Phase 1: resize in post-touchend grace window defers; flushes when timer fires', () => { + withFakeIOSUserAgent(() => { + const scrollToFn = vi.fn() + let timerId = 0 + const timers = new Map void>() + const mockWindow = { + setTimeout: (fn: () => void, _ms: number) => { + const id = ++timerId + timers.set(id, fn) + return id + }, + clearTimeout: (id: number) => timers.delete(id), + } + const { v, el } = makeIOSVirtualizerWithRealEl(scrollToFn, mockWindow) + dispatchTouchEvent(el, 'touchstart') + dispatchTouchEvent(el, 'touchend') + const touch = v['_iosGesture'].touch + expect(touch?.phase).toBe('tail') + scrollToFn.mockClear() + + // Items measure during the grace window — must defer + v.resizeItem(0, 100) + v.resizeItem(1, 65) + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_holdOffset']).toBe(50 + 15) + + // Expire the grace timer; the timer callback flushes the accumulated delta. + const fn = timers.get((touch as { timerId: number }).timerId)! + fn() + expect(v['_iosGesture'].touch).toBeNull() + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(v['_holdOffset']).toBe(0) + }) +}) + +test('iOS Phase 1: momentum scroll events re-arm the post-touchend tail until the fling ends', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer() + touch('touchstart') + touch('touchend') + const firstTimer = + (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null + expect(firstTimer).not.toBeNull() + + // iOS fires no touch events during momentum, only scroll events. Each + // one must push the tail out — otherwise it would expire 150 ms into a + // fling that lasts a second or more, and the next resize would write + // scrollTop and kill the momentum (#884). + scroll(120, true) + expect(v['_iosGesture'].touch?.phase).toBe('tail') + expect( + (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null, + ).not.toBe(firstTimer) + + v.resizeItem(0, 100) + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_holdOffset']).toBe(50) + + scroll(140, true) // still flinging: re-armed again, still deferred + expect(v['_iosGesture'].touch?.phase).toBe('tail') + expect(scrollToFn).not.toHaveBeenCalled() + + // 150 ms after the last frame the tail expires and the delta flushes. + expireTouchTail() + expect(v['_iosGesture'].touch?.phase).not.toBe('tail') + expect( + (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null, + ).toBeNull() + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(v['_holdOffset']).toBe(0) + }) +}) + +// The first frame of a fling can come 150–250ms after touchend when the main +// thread is busy (27.0 simulator, #1307): after the tail has closed. +function dragThenLateFling({ ios27 = false } = {}) { + const h = makeDeferralHarness({ start: 2000, ios27 }) + h.touch('touchstart') + h.run(() => h.scroll(true, 1990)) + h.touch('touchend') + h.advance(150) + h.expireTail() // no momentum frame yet + h.writes.length = 0 + return h +} + +test('iOS: a fling whose first frame comes after the tail closed reopens it', () => { + const h = dragThenLateFling() + h.advance(100) + h.run(() => { + h.scroll(true, 1960) + h.v.resizeItem(5, 100) + }) + // Held: a write would stop the fling (26) or be overridden (27). + expect(h.writes).toEqual([]) + h.run(() => { + h.scroll(true, 1900) + h.scroll(false) + }) + h.expireTail() + expect(h.writes).toEqual([1950]) +}) + +test('iOS 27: a command mid-fling waits when the first frame came late', () => { + const h = dragThenLateFling({ ios27: true }) + h.advance(100) + h.run(() => { + h.scroll(true, 1960) + h.v.scrollToIndex(90, { align: 'start' }) + }) + expect(h.writes).toEqual([]) + expect(h.v.scrollState?.phase).toBe('queued') +}) + +test('iOS: a scroll event over 1s after the tail closed does not reopen it', () => { + const h = dragThenLateFling() + h.advance(1100) + h.run(() => { + h.scroll(true, 1960) + h.v.resizeItem(5, 100) + }) + expect(h.writes).toEqual([2010]) +}) + +test('iOS: after a fling that reported in time, a later scroll event opens no tail', () => { + const h = makeDeferralHarness({ start: 2000 }) + h.touch('touchstart') + h.run(() => h.scroll(true, 1990)) + h.touch('touchend') + h.run(() => { + h.scroll(true, 1900) + h.scroll(false) + }) + h.expireTail() + h.writes.length = 0 + // Say the app scrolls the element itself. + h.advance(100) + h.run(() => { + h.scroll(true, 1800) + h.v.resizeItem(5, 100) + }) + expect(h.writes).toEqual([1850]) +}) + +test('iOS: a tap opens no late-fling window', () => { + const h = makeDeferralHarness({ start: 2000 }) + h.touch('touchstart') + h.touch('touchend') + h.expireTail() + h.writes.length = 0 + h.advance(100) + h.run(() => { + h.scroll(true, 1960) + h.v.resizeItem(5, 100) + }) + expect(h.writes).toEqual([2010]) +}) + +test('iOS Phase 1: new touchstart during grace window cancels pending flush timer', () => { + withFakeIOSUserAgent(() => { + const scrollToFn = vi.fn() + let timerId = 0 + const timers = new Map void>() + const mockWindow = { + setTimeout: (fn: () => void, _ms: number) => { + const id = ++timerId + timers.set(id, fn) + return id + }, + clearTimeout: (id: number) => timers.delete(id), + } + const { v, el } = makeIOSVirtualizerWithRealEl(scrollToFn, mockWindow) + dispatchTouchEvent(el, 'touchstart') + dispatchTouchEvent(el, 'touchend') + const firstTimerId = (v['_iosGesture'].touch as { timerId: number }).timerId + expect(timers.has(firstTimerId)).toBe(true) + + // User puts finger back down before grace window expired. + dispatchTouchEvent(el, 'touchstart') + // The pending timer must have been canceled. + expect(timers.has(firstTimerId)).toBe(false) + expect(v['_iosGesture'].touch).toEqual({ phase: 'down', moved: false }) + }) +}) + +// Helper for the element-swap tests below: like makeIOSVirtualizerWithRealEl +// but getScrollElement reads a mutable holder, so tests can swap the scroll +// element and re-run _willUpdate (which triggers cleanup + re-attach). +function makeIOSVirtualizerWithSwappableEl( + scrollToFn: ReturnType, + mockWindow: any, + { startScrolling = false }: { startScrolling?: boolean } = {}, +) { + const makeEl = () => + makeMockScrollElement({ + scrollTop: 100, + scrollLeft: 0, + scrollHeight: 500, + clientHeight: 200, + offsetHeight: 200, + ownerDocument: { defaultView: mockWindow }, + }) + const holder = { el: makeEl() } + let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = + null + const v = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => holder.el as any, + scrollToFn, + observeElementRect: () => {}, + observeElementOffset: (_inst, cb) => { + scrollCallback = cb + cb(100, startScrolling) + return () => {} + }, + }) + v._willUpdate() + v['getMeasurements']() + return { + v, + holder, + makeEl, + getScrollCallback: () => scrollCallback!, + } +} + +test('iOS Phase 1: scroll-element swap does not replay a stale deferred adjustment', () => { + withFakeIOSUserAgent(() => { + const scrollToFn = vi.fn() + const mockWindow = { + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + } + const { v, holder, makeEl, getScrollCallback } = + makeIOSVirtualizerWithSwappableEl(scrollToFn, mockWindow) + scrollToFn.mockClear() + + // A resize above the viewport during an active touch defers its + // adjustment instead of writing scrollTop. + dispatchTouchEvent(holder.el, 'touchstart') + v.resizeItem(0, 100) + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_holdOffset']).toBe(50) + + // The scroll element is swapped while the deferral is pending — the + // delta was computed against the old element's content and must not + // survive into the new element. + holder.el = makeEl() + v._willUpdate() + expect(v['_holdOffset']).toBe(0) + + // The new element's first quiescence must not write the stale delta. + scrollToFn.mockClear() + getScrollCallback()(100, false) + expect(scrollToFn).not.toHaveBeenCalled() + }) +}) + +test('iOS Phase 1: scroll-element swap mid-touch does not strand the `down` phase', () => { + withFakeIOSUserAgent(() => { + const mockWindow = { + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + } + const { v, holder, makeEl } = makeIOSVirtualizerWithSwappableEl( + vi.fn(), + mockWindow, + ) + dispatchTouchEvent(holder.el, 'touchstart') + expect(v['_iosGesture'].touch).toEqual({ phase: 'down', moved: false }) + + // The in-flight touch keeps targeting the old element (implicit touch + // capture), so the new element will never deliver its touchend. Without + // a reset, every adjustment on the new element would be deferred and + // the flush blocked until the user's next full touch cycle. + holder.el = makeEl() + v._willUpdate() + expect(v['_iosGesture'].touch).toBeNull() + }) +}) + +test('iOS Phase 1: scroll-element swap during grace window does not strand the tail', () => { + withFakeIOSUserAgent(() => { + let timerId = 0 + const timers = new Map void>() + const mockWindow = { + setTimeout: (fn: () => void, _ms: number) => { + const id = ++timerId + timers.set(id, fn) + return id + }, + clearTimeout: (id: number) => timers.delete(id), + } + const { v, holder, makeEl } = makeIOSVirtualizerWithSwappableEl( + vi.fn(), + mockWindow, + ) + dispatchTouchEvent(holder.el, 'touchstart') + dispatchTouchEvent(holder.el, 'touchend') + expect(v['_iosGesture'].touch?.phase).toBe('tail') + + // Swapping inside the grace window removes the listeners and clears the + // grace timer — which was the only pending reset of the record. Without + // the cleanup reset the tail would stay set indefinitely. + holder.el = makeEl() + v._willUpdate() + expect(v['_iosGesture'].touch).toBeNull() + expect(timers.size).toBe(0) + }) +}) + +// ─── Phase 2a: subpixel scrollTop reconciliation ───────────────────────────── + +test('Phase 2a: browser-rounded scrollTop after self-write is reconciled to intended value', () => { + let scrollCallback: ((o: number, s: boolean) => void) | null = null + const v = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => + ({ + scrollTop: 0, + scrollLeft: 0, + scrollHeight: 500, + clientHeight: 200, + offsetHeight: 200, + scrollTo: vi.fn(), + }) as any, + scrollToFn: vi.fn(), + observeElementRect: () => {}, + observeElementOffset: (_inst, cb) => { + scrollCallback = cb + cb(0, false) + return () => {} + }, + }) + v._willUpdate() + + // Simulate a self-write to 123.5 (subpixel target). + v.scrollToOffset(123.5, { behavior: 'auto' }) + expect(v['pendingWrite']?.target ?? null).toBe(123.5) + + // Browser fires a scroll event reporting 123 (integer-rounded). + scrollCallback!(123, false) + + // We should have reconciled the offset back to the intended 123.5, + // not stored the browser's rounded 123. + expect(v.scrollOffset).toBe(123.5) + expect(v['pendingWrite']?.target ?? null).toBeNull() +}) + +test('Phase 2a: user-initiated scroll (large delta) is NOT reconciled to intended value', () => { + let scrollCallback: ((o: number, s: boolean) => void) | null = null + const v = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => + ({ + scrollTop: 0, + scrollLeft: 0, + scrollHeight: 500, + clientHeight: 200, + offsetHeight: 200, + scrollTo: vi.fn(), + }) as any, + scrollToFn: vi.fn(), + observeElementRect: () => {}, + observeElementOffset: (_inst, cb) => { + scrollCallback = cb + cb(0, false) + return () => {} + }, + }) + v._willUpdate() + v.scrollToOffset(100, { behavior: 'auto' }) + expect(v['pendingWrite']?.target ?? null).toBe(100) + + // User then scrolls way past — browser reports 500. Diff (400) > 1.5 px + // tolerance, so we trust the browser-reported value. + scrollCallback!(500, true) + expect(v.scrollOffset).toBe(500) + expect(v['pendingWrite']?.target ?? null).toBeNull() +}) + +// ─── Phase 2b: scrollTopMax elastic-overscroll clamp ───────────────────────── + +test('Phase 2b: flush skipped when scrollTop is in elastic-overscroll zone (negative)', () => { + withFakeIOSUserAgent(() => { + const { v, el, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer() + touch('touchstart') + + // Resize during the drag: defers + v.resizeItem(0, 100) + expect(v['_holdOffset']).toBe(50) + + // User rubber-bands past the top and lets go: scrollTop is negative + // when the gesture settles. The elastic-zone check blocks the flush so + // we don't snap back to a clamped position. + el.scrollTop = -25 + scroll(-25, true) + touch('touchend') + expireTouchTail() + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_holdOffset']).toBe(50) // still deferred + + // Bounce-back resolves in-bounds. The next scroll event flushes. + el.scrollTop = 100 + scroll(100, false) + expect(scrollToFn).toHaveBeenCalled() + expect(v['_holdOffset']).toBe(0) + }) +}) + +test('Phase 2b: flush skipped when scrollTop > scrollHeight-clientHeight (overscroll bottom)', () => { + withFakeIOSUserAgent(() => { + const { v, el, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer() // clientHeight 200 → max valid scrollTop = 300 + touch('touchstart') + + v.resizeItem(0, 100) + expect(v['_holdOffset']).toBe(50) + + // User pulls past the bottom and lets go: scrollTop is 350 (> max 300) + // when the tail expires, so the flush is skipped. + el.scrollTop = 350 + scroll(350, true) + touch('touchend') + expireTouchTail() + expect(scrollToFn).not.toHaveBeenCalled() + + // Bounce-back resolves + el.scrollTop = 300 + scroll(300, false) + expect(scrollToFn).toHaveBeenCalled() + }) +}) + +test('Phase 2b: in-bounds flush proceeds normally (no regression)', () => { + withFakeIOSUserAgent(() => { + const scrollToFn = vi.fn() + let scrollCb: ((o: number, s: boolean) => void) | null = null + const el = makeMockScrollElement({ + scrollTop: 100, + scrollLeft: 0, + scrollHeight: 500, + clientHeight: 200, + offsetHeight: 200, + ownerDocument: { + defaultView: { + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + }, + }, + }) + const v = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => el as any, + scrollToFn, + observeElementRect: () => {}, + observeElementOffset: (_inst, cb) => { + scrollCb = cb + cb(100, true) + return () => {} + }, + }) + v._willUpdate() + v['getMeasurements']() + scrollToFn.mockClear() + + v.resizeItem(0, 100) + el.scrollTop = 150 + scrollCb!(150, false) // in-bounds (0..300) + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(v['_holdOffset']).toBe(0) + }) +}) + +test('Phase 2a: a second self-write replaces the intended target', () => { + let scrollCallback: ((o: number, s: boolean) => void) | null = null + const v = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => + ({ + scrollTop: 0, + scrollLeft: 0, + scrollHeight: 500, + clientHeight: 200, + offsetHeight: 200, + scrollTo: vi.fn(), + }) as any, + scrollToFn: vi.fn(), + observeElementRect: () => {}, + observeElementOffset: (_inst, cb) => { + scrollCallback = cb + cb(0, false) + return () => {} + }, + }) + v._willUpdate() + v.scrollToOffset(100, { behavior: 'auto' }) + v.scrollToOffset(200.7, { behavior: 'auto' }) + expect(v['pendingWrite']?.target ?? null).toBe(200.7) + // First scrollTo's offset (100) was overwritten — a scroll event near it + // would NOT reconcile. + scrollCallback!(101, true) + // 101 is not within 1.5px of 200.7, so browser value wins. + expect(v.scrollOffset).toBe(101) +}) + +test('iOS Phase 1: non-iOS still does NOT install touch state machine', () => { + // On non-iOS, touchend should not arm the grace timer. + _resetIOSDetectionForTests() + const scrollToFn = vi.fn() + const mockWindow = { + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + } + const { v, el } = makeIOSVirtualizerWithRealEl(scrollToFn, mockWindow) + + dispatchTouchEvent(el, 'touchstart') + expect(v['_iosGesture'].touch).toEqual({ phase: 'down', moved: false }) // touchstart still records (cheap) + dispatchTouchEvent(el, 'touchend') + // Non-iOS path returns before arming a tail + expect(v['_iosGesture'].touch).toBeNull() +}) + +test('non-iOS: adjustment is applied immediately during scroll (no regression)', () => { + // Without the iOS user-agent, the normal flow should run unchanged. + _resetIOSDetectionForTests() + const scrollToFn = vi.fn() + const v = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => + ({ + scrollTop: 100, + scrollLeft: 0, + scrollHeight: 500, + clientHeight: 200, + offsetHeight: 200, + }) as any, + scrollToFn, + observeElementRect: () => {}, + observeElementOffset: (_inst, cb) => { + cb(100, true) + return () => {} + }, + }) + v._willUpdate() + v['getMeasurements']() + scrollToFn.mockClear() + + v.resizeItem(0, 100) + + // Should have fired immediately + expect(scrollToFn).toHaveBeenCalled() + expect(v['_holdOffset']).toBe(0) +}) + +// #1250: the deferral is gated on touch provenance only. Any scroll event, +// including the read-back of our own write, sets `isScrolling`, so a +// programmatic landing used to defer its compensation past a paint. + +test('#1250: a programmatic scrollToIndex landing compensates synchronously with no touch', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll } = makeIOSTouchVirtualizer() + + v.scrollToIndex(8) + expect(scrollToFn).toHaveBeenCalledTimes(1) + scroll(250, true) // the write's own scroll-event echo: isScrolling=true + expect(v.isScrolling).toBe(true) + expect(v['_iosGesture'].touch?.phase).not.toBe('tail') // an echo never opens the tail + scrollToFn.mockClear() + + // Newly mounted rows measure. Item 0 sits above the fold, so its first + // measurement must compensate pre-paint — not defer past it. + v.resizeItem(0, 100) + expect(v['_holdOffset']).toBe(0) + expect(scrollToFn).toHaveBeenCalledTimes(1) + }) +}) + +test('#1250: a tap that never scrolls does not latch the deferral gate', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, touch, expireTouchTail } = makeIOSTouchVirtualizer() + + // Tap a row: touchstart + touchend, no scroll events at all. + touch('touchstart') + touch('touchend') + expireTouchTail() + expect(v['_iosGesture'].touch?.phase).not.toBe('down') + expect(v['_iosGesture'].touch?.phase).not.toBe('tail') + expect( + (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null, + ).toBeNull() + + // Every piece of touch state is timer-bounded, so the next compensation + // is applied synchronously rather than deferred by a stuck gate. + v.resizeItem(0, 100) + expect(v['_holdOffset']).toBe(0) + expect(scrollToFn).toHaveBeenCalledTimes(1) + }) +}) + +test('#1250: a scrollToIndex issued from a tap handler lands compensated', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch } = makeIOSTouchVirtualizer() + + // Tapping a search result: its click handler calls scrollToIndex inside + // the post-touchend window. + touch('touchstart') + touch('touchend') + expect(v['_iosGesture'].touch?.phase).toBe('tail') + + // The command's own write cancels any momentum, so it closes the tail. + v.scrollToIndex(8) + expect(v['_iosGesture'].touch?.phase).not.toBe('tail') + expect( + (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null, + ).toBeNull() + scroll(250, true) // the echo must not re-open it + expect(v['_iosGesture'].touch?.phase).not.toBe('tail') + scrollToFn.mockClear() + + v.resizeItem(0, 100) + expect(v['_holdOffset']).toBe(0) + expect(scrollToFn).toHaveBeenCalledTimes(1) + }) +}) + +test('#1250: an absolute command under a dragging finger keeps deferring', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch } = makeIOSTouchVirtualizer() + + touch('touchstart') + scroll(90, true) // the finger drags + v.scrollToIndex(8) // the dragging finger still owns the scroll + expect(v['_iosGesture'].touch?.phase).toBe('down') + scrollToFn.mockClear() + + v.resizeItem(0, 100) + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_holdOffset']).toBe(50) + }) +}) + +test('#1250: a scrollToIndex with no target leaves the fling running and the held correction', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer() + touch('touchstart') + touch('touchend') + scroll(120, true) // momentum + v.resizeItem(0, 100) + expect(v['_holdOffset']).toBe(50) + const timer = + (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null + expect(timer).not.toBeNull() + + // getOffsetForIndex finds no measurement for the index (e.g. the list was + // emptied), so scrollToIndex writes nothing. Nothing stopped the fling, + // so the tail stays open. The held correction is already in + // `scrollOffset` and stays for the flush. + v.measurementsCache = [] + v.scrollToIndex(5) + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_holdOffset']).toBe(50) + expect(v['_iosGesture'].touch?.phase).toBe('tail') + expect( + (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null, + ).toBe(timer) + + // A resize during the remaining momentum is held too rather than + // written into the fling. + v.resizeItem(1, 80) + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_holdOffset']).toBe(80) + + expireTouchTail() + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(200) + expect(v['_holdOffset']).toBe(0) + }) +}) + +test('iOS Phase 1: touchcancel releases the touch like touchend', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, touch, expireTouchTail } = makeIOSTouchVirtualizer() + + // A system gesture steals the touch: iOS fires touchcancel, never + // touchend. Without handling it the touch would stay `down` forever. + touch('touchstart') + expect(v['_iosGesture'].touch?.phase).toBe('down') + touch('touchcancel') + expect(v['_iosGesture'].touch?.phase).not.toBe('down') + expect(v['_iosGesture'].touch?.phase).toBe('tail') + + expireTouchTail() + expect(v['_iosGesture'].touch?.phase).not.toBe('tail') + + v.resizeItem(0, 100) + expect(scrollToFn).toHaveBeenCalledTimes(1) + }) +}) + +test.each([ + ['scrollToOffset', (v: any) => v.scrollToOffset(250)], + ['scrollToEnd', (v: any) => v.scrollToEnd()], +] as const)( + '#1250: a %s issued from a tap handler lands compensated', + (_name, command) => { + withFakeIOSUserAgent(() => { + const { v, el, scrollToFn, scroll, touch } = makeIOSTouchVirtualizer() + + touch('touchstart') + touch('touchend') + expect(v['_iosGesture'].touch?.phase).toBe('tail') + + // The command's own write stops any momentum, so it closes the tail. + command(v) + expect(v['_iosGesture'].touch?.phase).not.toBe('tail') + scroll(el.scrollTop, true) // the read-back must not re-open it + scrollToFn.mockClear() + + v.resizeItem(0, 100) + expect(v['_holdOffset']).toBe(0) + expect(scrollToFn).toHaveBeenCalledTimes(1) + }) + }, +) + +test('iOS deferral: under a resting finger the retarget lands and the correction is applied once', () => { + // scrollToIndex issued with a finger resting on the list (a touchstart + // handler, a long press). iOS ignores writes only under a dragging finger, + // so the command and reconcile's retarget from the grown row both land, + // and that retarget already contains the correction: the flush after the + // lift must not apply it again (@phcorp on #1318: row 20 ended 210px off). + const h = makeDeferralHarness({ start: 2000 }) + h.touch('touchstart') + h.run(() => { + h.v.scrollToIndex(20, { align: 'start' }) + h.v.resizeItem(10, 100) + for (let i = 0; i < 5; i++) { + h.frame() + h.scroll(true) + } + }) + h.touch('touchend') + h.run(() => h.scroll(false)) + h.expireTail() + expect(h.el.scrollTop).toBe(1050) + expect(h.rowTop(20)).toBe(0) +}) + +test('iOS deferral: a scroll report at rest under a resting finger is not a drag', () => { + // The command lands under a resting finger and the browser reports it, + // then reports the scroll at rest without the position moving. That is no + // drag, so a later retarget still lands and the correction is applied once + // (CodeRabbit on #1318). + const h = makeDeferralHarness({ start: 2000 }) + h.touch('touchstart') + h.run(() => { + h.v.scrollToIndex(20, { align: 'start' }) + h.scroll(true) // the write's read-back + h.scroll(false) // at rest, same position + h.v.resizeItem(10, 100) + for (let i = 0; i < 5; i++) { + h.frame() + h.scroll(true) + } + }) + h.touch('touchend') + h.run(() => h.scroll(false)) + h.expireTail() + expect(h.el.scrollTop).toBe(1050) + expect(h.rowTop(20)).toBe(0) +}) + +test('iOS deferral: a retarget iOS ignores under a finger keeps the deferred correction', () => { + // Same start, but the finger drags and reconcile retargets under it: iOS + // ignores that write, so the correction must survive it. The finger + // then scrolls, which ends the index scroll, and the flush applies it. + const h = makeDeferralHarness({ start: 2000 }) + const write = h.v.options.scrollToFn + h.v.setOptions({ + ...h.v.options, + scrollToFn: (offset, opts, inst) => { + if (!h.v['_iosGesture'].dragging()) write(offset, opts, inst) + }, + }) + h.touch('touchstart') + h.run(() => h.scroll(true, 1990)) // the finger drags + h.run(() => { + h.v.scrollToIndex(20, { align: 'start' }) + h.v.resizeItem(10, 100) + h.frame() // reconcile retargets to 1050; ignored under the finger + h.scroll(true, 2000) // the finger scrolls: the index scroll ends + }) + h.touch('touchend') + h.run(() => h.scroll(false)) + h.expireTail() + // Row 10 grew above the reader, so the content they were on moved down 50. + expect(h.el.scrollTop).toBe(2050) +}) + +test('iOS touch: a row that stops touch propagation does not hide the gesture', () => { + // The listeners run in the capture phase on the scroll element, before a + // descendant's handler can stop the event (@phcorp on #1318: a fling was + // cut short and a drag lost a correction when touches went unseen). + // Another test leaves a non-constructor ResizeObserver on window; this one + // runs on the real window, so give it a working one for its lifetime. + const prevRO = window.ResizeObserver + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver + try { + withFakeIOSUserAgent(() => { + const scrollEl = document.createElement('div') + const row = document.createElement('div') + scrollEl.appendChild(row) + document.body.appendChild(scrollEl) + const stop = (e: Event) => e.stopPropagation() + row.addEventListener('touchstart', stop) + row.addEventListener('touchend', stop) + + const v = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => scrollEl, + scrollToFn: () => {}, + observeElementRect: () => () => {}, + observeElementOffset: (_inst, cb) => { + cb(0, false) + return () => {} + }, + }) + const unmount = v._didMount() + v._willUpdate() + + row.dispatchEvent(new Event('touchstart', { bubbles: true })) + expect(v['_iosGesture'].touch).toEqual({ phase: 'down', moved: false }) + row.dispatchEvent(new Event('touchend', { bubbles: true })) + expect(v['_iosGesture'].touch?.phase).toBe('tail') + + unmount() + row.dispatchEvent(new Event('touchstart', { bubbles: true })) + expect(v['_iosGesture'].touch).toBeNull() + scrollEl.remove() + }) + } finally { + window.ResizeObserver = prevRO + } +}) + +// `scrollToOffset` closes the tail, so `_canWriteScroll()` is true while the +// command's write (270) has no read-back yet. The flush in the next +// `_willUpdate` must write the command's target (`getCommandWrittenOffset`), +// not the pre-command layout offset (250), which the read-back would then +// retire the command with as an external scroll. +test('iOS hold: a command that closes the tail is not overwritten by a flush before its read-back', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch } = makeIOSTouchVirtualizer({ + scrollTop: 200, + }) + touch('touchstart') + touch('touchend') + scroll(200, true) // momentum tail + v.resizeItem(0, 100) // above the viewport: held + expect(v['_holdOffset']).toBe(50) + expect(v.scrollOffset).toBe(250) + + // The command closes the tail and writes its target the hold short. + v.scrollToOffset(320) + expect(scrollToFn.mock.calls.at(-1)![0]).toBe(270) + scrollToFn.mockClear() + + // The adapter renders (a layout effect) before the scroll event of that + // write arrives. Whatever is written now must not undo the command. + v._willUpdate() + for (const [offset, opts] of scrollToFn.mock.calls) { + expect(offset + (opts.adjustments ?? 0)).toBe(320) + } + // The element reports the command's position; the read-back must not be + // taken for a user scroll away from the target. + scroll(scrollToFn.mock.calls.at(-1)?.[0] ?? 270, false) + expect(v.scrollOffset).toBe(320) + }) +}) + +// The same invariant through the flush in `resizeItem`, which runs even when +// `awaitingCommandReadBack` skips the compensation. +test('iOS hold: a resize landing between a command and its read-back does not flush the stale offset', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch } = makeIOSTouchVirtualizer({ + scrollTop: 200, + }) + touch('touchstart') + touch('touchend') + scroll(200, true) + v.resizeItem(0, 100) // held: 50 + v.scrollToOffset(320) // closes the tail, writes 270 + scrollToFn.mockClear() + // A row below the viewport re-measures before the read-back: no + // compensation, but `resizeItem` runs the flush. + v.resizeItem(9, 80) + for (const [offset, opts] of scrollToFn.mock.calls) { + expect(offset + (opts.adjustments ?? 0)).toBe(320) + } + scroll(scrollToFn.mock.calls.at(-1)?.[0] ?? 270, false) + expect(v.scrollOffset).toBe(320) + }) +}) diff --git a/packages/virtual-core/tests/ios-fling-wait.test.ts b/packages/virtual-core/tests/ios-fling-wait.test.ts new file mode 100644 index 000000000..e90c75075 --- /dev/null +++ b/packages/virtual-core/tests/ios-fling-wait.test.ts @@ -0,0 +1,390 @@ +import { expect, test } from 'vitest' +import { makeDeferralHarness } from './helpers' +import flings from './fixtures/ios27-flings.json' + +// ─── #1307 step 3: iOS 27, where a fling carries on past a write ──────────── +// +// On the 27.0 simulator a scrollTop write mid-fling shows for one frame and +// the fling continues on its own path: it neither stops the momentum (iOS 26 +// and earlier) nor stays. A correction is held through the tail as on every +// version; an absolute command issued mid-fling waits for the tail to close +// instead of being lost. Told apart by `CSS.supports('overflow-anchor: +// auto')`, stubbed here. + +// A fling from 2000, now passing 1500. +function flingOnIOS27(ios27 = true) { + const h = makeDeferralHarness({ start: 2000, ios27 }) + h.touch('touchstart') + h.run(() => h.scroll(true, 2010)) + h.touch('touchend') + h.run(() => h.scroll(true, 1500)) + h.writes.length = 0 + return h +} + +test('iOS 27: a scrollToIndex mid-fling waits for the tail, and the fling does not retire it', () => { + const h = flingOnIOS27() + h.run(() => { + h.v.scrollToIndex(90, { align: 'start' }) + // Not written: the fling would carry on past it. + expect(h.writes).toEqual([]) + h.scroll(true, 1200) + h.frame() + expect(h.writes).toEqual([]) + expect(h.el.scrollTop).toBe(1200) + h.scroll(false) + }) + // The tail closes 150ms after the last fling frame: written now, once. + h.expireTail() + expect(h.writes).toEqual([4500]) + h.run(() => { + h.scroll(true, 4500) + h.frame() + h.frame() + h.scroll(false) + }) + expect(h.rowTop(90)).toBe(0) + expect(h.v.scrollState).toBeNull() +}) + +test('iOS 26: a scrollToIndex mid-fling is written at once, as before', () => { + const h = flingOnIOS27(false) + h.run(() => h.v.scrollToIndex(90, { align: 'start' })) + expect(h.writes).toEqual([4500]) +}) + +test('iOS 27: a waiting index target follows rows measured during the fling', () => { + const h = flingOnIOS27() + h.run(() => { + h.v.scrollToIndex(90, { align: 'start' }) + h.v.resizeItem(30, 100) + h.scroll(true, 1200) + h.scroll(false) + }) + h.expireTail() + // Row 30 grew by 50 while the command waited: the write targets the fresh + // start of row 90, not the 4500 computed at the call. + expect(h.writes).toEqual([4550]) +}) + +// A touch that lands on a scroller still in its fling fires no touch or +// pointer events on iOS (seen on the 26.5 and 27.0 simulators and an iPhone +// on 27.0.1, #1307). Touch events arrive only once the fling has stopped, +// inside the tail's 150ms. + +test('iOS 27: a drag after the fling stopped, while the command waits, cancels it', () => { + const h = flingOnIOS27() + h.run(() => { + h.v.scrollToIndex(90, { align: 'start' }) + h.scroll(false) + }) + h.touch('touchstart') + h.run(() => h.scroll(true, 1300)) + h.touch('touchend') + h.run(() => h.scroll(false)) + h.expireTail() + expect(h.writes).toEqual([]) + expect(h.el.scrollTop).toBe(1300) + expect(h.v.scrollState).toBeNull() +}) + +test('iOS 27: a tap after the fling stopped, while the command waits, does not cancel it', () => { + const h = flingOnIOS27() + h.run(() => { + h.v.scrollToIndex(90, { align: 'start' }) + h.scroll(false) + }) + h.touch('touchstart') + h.touch('touchend') + h.expireTail() + expect(h.writes).toEqual([4500]) +}) + +test('iOS 27: a tap that stops the fling at speed (no touch events) cancels the waiting command', () => { + const h = flingOnIOS27() // heading up, passing 1500 + h.run(() => { + h.v.scrollToIndex(90, { align: 'start' }) + // Moving ~20px a frame when the finger lands: the events just stop. + for (const top of [1480, 1460, 1441, 1422]) { + h.advance(16) + h.scroll(true, top) + } + h.scroll(false) + }) + h.expireTail() + // The finger is still down: iOS would undo a write now, and the user has + // taken over. + expect(h.writes).toEqual([]) + expect(h.v.scrollState).toBeNull() +}) + +test('iOS 27: a fling caught and dragged back (no touch events) cancels the waiting command', () => { + const h = flingOnIOS27() // heading up, passing 1500 + h.run(() => { + h.v.scrollToIndex(90, { align: 'start' }) + h.scroll(true, 1300) + // A finger catches the fling and drags the other way. Momentum never + // reverses inside the scroll range. + h.scroll(true, 1350) + h.scroll(true, 1600) + h.scroll(false) + }) + h.expireTail() + expect(h.writes).toEqual([]) + expect(h.el.scrollTop).toBe(1600) + expect(h.v.scrollState).toBeNull() +}) + +test('iOS 27: a fling bouncing off the top does not cancel the waiting command', () => { + const h = flingOnIOS27() + h.run(() => { + h.v.scrollToIndex(90, { align: 'start' }) + h.scroll(true, 100) + h.scroll(true, -40) + // Back from the bounce in one frame: a reversal, but not the user's. + h.scroll(true, 0) + h.scroll(false) + }) + h.expireTail() + expect(h.writes).toEqual([4500]) +}) + +test('iOS 27: a fling bouncing off the bottom does not cancel the waiting command', () => { + const h = makeDeferralHarness({ start: 4000, ios27: true }) + h.touch('touchstart') + h.run(() => h.scroll(true, 3990)) + h.touch('touchend') + h.run(() => h.scroll(true, 4500)) + h.writes.length = 0 + h.run(() => { + h.v.scrollToIndex(10, { align: 'start' }) + h.scroll(true, 4800) // max + h.scroll(true, 4850) + h.scroll(true, 4800) + h.scroll(false) + }) + h.expireTail() + expect(h.writes).toEqual([500]) +}) + +// Scroll events recorded on an iPhone (iOS 27.0.1), replayed with their +// timing and shifted into this list (`[time ms, scrollTop]`). +function replayOnIOS27(name: string, shift: number) { + const recorded = flings.find((fling) => fling.name === name)!.samples + const [t0, top0] = recorded[0]! + // The flick that started the fling, in the fling's direction. + const back = Math.sign(recorded[1]![1]! - top0!) * 10 + const h = makeDeferralHarness({ + start: top0! + shift - 2 * back, + ios27: true, + }) + h.touch('touchstart') + h.run(() => h.scroll(true, top0! + shift - back)) + h.touch('touchend') + h.run(() => h.scroll(true, top0! + shift)) + h.writes.length = 0 + h.run(() => h.v.scrollToIndex(90, { align: 'start' })) + let last = t0! + for (const [t, top] of recorded.slice(1)) { + h.advance(t! - last) + last = t! + h.run(() => h.scroll(true, top! + shift)) + } + h.run(() => h.scroll(false)) + h.expireTail() + return h +} + +test('iOS 27: a recorded fling alone does not cancel the waiting command', () => { + const h = replayOnIOS27('one flick, glide to rest (reflick run)', -9000) + expect(h.writes).toEqual([4500]) +}) + +test.each([ + 'glide stopped by a tap 1', + 'glide stopped by a tap 2', + 'glide stopped by a tap 3', +])( + 'iOS 27: a recorded tap that stops the fling cancels the waiting command: %s', + (name) => { + const h = replayOnIOS27(name, -5000) + expect(h.writes).toEqual([]) + expect(h.v.scrollState).toBeNull() + }, +) + +test('iOS 27: a second flick during the fling (no touch events) cancels the waiting command', () => { + const h = replayOnIOS27('glide, flicked again the same way (back run)', -5000) + expect(h.writes).toEqual([]) + expect(h.v.scrollState).toBeNull() +}) + +test('iOS 27: a fling caught, held, then dragged the same way (no touch events) cancels the waiting command', () => { + const h = flingOnIOS27() // heading up, passing 1500 + h.run(() => { + h.v.scrollToIndex(90, { align: 'start' }) + for (const top of [1480, 1462, 1446]) { + h.advance(16) + h.scroll(true, top) + } + // Caught: the finger rests, nothing moves. + h.advance(100) + h.scroll(true, 1445) + // It drags on, faster than the fling was going. + for (const top of [1400, 1350, 1300]) { + h.advance(16) + h.scroll(true, top) + } + h.scroll(false) + }) + h.expireTail() + expect(h.writes).toEqual([]) + expect(h.v.scrollState).toBeNull() +}) + +test('iOS 27: a command issued after a second flick waits for the new fling', () => { + const h = flingOnIOS27() // heading up, passing 1500 + const glide = (tops: Array) => { + for (const top of tops) { + h.advance(16) + h.scroll(true, top) + } + } + h.run(() => { + h.v.scrollToIndex(90, { align: 'start' }) + glide([1490, 1481, 1473, 1466]) + // A second flick: the command waiting is the user's to drop. + glide([1430, 1390, 1350]) + expect(h.v.scrollState).toBeNull() + // A new one waits for the new fling as it slows. + h.v.scrollToIndex(80, { align: 'start' }) + glide([1312, 1276, 1242, 1210, 1180]) + expect(h.v.scrollState?.phase).toBe('queued') + // It fades out on its own. + for (const top of [1176, 1174, 1173, 1172]) { + h.advance(50) + h.scroll(true, top) + } + h.scroll(false) + }) + h.expireTail() + expect(h.writes).toEqual([4000]) +}) + +test('iOS 27: a tap late in the fling (0.21px/ms) cancels the waiting command', () => { + const h = flingOnIOS27() // heading up, passing 1500 + h.run(() => { + h.v.scrollToIndex(90, { align: 'start' }) + // ~3.4px a frame when the finger lands (another iPhone, #1307). + for (const top of [1497, 1493, 1490, 1487, 1483, 1480]) { + h.advance(16) + h.scroll(true, top) + } + h.scroll(false) + }) + h.expireTail() + expect(h.writes).toEqual([]) + expect(h.v.scrollState).toBeNull() +}) + +test('iOS 27: a fling that went on with no scroll events is not read as caught', () => { + const h = flingOnIOS27() // heading up, passing 1500 + h.run(() => { + h.v.scrollToIndex(90, { align: 'start' }) + // 0.25px/ms when the events stop... + for (const top of [1496, 1492, 1488, 1484]) { + h.advance(16) + h.scroll(true, top) + } + }) + // ...but the element keeps moving, fading out, with none. + for (const top of [1478, 1476]) { + h.advance(150) + h.el.scrollTop = top + h.expireTail() + expect(h.writes).toEqual([]) + } + // It stopped: the speed it stopped at is the silent one, not 0.25px/ms. + h.advance(150) + h.expireTail() + expect(h.writes).toEqual([4500]) +}) + +test('iOS 27: the waiting command is not written while the fling still moves between scroll events', () => { + const h = flingOnIOS27() + h.run(() => { + h.v.scrollToIndex(90, { align: 'start' }) + h.scroll(true, 1490) + }) + // The end of a fling: under a pixel per frame, so the element moves with + // no scroll event for 150ms. + h.el.scrollTop = 1488 + h.expireTail() + expect(h.writes).toEqual([]) + expect(h.v.scrollState?.phase).toBe('queued') + // At rest now: written. + h.expireTail() + expect(h.writes).toEqual([4500]) +}) + +test("iOS 27: a command issued from a tap's tail is written at once", () => { + const h = makeDeferralHarness({ start: 2000, ios27: true }) + h.touch('touchstart') + h.touch('touchend') + h.writes.length = 0 + h.run(() => h.v.scrollToIndex(90, { align: 'start' })) + // No drag before the finger lifted, so no fling to wait for. + expect(h.writes).toEqual([4500]) +}) + +test('iOS 27: the hold is released before the waiting command is written', () => { + const h = flingOnIOS27() + h.run(() => { + h.v.scrollToIndex(90, { align: 'start' }) + // Row 5 (above the viewport) grows by 50: held, the rows exposed + // shifted back by it. + h.v.resizeItem(5, 100) + expect(h.writes).toEqual([]) + expect(h.v.getVirtualItems()[0]!.start).toBe( + h.v['getMeasurements']()[h.v.getVirtualItems()[0]!.index]!.start - 50, + ) + h.scroll(false) + }) + h.expireTail() + // The hold lands where the fling ended (1500 + 50), then the command. + expect(h.writes).toEqual([1550, 4550]) + expect(h.v.getVirtualItems()[0]!.start).toBe( + h.v['getMeasurements']()[h.v.getVirtualItems()[0]!.index]!.start, + ) +}) + +test('iOS 27: a command that waits past the safety valve is dropped', () => { + const h = flingOnIOS27() + h.run(() => { + h.v.scrollToIndex(90, { align: 'start' }) + h.advance(6000) + h.frame() + }) + h.expireTail() + expect(h.writes).toEqual([]) + expect(h.v.scrollState).toBeNull() +}) + +test('iOS 27: a correction mid-fling is held through the tail, as on 26', () => { + const h = makeDeferralHarness({ start: 1000, ios27: true }) + h.writes.length = 0 + h.touch('touchstart') + h.run(() => { + h.scroll(true, 1010) + h.v.resizeItem(5, 100) + }) + h.touch('touchend') + h.run(() => { + h.scroll(true, 3000) + h.scroll(false) + }) + expect(h.writes).toEqual([]) + h.expireTail() + expect(h.el.scrollTop).toBe(3050) + expect(h.rowTop(60)).toBe(0) +}) diff --git a/packages/virtual-core/tests/measurement.test.ts b/packages/virtual-core/tests/measurement.test.ts new file mode 100644 index 000000000..ba16444d2 --- /dev/null +++ b/packages/virtual-core/tests/measurement.test.ts @@ -0,0 +1,996 @@ +import { expect, test, vi } from 'vitest' +import { + Virtualizer, + observeElementOffset, + observeElementRect, +} from '../src/index' + +// ─── resizeItem / measurement cache invalidation ───────────────────────────── +// These tests pin down the contract that resizeItem invalidates the +// getMeasurements memo so subsequent reads reflect the new sizes. +// They guard against regressions when changing the invalidation mechanism +// (e.g. Map clone → version counter). + +test('resizeItem should persist size for a single index', () => { + const virtualizer = new Virtualizer({ + count: 5, + estimateSize: () => 50, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + // Seed measurementsCache + virtualizer['getMeasurements']() + + virtualizer.resizeItem(2, 130) + + const measurements = virtualizer['getMeasurements']() + expect(measurements[2]!.size).toBe(130) + // Items after should be shifted by the delta (130 - 50 = 80) + expect(measurements[3]!.start).toBe(50 + 50 + 130) + expect(measurements[4]!.start).toBe(50 + 50 + 130 + 50) +}) + +test('resizeItem should persist sizes across many sequential calls', () => { + const N = 50 + const virtualizer = new Virtualizer({ + count: N, + estimateSize: () => 10, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + virtualizer['getMeasurements']() + + // Resize every item to a unique size + for (let i = 0; i < N; i++) { + virtualizer.resizeItem(i, 100 + i) + } + + const measurements = virtualizer['getMeasurements']() + let runningStart = 0 + for (let i = 0; i < N; i++) { + expect(measurements[i]!.size).toBe(100 + i) + expect(measurements[i]!.start).toBe(runningStart) + runningStart += 100 + i + } +}) + +test('resizeItem should invalidate getMeasurements memo even when same key resized twice', () => { + const virtualizer = new Virtualizer({ + count: 3, + estimateSize: () => 50, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + virtualizer['getMeasurements']() + + virtualizer.resizeItem(1, 100) + expect(virtualizer['getMeasurements']()[1]!.size).toBe(100) + + virtualizer.resizeItem(1, 200) + expect(virtualizer['getMeasurements']()[1]!.size).toBe(200) + + virtualizer.resizeItem(1, 75) + expect(virtualizer['getMeasurements']()[1]!.size).toBe(75) +}) + +test('resizeItem with same size as cached should be a no-op (no invalidation)', () => { + const virtualizer = new Virtualizer({ + count: 3, + estimateSize: () => 50, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + virtualizer['getMeasurements']() + virtualizer.resizeItem(0, 80) + const before = virtualizer['getMeasurements']() + const beforeRef = before + // Same value, should short-circuit (delta === 0) + virtualizer.resizeItem(0, 80) + const after = virtualizer['getMeasurements']() + // Memo should return the same array reference + expect(after).toBe(beforeRef) +}) + +test('measure() should clear size cache and lane assignments', () => { + const virtualizer = new Virtualizer({ + count: 4, + lanes: 2, + estimateSize: () => 50, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + virtualizer['getMeasurements']() + virtualizer.resizeItem(0, 200) + virtualizer.resizeItem(1, 100) + + expect(virtualizer['itemSizeCache'].size).toBe(2) + expect(virtualizer['laneAssignments'].size).toBeGreaterThan(0) + + virtualizer.measure() + + expect(virtualizer['itemSizeCache'].size).toBe(0) + expect(virtualizer['laneAssignments'].size).toBe(0) + + // After measure(), sizes should fall back to estimateSize + const measurements = virtualizer['getMeasurements']() + expect(measurements[0]!.size).toBe(50) + expect(measurements[1]!.size).toBe(50) +}) + +test('measure() should fully invalidate when a later index was dirtied without an intervening getMeasurements()', () => { + // Regression: measure() used to clear itemSizeCache but not pendingMin. + // If resizeItem() had been called without a subsequent getMeasurements() + // to flush pendingMin, the next rebuild would preserve measurementsCache + // entries before that index — even though measure() is supposed to wipe + // everything. + const virtualizer = new Virtualizer({ + count: 6, + estimateSize: () => 50, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + // Seed item 0 with a non-estimate size, then flush so it's in measurementsCache. + virtualizer.resizeItem(0, 999) + virtualizer['getMeasurements']() + // Now dirty a later index without flushing — pendingMin will be 2. + virtualizer.resizeItem(2, 888) + expect(virtualizer['pendingMin']).toBe(2) + + virtualizer.measure() + + // After measure(), pendingMin must be null so the rebuild starts at 0 + // and discards the stale item-0 entry. + expect(virtualizer['pendingMin']).toBe(null) + + const m = virtualizer['getMeasurements']() + expect(m[0]!.size).toBe(50) + expect(m[2]!.size).toBe(50) +}) + +test('measure() should trigger a re-measurement on subsequent getMeasurements', () => { + let sizeFn = (i: number) => 50 + const virtualizer = new Virtualizer({ + count: 3, + estimateSize: (i) => sizeFn(i), + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + const before = virtualizer['getMeasurements']() + expect(before[0]!.size).toBe(50) + + // Change the estimateSize function via setOptions + sizeFn = () => 100 + virtualizer.measure() + + const after = virtualizer['getMeasurements']() + expect(after[0]!.size).toBe(100) +}) + +test('resizeItem on unknown index is a no-op', () => { + const virtualizer = new Virtualizer({ + count: 3, + estimateSize: () => 50, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + virtualizer['getMeasurements']() + // Index out of bounds — should not crash + expect(() => virtualizer.resizeItem(99, 100)).not.toThrow() + + // Cache should be untouched + expect(virtualizer['itemSizeCache'].size).toBe(0) +}) + +test('resizeItem out-of-order should produce correct positions regardless of measurement order', () => { + const N = 10 + const virtualizer = new Virtualizer({ + count: N, + estimateSize: () => 20, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + virtualizer['getMeasurements']() + + // Resize in reverse order — should still produce a valid prefix-sum + for (let i = N - 1; i >= 0; i--) { + virtualizer.resizeItem(i, 30 + i) + } + + const measurements = virtualizer['getMeasurements']() + let runningStart = 0 + for (let i = 0; i < N; i++) { + expect(measurements[i]!.size).toBe(30 + i) + expect(measurements[i]!.start).toBe(runningStart) + runningStart += 30 + i + } +}) + +test('getMeasurements memo should return same array reference when nothing changed', () => { + const virtualizer = new Virtualizer({ + count: 5, + estimateSize: () => 50, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + const a = virtualizer['getMeasurements']() + const b = virtualizer['getMeasurements']() + expect(a).toBe(b) +}) + +test('getMeasurements memo should return new array reference after resizeItem', () => { + const virtualizer = new Virtualizer({ + count: 5, + estimateSize: () => 50, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + const a = virtualizer['getMeasurements']() + virtualizer.resizeItem(0, 100) + const b = virtualizer['getMeasurements']() + expect(a).not.toBe(b) + expect(b[0]!.size).toBe(100) +}) + +// ─── elementsCache leak: disconnected node cleanup ─────────────────────────── + +test('RO callback should remove disconnected node from elementsCache', () => { + // Pins down that when the ResizeObserver fires for a node that has been + // disconnected from the DOM, that node is removed from elementsCache. + // Without the fix, elementsCache accumulates stale entries. + let roCallback: ResizeObserverCallback | null = null + const MockResizeObserver = vi.fn(function (cb: ResizeObserverCallback) { + roCallback = cb + return { + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + } + }) + + const mockWindow = { + requestAnimationFrame: vi.fn(), + cancelAnimationFrame: vi.fn(), + performance: { now: () => Date.now() }, + ResizeObserver: MockResizeObserver, + } + + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 1000, + scrollHeight: 5000, + offsetWidth: 400, + offsetHeight: 600, + ownerDocument: { defaultView: mockWindow }, + } as unknown as HTMLDivElement + + const virtualizer = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => mockScrollElement, + scrollToFn: vi.fn(), + observeElementRect: (_inst, cb) => { + cb({ width: 400, height: 600 }) + return () => {} + }, + observeElementOffset: (_inst, cb) => { + cb(0, false) + return () => {} + }, + }) + + virtualizer._willUpdate() + + // Simulate React mounting an element by calling measureElement ref callback + const node = { + getAttribute: (name: string) => (name === 'data-index' ? '3' : null), + getBoundingClientRect: () => ({ height: 50, width: 400 }), + isConnected: true, + setAttribute: vi.fn(), + } as unknown as HTMLElement + + virtualizer.measureElement(node) + expect(virtualizer.elementsCache.get(3)).toBe(node) + + // Now simulate the node being disconnected from DOM + ;(node as any).isConnected = false + + // Fire the RO callback for this node — pretending it just resized + expect(roCallback).not.toBeNull() + roCallback!( + [ + { + target: node, + contentRect: { height: 50, width: 400 } as DOMRectReadOnly, + borderBoxSize: [{ blockSize: 50, inlineSize: 400 }], + contentBoxSize: [{ blockSize: 50, inlineSize: 400 }], + devicePixelContentBoxSize: [{ blockSize: 50, inlineSize: 400 }], + } as ResizeObserverEntry, + ], + {} as ResizeObserver, + ) + + // elementsCache should no longer contain the disconnected node + expect(virtualizer.elementsCache.has(3)).toBe(false) +}) + +test('measureElement should not cache a 0 size for a node that is not connected yet', () => { + // Vue calls function refs during patch, before the parent is inserted, + // so measureElement can receive a node whose offsetHeight is still 0. + let roCallback: ResizeObserverCallback | null = null + const observe = vi.fn() + const MockResizeObserver = vi.fn(function (cb: ResizeObserverCallback) { + roCallback = cb + return { + observe, + unobserve: vi.fn(), + disconnect: vi.fn(), + } + }) + + const mockWindow = { + requestAnimationFrame: vi.fn(), + cancelAnimationFrame: vi.fn(), + performance: { now: () => Date.now() }, + ResizeObserver: MockResizeObserver, + } + + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 1000, + scrollHeight: 5000, + offsetWidth: 400, + offsetHeight: 600, + ownerDocument: { defaultView: mockWindow }, + } as unknown as HTMLDivElement + + const virtualizer = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => mockScrollElement, + scrollToFn: vi.fn(), + observeElementRect: (_inst, cb) => { + cb({ width: 400, height: 600 }) + return () => {} + }, + observeElementOffset: (_inst, cb) => { + cb(0, false) + return () => {} + }, + }) + + virtualizer._willUpdate() + + const nodes = [0, 1, 2].map( + (index) => + ({ + getAttribute: (name: string) => + name === 'data-index' ? String(index) : null, + isConnected: false, + offsetHeight: 0, + offsetWidth: 0, + setAttribute: vi.fn(), + }) as unknown as HTMLElement, + ) + + nodes.forEach((node) => virtualizer.measureElement(node)) + + // Observed, but the 0 isn't cached: items keep their estimated positions. + expect(observe).toHaveBeenCalledTimes(3) + expect(virtualizer.elementsCache.get(1)).toBe(nodes[1]) + expect( + virtualizer + .getVirtualItems() + .slice(0, 3) + .map((item) => item.start), + ).toEqual([0, 50, 100]) + + nodes.forEach((node) => { + ;(node as any).isConnected = true + }) + roCallback!( + nodes.map( + (node) => + ({ + target: node, + contentRect: { height: 60, width: 400 } as DOMRectReadOnly, + borderBoxSize: [{ blockSize: 60, inlineSize: 400 }], + contentBoxSize: [{ blockSize: 60, inlineSize: 400 }], + devicePixelContentBoxSize: [{ blockSize: 60, inlineSize: 400 }], + }) as ResizeObserverEntry, + ), + {} as ResizeObserver, + ) + + expect( + virtualizer + .getVirtualItems() + .slice(0, 3) + .map((item) => item.start), + ).toEqual([0, 60, 120]) +}) + +test('RO callback should not delete cache entry if node was replaced by React', () => { + // Edge case: if React unmounts node A and mounts node B for the same key, + // a delayed RO callback for the now-disconnected node A must not delete + // the entry that now points to node B. + let roCallback: ResizeObserverCallback | null = null + const MockResizeObserver = vi.fn(function (cb: ResizeObserverCallback) { + roCallback = cb + return { + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + } + }) + + const mockWindow = { + requestAnimationFrame: vi.fn(), + cancelAnimationFrame: vi.fn(), + performance: { now: () => Date.now() }, + ResizeObserver: MockResizeObserver, + } + + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 1000, + scrollHeight: 5000, + offsetWidth: 400, + offsetHeight: 600, + ownerDocument: { defaultView: mockWindow }, + } as unknown as HTMLDivElement + + const virtualizer = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => mockScrollElement, + scrollToFn: vi.fn(), + observeElementRect: (_inst, cb) => { + cb({ width: 400, height: 600 }) + return () => {} + }, + observeElementOffset: (_inst, cb) => { + cb(0, false) + return () => {} + }, + }) + + virtualizer._willUpdate() + + // Mount nodeA at index 3 + const nodeA = { + getAttribute: () => '3', + getBoundingClientRect: () => ({ height: 50, width: 400 }), + isConnected: true, + setAttribute: vi.fn(), + } as unknown as HTMLElement + virtualizer.measureElement(nodeA) + expect(virtualizer.elementsCache.get(3)).toBe(nodeA) + + // Mount nodeB for the same index — replaces nodeA in elementsCache + const nodeB = { + getAttribute: () => '3', + getBoundingClientRect: () => ({ height: 50, width: 400 }), + isConnected: true, + setAttribute: vi.fn(), + } as unknown as HTMLElement + virtualizer.measureElement(nodeB) + expect(virtualizer.elementsCache.get(3)).toBe(nodeB) + + // Now fire a delayed RO callback for the now-disconnected nodeA. + // This must NOT delete elementsCache[3] (which points to nodeB). + ;(nodeA as any).isConnected = false + roCallback!( + [ + { + target: nodeA, + contentRect: { height: 50, width: 400 } as DOMRectReadOnly, + borderBoxSize: [{ blockSize: 50, inlineSize: 400 }], + contentBoxSize: [{ blockSize: 50, inlineSize: 400 }], + devicePixelContentBoxSize: [{ blockSize: 50, inlineSize: 400 }], + } as ResizeObserverEntry, + ], + {} as ResizeObserver, + ) + + expect(virtualizer.elementsCache.get(3)).toBe(nodeB) +}) + +test('ignores connected stale ref and ResizeObserver measurements after count shrinks', () => { + let roCallback: ResizeObserverCallback | null = null + const MockResizeObserver = vi.fn(function (cb: ResizeObserverCallback) { + roCallback = cb + return { + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + } + }) + const mockWindow = { + requestAnimationFrame: vi.fn(), + cancelAnimationFrame: vi.fn(), + performance: { now: () => Date.now() }, + ResizeObserver: MockResizeObserver, + } + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 1000, + scrollHeight: 5000, + offsetWidth: 400, + offsetHeight: 600, + ownerDocument: { defaultView: mockWindow }, + } as unknown as HTMLDivElement + + let virtualizer: Virtualizer + const getItemKey = vi.fn((index: number) => { + if (index < 0 || index >= virtualizer.options.count) { + throw new Error(`getItemKey received stale index ${index}`) + } + return index + }) + virtualizer = new Virtualizer({ + count: 22, + estimateSize: () => 50, + getItemKey, + useCachedMeasurements: true, + getScrollElement: () => mockScrollElement, + scrollToFn: vi.fn(), + observeElementRect: (_instance, cb) => { + cb({ width: 400, height: 600 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + return () => {} + }, + }) + virtualizer._willUpdate() + + const staleNode = { + getAttribute: () => '21', + getBoundingClientRect: () => ({ height: 50, width: 400 }), + isConnected: true, + setAttribute: vi.fn(), + } as unknown as HTMLElement + virtualizer.measureElement(staleNode) + + virtualizer.setOptions({ ...virtualizer.options, count: 1 }) + getItemKey.mockClear() + + expect(() => virtualizer.measureElement(staleNode)).not.toThrow() + expect(getItemKey).not.toHaveBeenCalled() + + getItemKey.mockClear() + expect(roCallback).not.toBeNull() + expect(() => { + roCallback!( + [ + { + target: staleNode, + contentRect: { height: 50, width: 400 } as DOMRectReadOnly, + borderBoxSize: [{ blockSize: 50, inlineSize: 400 }], + contentBoxSize: [{ blockSize: 50, inlineSize: 400 }], + devicePixelContentBoxSize: [{ blockSize: 50, inlineSize: 400 }], + } as ResizeObserverEntry, + ], + {} as ResizeObserver, + ) + }).not.toThrow() + expect(getItemKey).not.toHaveBeenCalled() +}) + +// ─── pending min pointer for measure storms ────────────────────────────────── + +test('resizeItem random order should rebuild from earliest dirty index', () => { + // This pins down the min-of-pending-indices behavior. If indices 5, 0, 8 are + // dirtied in that order, getMeasurements must rebuild from index 0 onward so + // all later items have correct offsets. + const N = 20 + const virtualizer = new Virtualizer({ + count: N, + estimateSize: () => 10, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + virtualizer['getMeasurements']() + + virtualizer.resizeItem(5, 50) + virtualizer.resizeItem(0, 30) + virtualizer.resizeItem(8, 70) + virtualizer.resizeItem(15, 100) + virtualizer.resizeItem(3, 40) + + const measurements = virtualizer['getMeasurements']() + // Sizes + expect(measurements[0]!.size).toBe(30) + expect(measurements[3]!.size).toBe(40) + expect(measurements[5]!.size).toBe(50) + expect(measurements[8]!.size).toBe(70) + expect(measurements[15]!.size).toBe(100) + + // Verify start/end are correct (prefix-sum invariant) for ALL items, + // even those that were not resized — they must have absorbed the shifts + // from earlier resized items. + let runningStart = 0 + for (let i = 0; i < N; i++) { + expect(measurements[i]!.start).toBe(runningStart) + runningStart += measurements[i]!.size + } +}) + +test('resizeItem in massive storm (10k items) does not crash on min lookup', () => { + // Regression: Math.min(...arr) spreads the array onto the call stack. + // V8's argument-list limit is around 125k. With many pending indices, + // this can throw RangeError. We test 10k to be well within range but + // catch any regression in the running-min mechanism. + const N = 10_000 + const virtualizer = new Virtualizer({ + count: N, + estimateSize: () => 10, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + + virtualizer['getMeasurements']() + + // Resize every item before reading measurements — accumulates N pending indices + for (let i = 0; i < N; i++) { + virtualizer.resizeItem(i, 20 + (i % 13)) + } + + expect(() => virtualizer['getMeasurements']()).not.toThrow() + const measurements = virtualizer['getMeasurements']() + expect(measurements.length).toBe(N) + // Verify last item has correct prefix-sum + let expected = 0 + for (let i = 0; i < N; i++) expected += 20 + (i % 13) + expect(measurements[N - 1]!.start + measurements[N - 1]!.size).toBe(expected) +}) + +test('changing gap invalidates cached measurements', () => { + const baseOptions = { + count: 5, + estimateSize: () => 50, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + } + const v = new Virtualizer({ ...baseOptions, gap: 0 }) + let m = v['getMeasurements']() + expect(m[1]!.start).toBe(50) + expect(v.getTotalSize()).toBe(250) + + v.setOptions({ ...baseOptions, gap: 40 }) + m = v['getMeasurements']() + // start(i) = i * (size + gap); previously stale starts survived a gap-only + // change because gap was missing from the measurement memo dependencies + expect(m[1]!.start).toBe(90) + expect(m[2]!.start).toBe(180) + expect(v.getTotalSize()).toBe(410) +}) + +test('changing gap invalidates cached measurements (multi-lane)', () => { + const baseOptions = { + count: 4, + lanes: 2, + estimateSize: () => 50, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + } + const v = new Virtualizer({ ...baseOptions, gap: 0 }) + let m = v['getMeasurements']() + expect(m[2]!.start).toBe(50) + + v.setOptions({ ...baseOptions, gap: 40 }) + m = v['getMeasurements']() + // second row in each lane starts at prevInLane.end + gap + expect(m[2]!.start).toBe(90) + expect(m[3]!.start).toBe(90) +}) + +// ─── ResizeObserver batching ───────────────────────────────────────────────── + +function setupResizeBatch(onChange: (instance: any, sync: boolean) => void) { + let roCallback: ResizeObserverCallback | null = null + const mockWindow = { + requestAnimationFrame: vi.fn(), + cancelAnimationFrame: vi.fn(), + performance: { now: () => Date.now() }, + ResizeObserver: vi.fn(function (cb: ResizeObserverCallback) { + roCallback = cb + return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() } + }), + } + const el = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 400, + scrollHeight: 100000, + offsetWidth: 400, + offsetHeight: 300, + clientHeight: 300, + ownerDocument: { defaultView: mockWindow }, + } as unknown as HTMLDivElement + const sizes = new Map() + let setOffset: (offset: number, isScrolling: boolean) => void = () => {} + const virtualizer = new Virtualizer({ + count: 20, + estimateSize: () => 90, + overscan: 0, + getScrollElement: () => el, + scrollToFn: (offset, { adjustments }) => { + el.scrollTop = offset + (adjustments ?? 0) + }, + measureElement: (node) => + sizes.get(Number((node as any).getAttribute('data-index')))!, + observeElementRect: (_instance, cb) => { + cb({ width: 400, height: 300 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + setOffset = cb + cb(0, false) + return () => {} + }, + onChange, + }) + virtualizer._willUpdate() + virtualizer.getVirtualItems() + + const nodes = new Map() + const node = (i: number) => { + if (!nodes.has(i)) { + nodes.set(i, { + getAttribute: (name: string) => + name === 'data-index' ? String(i) : null, + isConnected: true, + }) + } + return nodes.get(i) + } + // Measure items 0..9 at 100px while at offset 0, then scroll to 450: + // item 3 spans 300..400 (entirely above), item 4 spans 400..500 (across + // the fold). + for (let i = 0; i < 10; i++) { + sizes.set(i, 100) + virtualizer.measureElement(node(i)) + } + el.scrollTop = 450 + setOffset(450, false) + virtualizer._willUpdate() + virtualizer.getVirtualItems() + + const deliver = (indexes: Array) => + roCallback!( + indexes.map( + (i) => ({ target: node(i) }) as unknown as ResizeObserverEntry, + ), + {} as ResizeObserver, + ) + return { virtualizer, sizes, node, deliver } +} + +test('ResizeObserver batch: later entries see starts updated by earlier ones (#1218)', () => { + const { virtualizer, sizes, deliver } = setupResizeBatch((instance) => { + instance.getVirtualItems() + }) + expect(virtualizer.getMeasurements()[4]!.start).toBe(400) + + // Item 3 grows above the fold: compensated (+100). Item 4 spans the fold, + // so its growth happens below the anchor point and must not move scroll. + sizes.set(3, 200) + sizes.set(4, 150) + deliver([3, 4]) + + expect(virtualizer.scrollOffset).toBe(550) +}) + +test('ResizeObserver batch: a sync re-render does not drop queued entries (#1036)', () => { + const notifies: Array = [] + const { virtualizer, sizes, node, deliver } = setupResizeBatch( + (instance, sync) => { + notifies.push(sync) + // A synchronous render that moves the range unmounts item 5. + if (sync) node(5).isConnected = false + instance.getVirtualItems() + }, + ) + + notifies.length = 0 + sizes.set(3, 200) + sizes.set(5, 140) + deliver([3, 5]) + + // Both entries are measured before the single (sync) notify. + expect(virtualizer.getMeasurements()[5]!.size).toBe(140) + expect(notifies).toEqual([true]) +}) + +// ─── useAnimationFrameWithResizeObserver: frames of the scroll element's window + +// A window other than the global one, as for a list rendered into a +// `window.open` popup of the same realm. Its frames run only on `flush`. +function createOwnWindow() { + const frames = new Map() + let nextId = 0 + let roCallback: ResizeObserverCallback | null = null + const ownWindow = { + requestAnimationFrame: vi.fn((cb: FrameRequestCallback) => { + frames.set(++nextId, cb) + return nextId + }), + cancelAnimationFrame: vi.fn((id: number) => { + frames.delete(id) + }), + performance: { now: () => Date.now() }, + ResizeObserver: vi.fn(function (cb: ResizeObserverCallback) { + roCallback = cb + return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() } + }), + } + const flush = () => { + const queued = [...frames.values()] + frames.clear() + queued.forEach((cb) => cb(0)) + } + const resize = (target: Element, blockSize: number) => + roCallback!( + [ + { + target, + borderBoxSize: [{ blockSize, inlineSize: 400 }], + } as unknown as ResizeObserverEntry, + ], + {} as ResizeObserver, + ) + const el = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 400, + scrollHeight: 5000, + offsetWidth: 400, + offsetHeight: 600, + ownerDocument: { defaultView: ownWindow }, + } as unknown as HTMLDivElement + return { ownWindow, frames, flush, resize, el } +} + +function withGlobalRaf(fn: (globalRaf: ReturnType) => void) { + const globalRaf = vi.fn() + vi.stubGlobal('requestAnimationFrame', globalRaf) + try { + fn(globalRaf) + } finally { + vi.unstubAllGlobals() + } +} + +function createDeferredVirtualizer(el: HTMLDivElement) { + const virtualizer = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => el, + scrollToFn: vi.fn(), + useAnimationFrameWithResizeObserver: true, + observeElementRect: (_instance, cb) => { + cb({ width: 400, height: 600 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + return () => {} + }, + }) + virtualizer._willUpdate() + virtualizer.getVirtualItems() + const node = { + getAttribute: (name: string) => (name === 'data-index' ? '3' : null), + getBoundingClientRect: () => ({ height: 50, width: 400 }), + isConnected: true, + } as unknown as HTMLElement + virtualizer.measureElement(node) + return { virtualizer, node } +} + +test('deferred item measurements wait for a frame of the scroll element window', () => { + withGlobalRaf((globalRaf) => { + const { ownWindow, flush, resize, el } = createOwnWindow() + const { virtualizer, node } = createDeferredVirtualizer(el) + expect(virtualizer.getTotalSize()).toBe(500) + + resize(node, 80) + expect(ownWindow.requestAnimationFrame).toHaveBeenCalledTimes(1) + expect(globalRaf).not.toHaveBeenCalled() + expect(virtualizer.getTotalSize()).toBe(500) + + flush() + expect(virtualizer.getTotalSize()).toBe(530) + }) +}) + +test('cleanup cancels a deferred item measurement still waiting for its frame', () => { + const { ownWindow, frames, flush, resize, el } = createOwnWindow() + const { virtualizer, node } = createDeferredVirtualizer(el) + const unmount = virtualizer._didMount() + + resize(node, 80) + expect(frames.size).toBe(1) + + unmount() + expect(ownWindow.cancelAnimationFrame).toHaveBeenCalledTimes(1) + expect(frames.size).toBe(0) + flush() + expect(virtualizer.getTotalSize()).toBe(500) +}) + +test('observeElementRect defers to a frame of the scroll element window and cancels it on unsubscribe', () => { + withGlobalRaf((globalRaf) => { + const { frames, flush, resize, el } = createOwnWindow() + const instance = { + scrollElement: el, + targetWindow: el.ownerDocument.defaultView, + options: { useAnimationFrameWithResizeObserver: true }, + } as unknown as Virtualizer + const cb = vi.fn() + + const unsubscribe = observeElementRect(instance, cb)! + expect(cb).toHaveBeenLastCalledWith({ width: 400, height: 600 }) + + resize(el, 700) + expect(cb).toHaveBeenCalledTimes(1) + flush() + expect(cb).toHaveBeenCalledTimes(2) + expect(cb).toHaveBeenLastCalledWith({ width: 400, height: 700 }) + + resize(el, 800) + unsubscribe() + expect(frames.size).toBe(0) + flush() + expect(cb).toHaveBeenCalledTimes(2) + expect(globalRaf).not.toHaveBeenCalled() + }) +}) diff --git a/packages/virtual-core/tests/range.test.ts b/packages/virtual-core/tests/range.test.ts new file mode 100644 index 000000000..ab45f5fa7 --- /dev/null +++ b/packages/virtual-core/tests/range.test.ts @@ -0,0 +1,332 @@ +import { expect, test, vi } from 'vitest' +import { + Virtualizer, + defaultRangeExtractor, + observeElementOffset, + observeElementRect, +} from '../src/index' + +// ─── defaultRangeExtractor ─────────────────────────────────────────────────── + +test('defaultRangeExtractor: simple range with no overscan', () => { + const result = defaultRangeExtractor({ + startIndex: 5, + endIndex: 10, + overscan: 0, + count: 100, + }) + expect(result).toEqual([5, 6, 7, 8, 9, 10]) +}) + +test('defaultRangeExtractor: range with overscan', () => { + const result = defaultRangeExtractor({ + startIndex: 5, + endIndex: 10, + overscan: 2, + count: 100, + }) + expect(result).toEqual([3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) +}) + +test('defaultRangeExtractor: clamps start to 0 when overscan would go negative', () => { + const result = defaultRangeExtractor({ + startIndex: 1, + endIndex: 5, + overscan: 5, + count: 100, + }) + expect(result).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + expect(result[0]).toBe(0) +}) + +test('defaultRangeExtractor: clamps end to count-1 when overscan would go past', () => { + const result = defaultRangeExtractor({ + startIndex: 95, + endIndex: 99, + overscan: 5, + count: 100, + }) + expect(result).toEqual([90, 91, 92, 93, 94, 95, 96, 97, 98, 99]) + expect(result[result.length - 1]).toBe(99) +}) + +test('defaultRangeExtractor: overscan expands by whole rows when lanes > 1', () => { + const result = defaultRangeExtractor({ + startIndex: 35, + endIndex: 54, + overscan: 1, + count: 100, + lanes: 5, + }) + expect(result[0]).toBe(30) + expect(result[result.length - 1]).toBe(59) + expect(result.length).toBe(30) +}) + +test('defaultRangeExtractor: row overscan clamps to the list bounds', () => { + const result = defaultRangeExtractor({ + startIndex: 0, + endIndex: 98, + overscan: 2, + count: 100, + lanes: 5, + }) + expect(result[0]).toBe(0) + expect(result[result.length - 1]).toBe(99) +}) + +test('multi-lane virtualizer renders whole rows with overscan (#1036)', () => { + const virtualizer = new Virtualizer({ + count: 100, + lanes: 5, + gap: 2, + overscan: 1, + estimateSize: () => 50, + initialRect: { width: 600, height: 200 }, + initialOffset: 418, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + const indexes = virtualizer.getVirtualIndexes() + // Every rendered row is complete, so every lane gets the same number of + // items measured. A partial row lets one lane drift from the others. + expect(indexes[0]! % 5).toBe(0) + expect(indexes[indexes.length - 1]! % 5).toBe(4) +}) + +test('defaultRangeExtractor: single item range', () => { + const result = defaultRangeExtractor({ + startIndex: 5, + endIndex: 5, + overscan: 0, + count: 100, + }) + expect(result).toEqual([5]) +}) + +test('defaultRangeExtractor: returns a plain Array (not iterable proxy)', () => { + const result = defaultRangeExtractor({ + startIndex: 0, + endIndex: 3, + overscan: 0, + count: 100, + }) + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(4) +}) + +test('defaultRangeExtractor: large range produces correct length', () => { + const result = defaultRangeExtractor({ + startIndex: 0, + endIndex: 999, + overscan: 0, + count: 1000, + }) + expect(result.length).toBe(1000) + expect(result[0]).toBe(0) + expect(result[999]).toBe(999) +}) + +// ─── Lazy fast path (lanes === 1) edge cases ───────────────────────────────── +// Pins down behavior of the typed-array-backed lazy measurements view. + +test('lazy fast path: empty list (count=0)', () => { + const v = new Virtualizer({ + count: 0, + estimateSize: () => 50, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + const m = v['getMeasurements']() + expect(m.length).toBe(0) + expect(v.getTotalSize()).toBe(0) +}) + +test('lazy fast path: respects paddingStart + scrollMargin + gap', () => { + const v = new Virtualizer({ + count: 5, + estimateSize: () => 40, + paddingStart: 10, + scrollMargin: 20, + gap: 8, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + const m = v['getMeasurements']() + // First item starts at paddingStart + scrollMargin = 30 + expect(m[0]!.start).toBe(30) + expect(m[0]!.size).toBe(40) + expect(m[0]!.end).toBe(70) + // Subsequent items separated by gap + expect(m[1]!.start).toBe(70 + 8) // prev.end + gap + expect(m[1]!.size).toBe(40) +}) + +test('lazy fast path: VirtualItem fields are correct', () => { + const v = new Virtualizer({ + count: 3, + estimateSize: () => 50, + getItemKey: (i) => `item-${i}`, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + const m = v['getMeasurements']() + expect(m[0]!.index).toBe(0) + expect(m[0]!.key).toBe('item-0') + expect(m[0]!.start).toBe(0) + expect(m[0]!.size).toBe(50) + expect(m[0]!.end).toBe(50) + expect(m[0]!.lane).toBe(0) + expect(m[1]!.index).toBe(1) + expect(m[1]!.key).toBe('item-1') + expect(m[2]!.key).toBe('item-2') +}) + +test('lazy fast path: same item read twice returns identical reference (cache works)', () => { + const v = new Virtualizer({ + count: 10, + estimateSize: () => 30, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + const m = v['getMeasurements']() + const a = m[5] + const b = m[5] + expect(a).toBe(b) +}) + +test.each([ + { name: 'initial build', resizeIndex: null }, + { name: 'partial rebuild', resizeIndex: 2 }, +])( + 'lazy fast path: preserves unread item keys after $name', + ({ resizeIndex }) => { + const messages = ['a', 'b', 'c', 'd'] + const v = new Virtualizer({ + count: messages.length, + estimateSize: () => 50, + getItemKey: (index) => messages[index]!, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + expect(v.getTotalSize()).toBe(200) + + if (resizeIndex !== null) { + v.resizeItem(resizeIndex, 80) + expect(v.getTotalSize()).toBe(230) + } + + // The old layout is still needed while setOptions compares the two lists. + messages.splice(0, 1) + messages.push('e') + + expect(v.getVirtualItemForOffset(50)).toEqual({ + index: 1, + key: 'b', + start: 50, + size: 50, + end: 100, + lane: 0, + }) + }, +) + +test('lazy fast path: out-of-range access returns undefined', () => { + const v = new Virtualizer({ + count: 5, + estimateSize: () => 30, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + const m = v['getMeasurements']() + expect(m[10]).toBeUndefined() + expect(m[-1]).toBeUndefined() + expect(m[5]).toBeUndefined() +}) + +test('lazy fast path: getTotalSize after resizeItem reflects new size', () => { + const v = new Virtualizer({ + count: 5, + estimateSize: () => 30, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + expect(v.getTotalSize()).toBe(150) + v.resizeItem(2, 100) + expect(v.getTotalSize()).toBe(120 + 100) // 4 * 30 + 100 +}) + +test('lazy fast path: getVirtualItemForOffset binary search returns correct item', () => { + const v = new Virtualizer({ + count: 100, + estimateSize: () => 30, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + const found = v.getVirtualItemForOffset(500) + // Item at offset 500 should be index 16 (500/30 = 16.67) + expect(found?.index).toBe(16) + expect(found?.start).toBe(480) + expect(found?.end).toBe(510) +}) + +test('lazy fast path: 1M-item list returns a sparse view, not an eagerly-allocated array', () => { + // Functional contract for the lazy fast path: a 1M-item virtualizer + // returns measurements that report the correct total length and produce + // exact start/size/end values on indexed access without requiring the + // whole array to be materialized. Sparse spot-checks across the range + // would fail if the fast path were silently allocating N VirtualItems + // (or if the typed-array backing computed offsets incorrectly). + const v = new Virtualizer({ + count: 1_000_000, + estimateSize: () => 30, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + const m = v['getMeasurements']() + expect(m.length).toBe(1_000_000) + expect(m[0]!.start).toBe(0) + expect(m[0]!.size).toBe(30) + expect(m[0]!.end).toBe(30) + expect(m[500_000]!.start).toBe(15_000_000) + expect(m[500_000]!.end).toBe(15_000_030) + expect(m[999_999]!.start).toBe(29_999_970) + expect(m[999_999]!.end).toBe(30_000_000) +}) + +test('lazy fast path: lanes>1 still uses eager path (regression guard)', () => { + const v = new Virtualizer({ + count: 10, + lanes: 2, + estimateSize: () => 50, + getScrollElement: () => null, + scrollToFn: vi.fn(), + observeElementRect: vi.fn(), + observeElementOffset: vi.fn(), + }) + const m = v['getMeasurements']() + // Eager array, so m is a real Array; both lanes present + const lanes = new Set(m.map((x) => x.lane)) + expect(lanes.has(0)).toBe(true) + expect(lanes.has(1)).toBe(true) +}) diff --git a/packages/virtual-core/tests/scroll-commands.test.ts b/packages/virtual-core/tests/scroll-commands.test.ts new file mode 100644 index 000000000..f84b3294a --- /dev/null +++ b/packages/virtual-core/tests/scroll-commands.test.ts @@ -0,0 +1,1466 @@ +import { expect, test, vi } from 'vitest' +import { + Virtualizer, + observeElementOffset, + observeElementRect, +} from '../src/index' +import type { VirtualizerOptions } from '../src/index' + +test('should not throw when component unmounts during scrollToIndex rAF loop', () => { + // Collect rAF callbacks so we can flush them manually + const rafCallbacks: Array = [] + const mockRaf = vi.fn((cb: FrameRequestCallback) => { + rafCallbacks.push(cb) + return rafCallbacks.length + }) + + const MockResizeObserver = vi.fn(function () { + return { + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + } + }) + + const mockWindow = { + requestAnimationFrame: mockRaf, + cancelAnimationFrame: vi.fn(), + ResizeObserver: MockResizeObserver, + } + + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 1000, + scrollHeight: 5000, + offsetWidth: 400, + offsetHeight: 600, + ownerDocument: { + defaultView: mockWindow, + }, + } as unknown as HTMLDivElement + + const virtualizer = new Virtualizer({ + count: 100, + estimateSize: () => 50, + measureElement: (el) => el.getBoundingClientRect().height, + getScrollElement: () => mockScrollElement, + scrollToFn: vi.fn(), + observeElementRect: (instance, cb) => { + cb({ width: 400, height: 600 }) + return () => {} + }, + observeElementOffset: (instance, cb) => { + cb(0, false) + return () => {} + }, + }) + + // Initialize the virtualizer so targetWindow is set + virtualizer._willUpdate() + + // Populate elementsCache so isDynamicMode() returns true. + // This triggers the code path where the rAF callback calls + // this.targetWindow!.requestAnimationFrame(verify) + const mockElement = { + getBoundingClientRect: () => ({ height: 50 }), + isConnected: true, + setAttribute: vi.fn(), + } as unknown as HTMLElement + virtualizer.elementsCache.set(0, mockElement) + + // Trigger scrollToIndex which schedules a rAF callback + virtualizer.scrollToIndex(50) + + // Simulate component unmount — cleanup sets targetWindow to null + const unmount = virtualizer._didMount() + unmount() + + // Flush all pending rAF callbacks — this should not throw + // Without the fix, this crashes with: + // "Cannot read properties of null (reading 'requestAnimationFrame')" + expect(() => { + rafCallbacks.forEach((cb) => cb(0)) + }).not.toThrow() +}) + +function createMockEnvironment() { + const rafCallbacks: Array = [] + let rafIdCounter = 0 + const mockRaf = vi.fn((cb: FrameRequestCallback) => { + rafCallbacks.push(cb) + return ++rafIdCounter + }) + const mockCancelRaf = vi.fn() + + const MockResizeObserver = vi.fn(function () { + return { + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + } + }) + + const mockWindow = { + requestAnimationFrame: mockRaf, + cancelAnimationFrame: mockCancelRaf, + performance: { now: () => Date.now() }, + ResizeObserver: MockResizeObserver, + } + + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 1000, + scrollHeight: 5000, + clientWidth: 400, + clientHeight: 600, + offsetWidth: 400, + offsetHeight: 600, + ownerDocument: { + defaultView: mockWindow, + }, + scrollTo: vi.fn(), + } as unknown as HTMLDivElement + + const scrollToFn = vi.fn() + + return { rafCallbacks, mockWindow, mockScrollElement, scrollToFn } +} + +function createVirtualizer( + mockScrollElement: HTMLDivElement, + scrollToFn: ReturnType, +) { + return new Virtualizer({ + count: 100, + estimateSize: () => 50, + getScrollElement: () => mockScrollElement, + scrollToFn, + observeElementRect: (_instance, cb) => { + cb({ width: 400, height: 600 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + return () => {} + }, + }) +} + +test('scrollToIndex(0) should reconcile correctly', () => { + const { rafCallbacks, mockScrollElement, scrollToFn } = + createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + + virtualizer._willUpdate() + scrollToFn.mockClear() + + virtualizer.scrollToIndex(0) + + // scrollToFn should have been called with offset for index 0 + expect(scrollToFn).toHaveBeenCalled() + const calledOffset = scrollToFn.mock.calls[0]![0] + expect(calledOffset).toBe(0) + + // The first frame leaves room for pending ResizeObserver measurements. + rafCallbacks.shift()!(0) + expect(virtualizer['scrollState']).not.toBeNull() + rafCallbacks.shift()!(0) + + // scrollState should be cleared after settling + expect(virtualizer['scrollState']).toBeNull() +}) + +test.each([false, true])( + 'scrollToIndex waits for pending measurements (deferred observer: %s)', + (useAnimationFrameWithResizeObserver) => { + const { rafCallbacks, mockScrollElement, scrollToFn } = + createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + virtualizer.setOptions({ + ...virtualizer.options, + useAnimationFrameWithResizeObserver, + }) + virtualizer._willUpdate() + virtualizer.getVirtualItems() + virtualizer.resizeItem(98, 60) + virtualizer.getVirtualItems() + virtualizer.scrollOffset = 4400 + scrollToFn.mockClear() + + virtualizer.scrollToIndex(99, { align: 'end' }) + expect(scrollToFn).toHaveBeenLastCalledWith( + 4400, + expect.anything(), + virtualizer, + ) + + // rAF runs before ResizeObserver. An optional rAF wrapper defers the + // measurement until after the next reconciliation callback as well. + rafCallbacks.shift()!(0) + if (useAnimationFrameWithResizeObserver) { + rafCallbacks.shift()?.(0) + } + virtualizer.resizeItem(98, 90) + Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 5030 }) + virtualizer.getVirtualItems() + rafCallbacks.shift()?.(0) + + expect(scrollToFn).toHaveBeenLastCalledWith( + 4430, + expect.anything(), + virtualizer, + ) + + virtualizer.scrollOffset = 4430 + // Reconciliation must still retire once the new target is stable. + for (let frame = 0; frame < 3; frame++) { + rafCallbacks.shift()?.(0) + } + expect(virtualizer['scrollState']).toBeNull() + expect(rafCallbacks).toHaveLength(0) + }, +) + +test.each([false, true])( + 'a new scroll away from a reached index target cancels reconciliation (retargeted: %s)', + (retargeted) => { + const { rafCallbacks, mockScrollElement, scrollToFn } = + createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + let onScroll!: (offset: number, isScrolling: boolean) => void + virtualizer.setOptions({ + ...virtualizer.options, + observeElementOffset: (_instance, callback) => { + onScroll = callback + callback(0, false) + return () => {} + }, + }) + virtualizer._willUpdate() + virtualizer.getVirtualItems() + virtualizer.scrollOffset = 4400 + scrollToFn.mockClear() + + virtualizer.scrollToIndex(99, { align: 'end' }) + rafCallbacks.shift()!(0) + if (retargeted) { + virtualizer.resizeItem(97, 80) + Object.defineProperty(mockScrollElement, 'scrollHeight', { + value: 5030, + configurable: true, + }) + virtualizer.getVirtualItems() + rafCallbacks.shift()!(0) + // The target changed before its write produced a scroll event. Arrival + // at the previous target must still let an external scroll cancel it. + } + const writes = scrollToFn.mock.calls.length + onScroll(2000, true) + + // Measurements must not pull a reader back to the old target after they leave it. + virtualizer.resizeItem(98, 80) + Object.defineProperty(mockScrollElement, 'scrollHeight', { + value: retargeted ? 5060 : 5030, + }) + virtualizer.getVirtualItems() + rafCallbacks.shift()!(0) + + expect(virtualizer['scrollState']).toBeNull() + expect(virtualizer.scrollOffset).toBe(2000) + expect(scrollToFn).toHaveBeenCalledTimes(writes) + }, +) + +test.each(['auto', 'smooth'] as const)( + 'keeps an index scroll active before reaching its target (%s)', + (behavior) => { + const { mockScrollElement, scrollToFn } = createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + let onScroll!: (offset: number, isScrolling: boolean) => void + virtualizer.setOptions({ + ...virtualizer.options, + observeElementOffset: (_instance, callback) => { + onScroll = callback + callback(0, false) + return () => {} + }, + }) + virtualizer._willUpdate() + virtualizer.getVirtualItems() + virtualizer.scrollToIndex(99, { align: 'end', behavior }) + // The scroll max shrinks to 2000 before the read-back, so the browser + // reports 2000 instead of the 4400 we wrote. That is where the fresh + // target clamps too, so it isn't mistaken for the user scrolling away. + Object.defineProperty(mockScrollElement, 'scrollHeight', { + value: 2600, + configurable: true, + }) + onScroll(2000, true) + + expect(virtualizer['scrollState']).not.toBeNull() + }, +) + +test.each([30, 3000])( + 'measurement compensation does not cancel a reached index target (growth: %s)', + (growth) => { + const { rafCallbacks, mockScrollElement, scrollToFn } = + createMockEnvironment() + scrollToFn.mockImplementation((offset, { adjustments }) => { + mockScrollElement.scrollTop = Math.min(4400, offset + (adjustments ?? 0)) + }) + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + let onScroll!: (offset: number, isScrolling: boolean) => void + virtualizer.setOptions({ + ...virtualizer.options, + observeElementOffset: (_instance, callback) => { + onScroll = callback + callback(0, false) + return () => {} + }, + }) + virtualizer._willUpdate() + virtualizer.getVirtualItems() + virtualizer.scrollOffset = 2500 + virtualizer.scrollToIndex(50, { align: 'start' }) + rafCallbacks.shift()!(0) + + // The browser may clamp this adjustment until the consumer grows the sizer. + virtualizer.resizeItem(0, 50 + growth) + onScroll(mockScrollElement.scrollTop, true) + + expect(virtualizer['scrollState']).not.toBeNull() + }, +) + +test('a browser clamp to the new max does not cancel a reached scrollToEnd', () => { + const { rafCallbacks, mockScrollElement, scrollToFn } = + createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + let onScroll!: (offset: number, isScrolling: boolean) => void + virtualizer.setOptions({ + ...virtualizer.options, + paddingEnd: 40, + observeElementOffset: (_instance, callback) => { + onScroll = callback + callback(0, false) + return () => {} + }, + }) + Object.defineProperty(mockScrollElement, 'scrollHeight', { + value: 5040, + configurable: true, + }) + virtualizer._willUpdate() + virtualizer.getVirtualItems() + virtualizer.scrollOffset = 4440 + + // Already at the end, so the target counts as reached straight away. + virtualizer.scrollToEnd() + onScroll(4440, false) + + // The last row shrinks and the browser clamps scrollTop to the new max. + // That is the scrollToEnd target (paddingEnd included), not a scroll away. + virtualizer.resizeItem(99, 20) + Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 5010 }) + onScroll(4410, false) + + expect(virtualizer['scrollState']).not.toBeNull() + rafCallbacks.shift()!(0) + expect(virtualizer['scrollState']?.lastTargetOffset).toBe(4410) +}) + +test('a smooth retarget is not cancelled by its own intermediate scroll events', () => { + const { rafCallbacks, mockScrollElement, scrollToFn } = + createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + let onScroll!: (offset: number, isScrolling: boolean) => void + virtualizer.setOptions({ + ...virtualizer.options, + observeElementOffset: (_instance, callback) => { + onScroll = callback + callback(0, false) + return () => {} + }, + }) + virtualizer._willUpdate() + virtualizer.getVirtualItems() + virtualizer.scrollOffset = 4400 + + // Already at the end, so the target counts as reached straight away. + virtualizer.scrollToIndex(99, { align: 'end', behavior: 'smooth' }) + rafCallbacks.shift()!(0) + + // A visible row grows by more than a viewport, so the retarget stays smooth. + virtualizer.resizeItem(98, 1050) + Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 6000 }) + virtualizer.getVirtualItems() + rafCallbacks.shift()!(0) + expect(scrollToFn).toHaveBeenLastCalledWith( + 5400, + { adjustments: undefined, behavior: 'smooth' }, + virtualizer, + ) + + // The smooth scroll reports a position on its way to the new target. + onScroll(4800, true) + + expect(virtualizer['scrollState']).not.toBeNull() +}) + +test('scrollToOffset should reconcile and clear scrollState', () => { + const { rafCallbacks, mockScrollElement, scrollToFn } = + createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + + virtualizer._willUpdate() + scrollToFn.mockClear() + + virtualizer.scrollToOffset(200) + + expect(scrollToFn).toHaveBeenCalled() + + // scrollState should be set with index: null + expect(virtualizer['scrollState']).not.toBeNull() + expect(virtualizer['scrollState']!.index).toBeNull() + + // Simulate the scroll offset reaching the target + virtualizer.scrollOffset = 200 + + // Flush rAF — reconciliation should settle and clear scrollState + rafCallbacks.forEach((cb) => cb(0)) + + expect(virtualizer['scrollState']).toBeNull() +}) + +test('scrollBy should reconcile and clear scrollState', () => { + const { rafCallbacks, mockScrollElement, scrollToFn } = + createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + + virtualizer._willUpdate() + scrollToFn.mockClear() + + virtualizer.scrollBy(100) + + expect(virtualizer['scrollState']).not.toBeNull() + expect(virtualizer['scrollState']!.index).toBeNull() + + // Simulate scroll offset reaching the target + virtualizer.scrollOffset = 100 + + rafCallbacks.forEach((cb) => cb(0)) + + expect(virtualizer['scrollState']).toBeNull() +}) + +test('reconcileScroll should bail out after timeout', () => { + const { rafCallbacks, mockWindow, mockScrollElement, scrollToFn } = + createMockEnvironment() + + // Make performance.now() return a controllable value + let fakeTime = 1000 + mockWindow.performance.now = () => fakeTime + + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + virtualizer._willUpdate() + + virtualizer.scrollToIndex(50) + + expect(virtualizer['scrollState']).not.toBeNull() + + // Advance time past the 5s safety valve + fakeTime = 7000 + + // Flush rAF — should trigger timeout bailout + rafCallbacks.forEach((cb) => cb(0)) + + expect(virtualizer['scrollState']).toBeNull() +}) + +test('cleanup should cancel pending RAF and clear scrollState', () => { + const { mockWindow, mockScrollElement, scrollToFn } = createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + + virtualizer._willUpdate() + virtualizer.scrollToIndex(50) + + expect(virtualizer['scrollState']).not.toBeNull() + expect(virtualizer['rafId']).not.toBeNull() + + const unmount = virtualizer._didMount() + unmount() + + expect(virtualizer['scrollState']).toBeNull() + expect(virtualizer['rafId']).toBeNull() + expect(mockWindow.cancelAnimationFrame).toHaveBeenCalled() +}) + +test('cancelScroll stops reconciling toward the old target (#1285)', () => { + const { rafCallbacks, mockWindow, mockScrollElement, scrollToFn } = + createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + + virtualizer._willUpdate() + virtualizer.scrollToIndex(50, { align: 'start' }) + + expect(virtualizer['scrollState']).not.toBeNull() + expect(virtualizer['rafId']).not.toBeNull() + + // The user takes over mid-travel. + virtualizer.cancelScroll() + + expect(virtualizer['scrollState']).toBeNull() + expect(virtualizer['rafId']).toBeNull() + expect(mockWindow.cancelAnimationFrame).toHaveBeenCalled() + + // A measurement above the old target would move it; without the cancel, + // reconcile chases the new target and yanks the viewport back. + scrollToFn.mockClear() + virtualizer.resizeItem(10, 200) + rafCallbacks.forEach((cb) => cb(0)) + + expect(scrollToFn).not.toHaveBeenCalled() +}) + +test('cancelScroll restores size-change compensation after a smooth scroll', () => { + const { mockScrollElement, scrollToFn } = createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + + virtualizer._willUpdate() + virtualizer.scrollOffset = 1000 + virtualizer.scrollToIndex(50, { behavior: 'smooth' }) + virtualizer.cancelScroll() + scrollToFn.mockClear() + + // First measurement of an item above the fold is compensated again; while + // the smooth scrollState was active it would have been skipped. + virtualizer.resizeItem(0, 80) + + expect(scrollToFn).toHaveBeenCalledWith( + 1000, + expect.objectContaining({ adjustments: 30 }), + virtualizer, + ) +}) + +test('cancelScroll is a no-op when no scroll is in flight', () => { + const { rafCallbacks, mockScrollElement, scrollToFn } = + createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + + virtualizer._willUpdate() + scrollToFn.mockClear() + + expect(() => virtualizer.cancelScroll()).not.toThrow() + expect(scrollToFn).not.toHaveBeenCalled() + + // A later scroll command still works normally. + virtualizer.scrollToOffset(200) + expect(virtualizer['scrollState']).not.toBeNull() + virtualizer.scrollOffset = 200 + rafCallbacks.forEach((cb) => cb(0)) + expect(virtualizer['scrollState']).toBeNull() +}) + +test('reconcileScroll: smooth scroll retargets remain smooth while distance > viewport', () => { + // When target drifts during a smooth scroll (because newly visible items + // measured in and shifted positions), the prior behavior snapped to + // behavior:'auto' on the first retarget. New behavior: keep smooth while + // we're still more than a viewport away, snap only on final approach. + const { rafCallbacks, mockScrollElement, scrollToFn } = + createMockEnvironment() + const virtualizer = new Virtualizer({ + count: 10000, + estimateSize: () => 50, + getScrollElement: () => mockScrollElement, + scrollToFn, + observeElementRect: (_inst, cb) => { + cb({ width: 400, height: 600 }) + return () => {} + }, + observeElementOffset: (_inst, cb) => { + cb(0, false) + return () => {} + }, + }) + virtualizer._willUpdate() + scrollToFn.mockClear() + + virtualizer.scrollToIndex(5000, { behavior: 'smooth' }) + // First call: smooth, with our best estimate target + const firstCall = scrollToFn.mock.calls[0] + expect(firstCall![1].behavior).toBe('smooth') + + // Simulate a measurement that moved the target. Force resizeItem at a + // visible-enough position so getOffsetForIndex(5000) returns a different + // value than what scrollState.lastTargetOffset has. + virtualizer.resizeItem(0, 80) + + // Now trigger the reconcile RAF + rafCallbacks.forEach((cb) => cb(0)) + + // The reconcile retarget should be smooth (we're far from target). + const lastCall = scrollToFn.mock.calls[scrollToFn.mock.calls.length - 1] + expect(lastCall![1].behavior).toBe('smooth') +}) + +// ─── a prepend must not strand a travelling smooth scroll ──────────────────── +// The end-anchor prepend sync in _willUpdate writes scrollTop instantly. That +// cancels a smooth scrollToIndex the browser is still animating, and Chromium +// drops a smooth request re-issued in the frame right after that cancel, so +// re-driving from reconcileScroll cannot recover it. While a smooth +// programmatic scroll is travelling, the sync is skipped: its target is +// index-based and recomputes against the new layout, so the animation simply +// continues. Browser coverage: react-virtual e2e/app/test/smooth-prepend.spec.ts. + +function createSmoothPrependVirtualizer() { + const mockWindow = { + requestAnimationFrame: vi.fn(() => 1), + cancelAnimationFrame: vi.fn(), + performance: { now: () => Date.now() }, + ResizeObserver: vi.fn(function () { + return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() } + }), + } + const el = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 400, + scrollHeight: 10000, // 200 x 50 + clientWidth: 400, + clientHeight: 300, + offsetWidth: 400, + offsetHeight: 300, + ownerDocument: { defaultView: mockWindow }, + scrollTo: vi.fn(), + } as unknown as HTMLDivElement + + const scrollToFn = vi.fn() + let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = + null + const messages = Array.from({ length: 200 }, (_, i) => `m-${i}`) + const v = new Virtualizer({ + count: messages.length, + estimateSize: () => 50, + anchorTo: 'end', + getItemKey: (i) => messages[i]!, + getScrollElement: () => el, + scrollToFn, + observeElementRect: (_inst, cb) => { + cb({ width: 400, height: 300 }) + return () => {} + }, + observeElementOffset: (_inst, cb) => { + scrollCallback = cb + cb(9700, false) + return () => {} + }, + }) + v._willUpdate() + v.getVirtualItems() + scrollToFn.mockClear() // drop the mount sync write + + return { + v, + scrollToFn, + scroll: (offset: number, isScrolling = true) => { + scrollCallback!(offset, isScrolling) + v.getVirtualItems() + }, + // 5 messages x 50px land above the reader. + prepend: () => { + messages.unshift('m--5', 'm--4', 'm--3', 'm--2', 'm--1') + ;(el as any).scrollHeight = 10250 + v.setOptions({ + ...v.options, + count: messages.length, + getItemKey: (i: number) => messages[i]!, + }) + v._willUpdate() + }, + } +} + +test('a prepend mid-flight leaves a travelling smooth scrollToIndex alone', () => { + const { v, scrollToFn, scroll, prepend } = createSmoothPrependVirtualizer() + + v.scrollToIndex(0, { behavior: 'smooth' }) + expect(scrollToFn.mock.calls[0]![0]).toBe(0) + scroll(3500) // the browser is mid-animation + scrollToFn.mockClear() + + prepend() + + // No instant scrollTop write that would cancel the animation... + expect(scrollToFn).not.toHaveBeenCalled() + // ...and the journey is still live. + expect(v['scrollState']).toMatchObject({ index: 0, phase: 'smooth' }) +}) + +test('a prepend right after a smooth scroll landed still syncs the anchor', () => { + const { v, scrollToFn, scroll, prepend } = createSmoothPrependVirtualizer() + + v.scrollToIndex(100, { behavior: 'smooth' }) + const target = scrollToFn.mock.calls[0]![0] as number + expect(target).toBe(5000) + // Arrived; reconcileScroll has not retired scrollState yet. + scroll(target, false) + scrollToFn.mockClear() + + prepend() + + // The reader's position is preserved: the DOM is synced to the shifted offset. + expect(scrollToFn.mock.calls.at(-1)?.[0]).toBe(target + 250) +}) + +// ─── #1257: paddingEnd must not make scrollToIndex(last) overshoot the last item ───────────── +// When paddingEnd > 0, getOffsetForIndex(last, 'end') was returning the raw +// DOM max scroll offset (scrollHeight - clientHeight), which equals +// (content + paddingEnd - clientHeight). This caused scrollToIndex(last) to +// scroll past the rendered end of the last item. The fix uses +// getMaxScrollOffset() - paddingEnd + scrollPaddingEnd (clamped to +// [0, getMaxScrollOffset()]), which with scrollPaddingEnd = 0 equals +// (content - clientHeight) — the offset that keeps the last item flush with +// the bottom of the viewport. + +test('#1257: scrollToIndex(last) with paddingEnd keeps the last item flush with the viewport bottom', () => { + // 5 items × 50px = 250px content, paddingEnd = 80, scrollMargin = 0 + // viewport = 200px → total scrollHeight = 330px (250 + 80) + // Expected virtual max scroll offset: + // getMaxScrollOffset() - paddingEnd = (330 - 200) - 80 = 50 + // Without the fix (using raw scrollHeight - clientHeight): + // 330 - 200 = 130 → overshoots by 80px (exactly the paddingEnd) + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 200, + scrollHeight: 330, // 250 (content) + 80 (paddingEnd) + clientWidth: 200, + clientHeight: 200, + offsetWidth: 200, + offsetHeight: 200, + ownerDocument: { defaultView: globalThis }, + scrollTo: vi.fn(), + } as unknown as HTMLDivElement + + const scrollToFn = vi.fn() + const virtualizer = new Virtualizer({ + count: 5, + estimateSize: () => 50, + paddingEnd: 80, + getScrollElement: () => mockScrollElement, + scrollToFn, + observeElementRect: (_instance, cb) => { + cb({ width: 200, height: 200 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + return () => {} + }, + }) + + virtualizer._willUpdate() + scrollToFn.mockClear() + + // Scroll to last item with 'end' alignment + virtualizer.scrollToIndex(4, { align: 'end' }) + + // getMaxScrollOffset() = 330 - 200 = 130; minus paddingEnd(80) = 50 + expect(scrollToFn).toHaveBeenCalledWith( + 50, + expect.any(Object), + expect.any(Object), + ) +}) + +// ─── #1001: getMaxScrollOffset() preserves lane-max for multi-lane layouts ───────────── +// The fix uses getMaxScrollOffset() directly (rather than getTotalSize()), which +// preserves the lane-max behavior added in #1105 (#1001 fix). In multi-lane layouts +// where the last item lives in a shorter lane, getMaxScrollOffset() still absorbs +// DOM extras that aren't in our measurements. + +test('#1001: scrollToIndex(last) in a shorter lane uses getMaxScrollOffset() lane-max', () => { + // 2 lanes: lane 0 = [0] @300px (ends at 300), lane 1 = [1..3] @50px + // (last item ends at 150). getTotalSize() = 300 (lane-max), + // getMaxScrollOffset() = 300 - 200 = 100. + // The fix uses getMaxScrollOffset() - paddingEnd + scrollPaddingEnd = 100 - 0 + 0 = 100. + // Aligning to the last item's own end would give max(150 - 200, 0) = 0. + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 200, + scrollHeight: 300, + clientWidth: 200, + clientHeight: 200, + offsetWidth: 200, + offsetHeight: 200, + ownerDocument: { defaultView: globalThis }, + scrollTo: vi.fn(), + } as unknown as HTMLDivElement + + const scrollToFn = vi.fn() + const virtualizer = new Virtualizer({ + count: 4, + estimateSize: (index) => (index === 0 ? 300 : 50), + lanes: 2, + getScrollElement: () => mockScrollElement, + scrollToFn, + observeElementRect: (_instance, cb) => { + cb({ width: 200, height: 200 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + return () => {} + }, + }) + + virtualizer._willUpdate() + scrollToFn.mockClear() + + expect(virtualizer.measurementsCache[3]).toMatchObject({ lane: 1, end: 150 }) + + virtualizer.scrollToIndex(3, { align: 'end' }) + + // getMaxScrollOffset() = 100; minus paddingEnd(0) = 100 + expect(scrollToFn).toHaveBeenCalledWith( + 100, + expect.any(Object), + expect.any(Object), + ) +}) + +// ─── #1257: paddingEnd=0 is a no-op ───────────── +test('#1257: scrollToIndex(last) with paddingEnd=0 uses getMaxScrollOffset() unchanged', () => { + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 200, + scrollHeight: 250, // 5 × 50px content, no paddingEnd + clientWidth: 200, + clientHeight: 200, + offsetWidth: 200, + offsetHeight: 200, + ownerDocument: { defaultView: globalThis }, + scrollTo: vi.fn(), + } as unknown as HTMLDivElement + + const scrollToFn = vi.fn() + const virtualizer = new Virtualizer({ + count: 5, + estimateSize: () => 50, + paddingEnd: 0, + getScrollElement: () => mockScrollElement, + scrollToFn, + observeElementRect: (_instance, cb) => { + cb({ width: 200, height: 200 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + return () => {} + }, + }) + + virtualizer._willUpdate() + scrollToFn.mockClear() + + virtualizer.scrollToIndex(4, { align: 'end' }) + + // getMaxScrollOffset() = 250 - 200 = 50; minus paddingEnd(0) = 50 + expect(scrollToFn).toHaveBeenCalledWith( + 50, + expect.any(Object), + expect.any(Object), + ) +}) + +// ─── #1257: scrollPaddingEnd still applies to the last item ───────────── +// A sticky footer is usually reserved with paddingEnd and kept clear with +// scrollPaddingEnd. The last item must end above the footer, like any other +// end-aligned item. +test('#1257: scrollToIndex(last) with paddingEnd and scrollPaddingEnd keeps the last item above the footer', () => { + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 200, + scrollHeight: 310, // 250 (content) + 60 (paddingEnd) + clientWidth: 200, + clientHeight: 200, + offsetWidth: 200, + offsetHeight: 200, + ownerDocument: { defaultView: globalThis }, + scrollTo: vi.fn(), + } as unknown as HTMLDivElement + + const scrollToFn = vi.fn() + const virtualizer = new Virtualizer({ + count: 5, + estimateSize: () => 50, + paddingEnd: 60, + scrollPaddingEnd: 40, + getScrollElement: () => mockScrollElement, + scrollToFn, + observeElementRect: (_instance, cb) => { + cb({ width: 200, height: 200 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + return () => {} + }, + }) + + virtualizer._willUpdate() + scrollToFn.mockClear() + + virtualizer.scrollToIndex(4, { align: 'end' }) + + // item.end(250) + scrollPaddingEnd(40) - viewport(200) = 90, below + // getMaxScrollOffset() = 110 (main used 110, paddingEnd alone gives 50) + expect(scrollToFn).toHaveBeenCalledWith( + 90, + expect.any(Object), + expect.any(Object), + ) + + scrollToFn.mockClear() + virtualizer.scrollToIndex(3, { align: 'end' }) + + // The item before it lands the same way: 200 + 40 - 200 = 40 + expect(scrollToFn).toHaveBeenCalledWith( + 40, + expect.any(Object), + expect.any(Object), + ) +}) + +// ─── #1257: scrollToEnd() still reaches the DOM bottom ───────────── +// scrollToEnd() must include paddingEnd so isAtEnd() holds afterwards +// (followOnAppend and "jump to latest" UI rely on it), even though +// scrollToIndex(last, 'end') now stops at the last item. +test('#1257: scrollToEnd() with paddingEnd scrolls to getMaxScrollOffset()', () => { + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 200, + scrollHeight: 310, // 250 (content) + 60 (paddingEnd) + clientWidth: 200, + clientHeight: 200, + offsetWidth: 200, + offsetHeight: 200, + ownerDocument: { defaultView: globalThis }, + scrollTo: vi.fn(), + } as unknown as HTMLDivElement + + const scrollToFn = vi.fn() + const virtualizer = new Virtualizer({ + count: 5, + estimateSize: () => 50, + paddingEnd: 60, + getScrollElement: () => mockScrollElement, + scrollToFn, + observeElementRect: (_instance, cb) => { + cb({ width: 200, height: 200 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + return () => {} + }, + }) + + virtualizer._willUpdate() + scrollToFn.mockClear() + + virtualizer.scrollToEnd() + + // getMaxScrollOffset() = 310 - 200 = 110, paddingEnd included + expect(scrollToFn).toHaveBeenCalledWith( + 110, + expect.any(Object), + expect.any(Object), + ) +}) + +// 100 rows estimated at 50px in a 200px viewport. scrollToFn writes the +// element and clamps like a browser; scroll events are delivered by hand, so a +// test can resize between a programmatic write and its read-back. +function setupHandDrivenScroll( + extra: Partial> = {}, +) { + const rafCallbacks: Array = [] + const mockWindow = { + requestAnimationFrame: (cb: FrameRequestCallback) => rafCallbacks.push(cb), + cancelAnimationFrame: () => {}, + } + const el = { + scrollTop: 0, + scrollLeft: 0, + scrollHeight: 5000, + clientHeight: 200, + offsetHeight: 200, + ownerDocument: { defaultView: mockWindow }, + } + let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = + null + // Like iOS during a touch: drop programmatic writes on the floor. + const browser = { ignoreWrites: false } + const v = new Virtualizer({ + count: 100, + estimateSize: () => 50, + getScrollElement: () => el as any, + scrollToFn: (offset, { adjustments = 0 }) => { + if (browser.ignoreWrites) return + el.scrollTop = Math.max( + 0, + Math.min(offset + adjustments, el.scrollHeight - el.clientHeight), + ) + }, + observeElementRect: (_inst, cb) => { + cb({ width: 400, height: 200 }) + return () => {} + }, + observeElementOffset: (_inst, cb) => { + scrollCallback = cb + cb(0, false) + return () => {} + }, + ...extra, + }) + v._willUpdate() + v['getMeasurements']() + const scroll = (offset: number, isScrolling: boolean) => + scrollCallback!(offset, isScrolling) + const frame = () => rafCallbacks.splice(0).forEach((cb) => cb(0)) + const settle = () => { + for (let i = 0; i < 10 && rafCallbacks.length > 0; i++) { + frame() + scroll(el.scrollTop, true) + } + scroll(el.scrollTop, false) + } + // Runs frames without scroll events, as a browser does when a write leaves + // the element where it was. + const frames = (n = 10) => { + for (let i = 0; i < n && rafCallbacks.length > 0; i++) { + rafCallbacks.splice(0).forEach((cb) => cb(0)) + } + } + return { v, el, scroll, frame, settle, frames, browser } +} + +test('a resize before the read-back of a scrollToIndex write is left to reconcile', () => { + // Scrolling up from 2000 to row 20 (1000), row 30 grows before the scroll + // event reports 1000. Compensating it against the stale 2000 used to write + // 2050, and reconcile (target unchanged) never rewrote it until the 5s + // safety valve. + const { v, el, scroll, settle } = setupHandDrivenScroll() + el.scrollTop = 2000 + scroll(2000, false) + + v.scrollToIndex(20, { align: 'start' }) + expect(el.scrollTop).toBe(1000) + v.resizeItem(30, 100) + expect(el.scrollTop).toBe(1000) + settle() + + expect(el.scrollTop).toBe(1000) + expect(v.scrollState).toBeNull() +}) + +test('a resize above the target before the read-back is picked up by reconcile', () => { + const { v, el, scroll, settle } = setupHandDrivenScroll() + el.scrollTop = 2000 + scroll(2000, false) + + v.scrollToIndex(20, { align: 'start' }) + v.resizeItem(10, 100) + // No compensation write before the read-back... + expect(el.scrollTop).toBe(1000) + settle() + // ...reconcile retargets to the row's new start. + expect(el.scrollTop).toBe(1050) + expect(v.scrollState).toBeNull() +}) + +test('rows at the old position measured during scrollToEnd do not push it off the end', () => { + // scrollToEnd writes 4800, but until the read-back the rendered rows are + // the ones at the top. They measure 20px smaller each; compensating them + // against the written offset would walk the view up from the end while + // the end target (the DOM max, sizer not re-rendered) stays put. + const { v, el, settle } = setupHandDrivenScroll() + + v.scrollToEnd() + expect(el.scrollTop).toBe(4800) + for (let i = 0; i < 5; i++) v.resizeItem(i, 30) + expect(el.scrollTop).toBe(4800) + settle() + + expect(el.scrollTop).toBe(4800) + expect(v.scrollState).toBeNull() +}) + +test('scrollBy right after a scrollToIndex builds on the written offset', () => { + const { v, el, settle } = setupHandDrivenScroll() + + v.scrollToIndex(20, { align: 'start' }) + v.scrollBy(100) + settle() + + expect(el.scrollTop).toBe(1100) +}) + +test('scrollBy after the user scrolled builds on the user position, not an old write', () => { + const { v, el, scroll, settle } = setupHandDrivenScroll() + + v.scrollToIndex(20, { align: 'start' }) + scroll(el.scrollTop, false) // read-back of 1000 + el.scrollTop = 1300 + scroll(1300, false) // the user moved on + v.scrollBy(100) + settle() + + expect(el.scrollTop).toBe(1400) +}) + +test('scrollBy during a smooth scroll builds on the current position', () => { + // The element is still travelling, so the destination isn't where it is. + const { v, el, settle } = setupHandDrivenScroll() + + v.scrollToIndex(20, { align: 'start', behavior: 'smooth' }) + v.scrollBy(100) + settle() + + expect(el.scrollTop).toBe(100) +}) + +test('an anchorTo end list scrolled up by scrollToIndex is not pinned back by a resize before the read-back', () => { + // At the end, scrollToIndex(20) writes 1000. The last row grows before the + // read-back; against the stale offset the list still looks pinned, and the + // end-anchoring compensation used to write 4850 over the index scroll. + const { v, el, scroll, settle } = setupHandDrivenScroll({ anchorTo: 'end' }) + el.scrollTop = 4800 + scroll(4800, false) + + v.scrollToIndex(20, { align: 'start' }) + v.resizeItem(99, 100) + settle() + + expect(el.scrollTop).toBe(1000) + expect(v.scrollState).toBeNull() +}) + +test('scrollBy past the end does not leave the next scrollBy building on an unreachable offset', () => { + // A write the browser clamps to where the element already is fires no + // scroll event, so nothing reads it back. + const { v, el, scroll, frames } = setupHandDrivenScroll() + el.scrollTop = 4800 + scroll(4800, false) + + v.scrollBy(100) + frames() + expect(el.scrollTop).toBe(4800) + v.scrollBy(-100) + + expect(el.scrollTop).toBe(4700) +}) + +// ─── #1307 step 5: an offset target follows the corrections made after its +// read-back, and a prepend before a command's read-back is left to reconcile + +test('scrollToOffset: a row above growing after the read-back moves the target with it, and the command retires', () => { + // The compensation moved the element to 2050 while the target stayed 2000: + // reconcile saw neither a moved target nor a reached one and kept running + // until the 5s safety valve. + const { v, el, scroll, settle } = setupHandDrivenScroll() + + v.scrollToOffset(2000) + scroll(2000, true) + v.resizeItem(10, 100) + expect(el.scrollTop).toBe(2050) + settle() + + expect(el.scrollTop).toBe(2050) + expect(v.scrollState).toBeNull() +}) + +test('scrollBy: a row above growing after the read-back moves the target with it, and the command retires', () => { + const { v, el, scroll, settle } = setupHandDrivenScroll() + el.scrollTop = 1500 + scroll(1500, false) + + v.scrollBy(500) + scroll(2000, true) + v.resizeItem(10, 100) + expect(el.scrollTop).toBe(2050) + settle() + + expect(el.scrollTop).toBe(2050) + expect(v.scrollState).toBeNull() +}) + +test('scrollToOffset: a prepend after the read-back moves the target with the anchor sync, and the command retires', () => { + const { v, el, scroll, settle } = setupHandDrivenScroll({ + anchorTo: 'end', + getItemKey: (i) => i, + }) + + v.scrollToOffset(2000) + scroll(2000, true) + v.setOptions({ ...v.options, count: 112, getItemKey: (i) => i - 12 }) + v._willUpdate() + expect(el.scrollTop).toBe(2600) + settle() + + expect(el.scrollTop).toBe(2600) + expect(v.scrollState).toBeNull() +}) + +test('scrollToOffset: a prepend held by a bounce before the commit moves the target with it', () => { + // At the max (4800). The options change finds the element writable, a + // bounce past the max lands before the commit, and `_willUpdate` holds + // the prepend instead; the target follows there too, so the command + // retires once the hold lands. + const { v, el, scroll, settle } = setupHandDrivenScroll({ + anchorTo: 'end', + getItemKey: (i) => i, + }) + + v.scrollToOffset(4800) + scroll(4800, true) + v.setOptions({ ...v.options, count: 112, getItemKey: (i) => i - 12 }) + el.scrollTop = 4810 + scroll(4810, true) + v._willUpdate() + expect(el.scrollTop).toBe(4810) + // The bounce returns; the sizer has grown by the prepend. + el.scrollHeight = 5600 + el.scrollTop = 4800 + scroll(4800, true) + expect(el.scrollTop).toBe(5400) + settle() + + expect(el.scrollTop).toBe(5400) + expect(v.scrollState).toBeNull() +}) + +test('scrollToOffset: a prepend held by a bounce before the options change moves the target with it', () => { + // As above, but the bounce lands first, so `setOptions` itself holds the + // prepend. The target stayed 4800 and reconcile ran until the safety valve. + const { v, el, scroll, settle } = setupHandDrivenScroll({ + anchorTo: 'end', + getItemKey: (i) => i, + }) + + v.scrollToOffset(4800) + scroll(4800, true) + el.scrollTop = 4810 + scroll(4810, true) + v.setOptions({ ...v.options, count: 112, getItemKey: (i) => i - 12 }) + v._willUpdate() + expect(el.scrollTop).toBe(4810) + el.scrollHeight = 5600 + el.scrollTop = 4800 + scroll(4800, true) + expect(el.scrollTop).toBe(5400) + settle() + + expect(el.scrollTop).toBe(5400) + expect(v.scrollState).toBeNull() +}) + +test('a prepend before the read-back of a scrollToIndex write is left to reconcile', () => { + // scrollToIndex(20) wrote 1000 while `scrollOffset` still holds 2000. The + // anchor sync used to write the stale offset plus the prepend (2600) over + // the command, and reconcile (index target unchanged at 1000) never wrote + // again until the safety valve. + const { v, el, scroll, settle } = setupHandDrivenScroll({ + anchorTo: 'end', + getItemKey: (i) => i, + }) + el.scrollTop = 2000 + scroll(2000, false) + + v.scrollToIndex(20, { align: 'start' }) + expect(el.scrollTop).toBe(1000) + v.setOptions({ ...v.options, count: 112, getItemKey: (i) => i - 12 }) + v._willUpdate() + // No anchor write before the read-back... + expect(el.scrollTop).toBe(1000) + scroll(1000, true) + settle() + // ...the index scroll lands on index 20 of the new list. + expect(el.scrollTop).toBe(1000) + expect(v.scrollState).toBeNull() +}) + +test('a prepend before the read-back of a scrollToOffset write is left to reconcile', () => { + const { v, el, scroll, settle } = setupHandDrivenScroll({ + anchorTo: 'end', + getItemKey: (i) => i, + }) + el.scrollTop = 2000 + scroll(2000, false) + + v.scrollToOffset(1000) + v.setOptions({ ...v.options, count: 112, getItemKey: (i) => i - 12 }) + v._willUpdate() + expect(el.scrollTop).toBe(1000) + scroll(1000, true) + settle() + + expect(el.scrollTop).toBe(1000) + expect(v.scrollState).toBeNull() +}) + +test('scrollBy past the start does not leave the next scrollBy building on an unreachable offset', () => { + const { v, el, frames } = setupHandDrivenScroll() + + v.scrollBy(-100) + frames() + expect(el.scrollTop).toBe(0) + v.scrollBy(100) + + expect(el.scrollTop).toBe(100) +}) + +test('scrollBy after the safety valve dropped an ignored write builds on the real position', () => { + const { v, el, scroll, frames, browser } = setupHandDrivenScroll() + el.scrollTop = 2000 + scroll(2000, false) + let now = 0 + v['now'] = () => now + + browser.ignoreWrites = true + v.scrollToIndex(20, { align: 'start' }) + now = 6000 + frames() + expect(v.scrollState).toBeNull() + browser.ignoreWrites = false + v.scrollBy(100) + + expect(el.scrollTop).toBe(2100) +}) + +test('scrollBy after cancelScroll dropped an ignored write builds on the real position', () => { + const { v, el, scroll, browser } = setupHandDrivenScroll() + el.scrollTop = 2000 + scroll(2000, false) + + browser.ignoreWrites = true + v.scrollToIndex(20, { align: 'start' }) + v.cancelScroll() + browser.ignoreWrites = false + v.scrollBy(100) + + expect(el.scrollTop).toBe(2100) +}) + +test('a smooth scrollToIndex switched to auto near the end is not cancelled by a late animation event', () => { + // Reconcile switches a smooth scroll to 'auto' for the final approach when + // the target moves within a viewport. WebKit can still deliver a scroll + // event from the smooth animation after that instant write; it is not the + // user and must not end the scroll. + const { v, el, scroll, frame, settle } = setupHandDrivenScroll() + + v.scrollToIndex(50, { align: 'start', behavior: 'smooth' }) + el.scrollTop = 2450 // the animation is almost there + scroll(2450, true) + v.resizeItem(10, 100) // the target moves to 2550, within a viewport + frame() // reconcile switches to 'auto' and writes 2550 + expect(el.scrollTop).toBe(2550) + + el.scrollTop = 2470 // a late event from the smooth animation + scroll(2470, true) + expect(v.scrollState).not.toBeNull() + + el.scrollTop = 2550 + scroll(2550, true) + settle() + expect(el.scrollTop).toBe(2550) + expect(v.scrollState).toBeNull() +}) + +test('a scrollToIndex write clamped by a sizer that shrank below it still reaches the fresh target', () => { + // The sizer re-renders before the scroll event reports the write (a + // same-task render after the command). Rows above the target shrink by + // 600px, so the max drops from 4800 to 4200 and the target from 4500 to + // 3900. The read-back at the new max is our write, clamped: it used to + // read as a user scroll and the index scroll stopped at 4200. + const { v, el, scroll, settle } = setupHandDrivenScroll() + + v.scrollToIndex(90, { align: 'start' }) + expect(el.scrollTop).toBe(4500) + for (let i = 0; i < 20; i++) v.resizeItem(i, 20) + // The re-render that shrinks the sizer also rebuilds the measurements. + v.getVirtualItems() + el.scrollHeight = 4400 + el.scrollTop = 4200 + scroll(4200, true) + settle() + + expect(el.scrollTop).toBe(3900) + expect(v.scrollState).toBeNull() +}) + +test('a user scroll to the max while a write above it is pending is still the user', () => { + // The clamped read-back is only a read-back when the pending write sits + // past the max. A write within the range that the user overtakes to the + // bottom reads as the user's and retires the command. + const { v, el, scroll, frame, settle } = setupHandDrivenScroll() + + v.scrollToIndex(20, { align: 'start' }) + expect(el.scrollTop).toBe(1000) + el.scrollTop = 4800 + scroll(4800, true) + // A row above grows while the user is still scrolling: a command still + // alive would retarget to 1050 and pull the user back. + v.resizeItem(5, 100) + frame() + expect(el.scrollTop).toBe(4800) + settle() + + expect(el.scrollTop).toBe(4800) + expect(v.scrollState).toBeNull() +}) + +test('a write to a scroll element that went away is not adopted by the next one', () => { + // A write is matched to its read-back within 1.5px. After the element is + // swapped (or the virtualizer disabled), the old write is never read back, + // so the new element's first report is taken as it is. + const mk = (top: number) => ({ + scrollTop: top, + scrollLeft: 0, + scrollHeight: 5000, + clientHeight: 200, + offsetHeight: 200, + }) + let el = mk(0) + const v = new Virtualizer({ + count: 100, + estimateSize: () => 50, + getScrollElement: () => el as any, + scrollToFn: () => {}, + observeElementRect: (_inst, cb) => { + cb({ width: 400, height: 200 }) + return () => {} + }, + observeElementOffset: (_inst, cb) => { + cb(el.scrollTop, false) + return () => {} + }, + }) + v._willUpdate() + v['getMeasurements']() + + v.scrollToOffset(1000.5) // written, never read back on the old element + el = mk(1001) + v._willUpdate() + + expect(v.scrollOffset).toBe(1001) +})