From 92c697f7b9c5189137b14f0b0fb41a29eddedbc6 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Sat, 10 Oct 2026 19:49:08 +0200 Subject: [PATCH 01/46] fix(virtual-core): keep lanes aligned after changing lanes with measureElement (#1302) --- .changeset/lanes-row-alignment.md | 8 + docs/api/virtualizer.md | 4 +- .../e2e/app/lanes-change/index.html | 10 ++ .../e2e/app/lanes-change/main.tsx | 78 +++++++++ .../e2e/app/test/lanes-change.spec.ts | 54 ++++++ packages/react-virtual/e2e/app/vite.config.ts | 1 + packages/virtual-core/src/index.ts | 120 +++++++++---- packages/virtual-core/tests/index.test.ts | 164 ++++++++++++++++++ 8 files changed, 399 insertions(+), 40 deletions(-) create mode 100644 .changeset/lanes-row-alignment.md create mode 100644 packages/react-virtual/e2e/app/lanes-change/index.html create mode 100644 packages/react-virtual/e2e/app/lanes-change/main.tsx create mode 100644 packages/react-virtual/e2e/app/test/lanes-change.spec.ts diff --git a/.changeset/lanes-row-alignment.md b/.changeset/lanes-row-alignment.md new file mode 100644 index 000000000..d7e81619a --- /dev/null +++ b/.changeset/lanes-row-alignment.md @@ -0,0 +1,8 @@ +--- +'@tanstack/virtual-core': minor +--- + +fix(virtual-core): keep lanes aligned after changing `lanes` with `measureElement` (#1036) + +- `overscan` now counts whole rows when `lanes > 1`, so every lane renders (and measures) the same number of extra items. This renders more items than before: `lanes: 4, overscan: 2` now adds 8 items on each side instead of 2. `Range` gains an optional `lanes` field that `defaultRangeExtractor` reads; a custom `rangeExtractor` gets row overscan only if it delegates to `defaultRangeExtractor`. +- A ResizeObserver callback now measures all of its entries before notifying, so a synchronous re-render can no longer unmount part of a row before it is measured. diff --git a/docs/api/virtualizer.md b/docs/api/virtualizer.md index 0a910e25c..4d8885363 100644 --- a/docs/api/virtualizer.md +++ b/docs/api/virtualizer.md @@ -80,7 +80,7 @@ The sync parameter indicates whether scrolling is currently in progress. It is ` overscan?: number ``` -The number of items to render above and below the visible area. Increasing this number will increase the amount of time it takes to render the virtualizer, but might decrease the likelihood of seeing slow-rendering blank items at the top and bottom of the virtualizer when scrolling. The default value is `1`. +The number of items to render above and below the visible area. Increasing this number will increase the amount of time it takes to render the virtualizer, but might decrease the likelihood of seeing slow-rendering blank items at the top and bottom of the virtualizer when scrolling. The default value is `1`. When `lanes` is greater than `1`, overscan counts whole rows of `lanes` items, so every lane renders the same number of extra items. ### `horizontal` @@ -146,7 +146,7 @@ This function is passed the index of each item and should return a unique key fo rangeExtractor?: (range: Range) => number[] ``` -This function receives visible range indexes and should return array of indexes to render. This is useful if you need to add or remove items from the virtualizer manually regardless of the visible range, eg. rendering sticky items, headers, footers, etc. The default range extractor implementation will return the visible range indexes and is exported as `defaultRangeExtractor`. +This function receives visible range indexes and should return array of indexes to render. This is useful if you need to add or remove items from the virtualizer manually regardless of the visible range, eg. rendering sticky items, headers, footers, etc. The default range extractor implementation will return the visible range indexes and is exported as `defaultRangeExtractor`. When `lanes` is greater than `1`, `range.lanes` holds the lane count and `defaultRangeExtractor` applies `overscan` as whole rows; a custom extractor that computes its own indexes should do the same to keep lanes aligned. ### `scrollToFn` diff --git a/packages/react-virtual/e2e/app/lanes-change/index.html b/packages/react-virtual/e2e/app/lanes-change/index.html new file mode 100644 index 000000000..56f418f61 --- /dev/null +++ b/packages/react-virtual/e2e/app/lanes-change/index.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/packages/react-virtual/e2e/app/lanes-change/main.tsx b/packages/react-virtual/e2e/app/lanes-change/main.tsx new file mode 100644 index 000000000..37adc028c --- /dev/null +++ b/packages/react-virtual/e2e/app/lanes-change/main.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { useVirtualizer } from '@tanstack/react-virtual' + +// Port of the reproduction in TanStack/virtual#1036: a grid of square cells +// whose height follows the lane width, so a lane change resizes every item. +const itemSize = 50 +const gap = 2 +const items = new Array(100) + +function App() { + const [lanes, setLanes] = React.useState(10) + const parentRef = React.useRef(null) + + const rowVirtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => itemSize, + lanes, + gap, + }) + ;(window as any).virtualizer = rowVirtualizer + + const visibleItems = rowVirtualizer.getVirtualItems() + + return ( + <> +
+
+ {visibleItems.map((virtualRow) => { + const widthPrc = 100 / rowVirtualizer.options.lanes + return ( +
+
+ {virtualRow.index} +
+
+ ) + })} +
+
+ + + ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/packages/react-virtual/e2e/app/test/lanes-change.spec.ts b/packages/react-virtual/e2e/app/test/lanes-change.spec.ts new file mode 100644 index 000000000..4e6bdd039 --- /dev/null +++ b/packages/react-virtual/e2e/app/test/lanes-change.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from '@playwright/test' +import type { Page } from '@playwright/test' + +// Layout of the rendered cells. `measured` is true once every rendered cell's +// virtual size (the wrapper height) matches the real height of its square +// content, i.e. the virtualizer has measured everything on screen. +// `misaligned` lists rows (index / lanes) whose cells don't share one start, +// meaning a lane drifted away from the others. +const layout = (page: Page) => + page.evaluate(() => { + const lanes = (window as any).virtualizer.options.lanes as number + const rows = new Map>() + let measured = true + for (const el of document.querySelectorAll('.cell')) { + const content = el.firstElementChild as HTMLElement + if (el.offsetHeight !== content.offsetHeight) measured = false + const row = Math.floor(Number(content.dataset.index) / lanes) + if (!rows.has(row)) rows.set(row, new Set()) + rows.get(row)!.add(Number(el.dataset.start)) + } + const misaligned = [...rows] + .filter(([, starts]) => starts.size > 1) + .map(([row, starts]) => `row ${row}: ${[...starts].join(', ')}`) + return { lanes, measured, misaligned } + }) + +test('rows stay aligned after changing lanes while scrolled to the end (#1036)', async ({ + page, +}) => { + await page.goto('/lanes-change/') + + // Scroll to the end until it settles: measurements can grow the list. + await expect(async () => { + const atEnd = await page.evaluate(() => { + const c = document.querySelector('#scroll-container')! + c.scrollTop = c.scrollHeight + return c.scrollTop + c.clientHeight >= c.scrollHeight - 1 + }) + expect(atEnd).toBe(true) + expect((await layout(page)).measured).toBe(true) + }).toPass() + + await page.click('[data-testid="toggle"]') + + // Right after the toggle every cell still has the same estimate, so rows + // are trivially aligned. Wait until the 5-lane sizes are measured. + await expect + .poll(async () => { + const { lanes, measured } = await layout(page) + return lanes === 5 && measured + }) + .toBe(true) + expect((await layout(page)).misaligned).toEqual([]) +}) diff --git a/packages/react-virtual/e2e/app/vite.config.ts b/packages/react-virtual/e2e/app/vite.config.ts index 87bc8b9d7..a7910bcf5 100644 --- a/packages/react-virtual/e2e/app/vite.config.ts +++ b/packages/react-virtual/e2e/app/vite.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ __dirname, 'direct-dom-updates/index.html', ), + 'lanes-change': path.resolve(__dirname, 'lanes-change/index.html'), 'cached-measurements': path.resolve( __dirname, 'cached-measurements/index.html', diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index d7748de49..8a2896f1f 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -56,6 +56,11 @@ export interface Range { endIndex: number overscan: number count: number + /** + * Lane count of the virtualizer. When greater than 1, `overscan` counts + * rows of `lanes` items rather than single items. + */ + lanes?: number } type Key = number | string | bigint @@ -84,8 +89,13 @@ const getRect = (element: HTMLElement): Rect => { export const defaultKeyExtractor = (index: number) => index export const defaultRangeExtractor = (range: Range) => { - const start = Math.max(range.startIndex - range.overscan, 0) - const end = Math.min(range.endIndex + range.overscan, range.count - 1) + // With lanes, the visible range is already aligned to whole rows; overscan + // by whole rows too, so every lane gets the same number of items rendered + // (and measured). Overscanning single items leaves partial rows, letting one + // lane collect more measured sizes than the others and drift (#1036). + const overscan = range.overscan * Math.max(range.lanes ?? 1, 1) + const start = Math.max(range.startIndex - overscan, 0) + const end = Math.min(range.endIndex + overscan, range.count - 1) const len = end - start + 1 const arr = new Array(len) @@ -505,6 +515,9 @@ export class Virtualizer< instance: Virtualizer, ) => boolean) elementsCache = new Map() + // Set while a ResizeObserver callback measures its entries; `resizeItem` + // records the pending notify here instead of firing it per entry. + private _resizeBatch: { sync: boolean; notify?: boolean } | null = null private now = () => this.targetWindow?.performance?.now?.() ?? Date.now() private observer = (() => { let _ro: ResizeObserver | null = null @@ -519,46 +532,60 @@ export class Virtualizer< } return (_ro = new this.targetWindow.ResizeObserver((entries) => { - entries.forEach((entry) => { - const run = () => { - const node = entry.target as TItemElement - const index = this.indexFromElement(node) - - if (!node.isConnected) { - this.observer.unobserve(node) - // Find the cache entry pointing to this exact node and remove - // it. We can't call getItemKey(index) here because items may - // have been removed since this node was rendered — the index - // could be stale and out-of-bounds in the user's data array - // (regression test in e2e/.../stale-index.spec.ts, fix #1148). - // The === comparison naturally handles the React-replaced- - // a-node-for-the-same-key case: that entry now points to a - // different node, so this loop won't match. - for (const [cacheKey, cachedNode] of this.elementsCache) { - if (cachedNode === node) { - this.elementsCache.delete(cacheKey) - break - } - } - return - } - - if (!this.isIndexInRange(index)) return - - if (this.shouldMeasureDuringScroll(index)) { - this.resizeItem( - index, - this.options.measureElement(node, entry, this), - ) + const run = () => { + // Measure every entry before notifying. A notify that adjusted + // scroll re-renders synchronously, and that render can unmount + // nodes whose entries are still queued here, leaving them + // unmeasured. With lanes, a half-measured row lets one lane drift + // away from the others (#1036). + const batch: { sync: boolean; notify?: boolean } = { sync: false } + this._resizeBatch = batch + try { + entries.forEach(measureEntry) + } finally { + this._resizeBatch = null + if (batch.notify) { + this.notify(batch.sync) + this._retryClampedAdjustment() } } - this.options.useAnimationFrameWithResizeObserver - ? requestAnimationFrame(run) - : run() - }) + } + this.options.useAnimationFrameWithResizeObserver + ? requestAnimationFrame(run) + : run() })) } + const measureEntry = (entry: ResizeObserverEntry) => { + const node = entry.target as TItemElement + const index = this.indexFromElement(node) + + if (!node.isConnected) { + this.observer.unobserve(node) + // Find the cache entry pointing to this exact node and remove + // it. We can't call getItemKey(index) here because items may + // have been removed since this node was rendered — the index + // could be stale and out-of-bounds in the user's data array + // (regression test in e2e/.../stale-index.spec.ts, fix #1148). + // The === comparison naturally handles the React-replaced- + // a-node-for-the-same-key case: that entry now points to a + // different node, so this loop won't match. + for (const [cacheKey, cachedNode] of this.elementsCache) { + if (cachedNode === node) { + this.elementsCache.delete(cacheKey) + break + } + } + return + } + + if (!this.isIndexInRange(index)) return + + if (this.shouldMeasureDuringScroll(index)) { + this.resizeItem(index, this.options.measureElement(node, entry, this)) + } + } + return { disconnect: () => { get()?.disconnect() @@ -1610,9 +1637,10 @@ export class Virtualizer< this.options.count, startIndex, endIndex, + this.options.lanes, ] }, - (rangeExtractor, overscan, count, startIndex, endIndex) => { + (rangeExtractor, overscan, count, startIndex, endIndex, lanes) => { return startIndex === null || endIndex === null ? [] : rangeExtractor({ @@ -1620,6 +1648,7 @@ export class Virtualizer< endIndex, overscan, count, + lanes, }) }, { @@ -1718,6 +1747,14 @@ export class Virtualizer< resizeItem = (index: number, size: number) => { if (!this.isIndexInRange(index)) return + // An earlier entry in this ResizeObserver batch moved `scrollOffset`. + // Outside a batch its sync notify would have re-rendered and rebuilt the + // caches read below; rebuild them here so the anchoring check compares + // this item's current start, not a stale one, against the adjusted + // offset (#1218). Without a scroll move, the caches are exactly as stale + // as they were before batching, so skip the rebuild. + if (this._resizeBatch?.sync) this.getMeasurements() + // Fast field reads. For lanes===1 we read raw start/size from the flat // typed array, avoiding a Proxy.get + VirtualItem allocation per call. // For lanes>1 we fall back to the cached VirtualItem array. @@ -1809,6 +1846,13 @@ export class Virtualizer< // synchronous notify flushes the render in this same callback, so both // land in one paint. When nothing moved (or the write was deferred on // iOS), keep the cheaper async notify. + if (this._resizeBatch) { + // Inside a ResizeObserver callback: notify once after every entry + // is measured, synchronously if any of them moved `scrollTop`. + this._resizeBatch.notify = true + this._resizeBatch.sync ||= adjustedSync + return + } this.notify(adjustedSync) // A consumer that grows the sizer synchronously inside `onChange` // (direct DOM updates) may never re-render when the range is diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index db7851eed..e2511385b 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -1679,6 +1679,52 @@ test('defaultRangeExtractor: clamps end to count-1 when overscan would go past', 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, @@ -4922,3 +4968,121 @@ test('#1257: scrollToEnd() with paddingEnd scrolls to getMaxScrollOffset()', () 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]) +}) From 28a21741bcc5055bbda9891655630989dc6b7517 Mon Sep 17 00:00:00 2001 From: phcorp Date: Sun, 11 Oct 2026 11:36:19 +0200 Subject: [PATCH 02/46] fix(virtual-core): don't replay iOS-deferred size corrections after an in-flight scrollToIndex lands (#1305) --- .changeset/ios-index-scroll-deferral.md | 5 ++ packages/virtual-core/src/index.ts | 10 +++- packages/virtual-core/tests/index.test.ts | 67 +++++++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 .changeset/ios-index-scroll-deferral.md diff --git a/.changeset/ios-index-scroll-deferral.md b/.changeset/ios-index-scroll-deferral.md new file mode 100644 index 000000000..311c334cc --- /dev/null +++ b/.changeset/ios-index-scroll-deferral.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): don't replay iOS-deferred size corrections after an in-flight `scrollToIndex` lands diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 8a2896f1f..38989f0f0 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -787,7 +787,11 @@ export class Virtualizer< isIOSWebKit() && (this.isScrolling || this._iosTouching || this._iosJustTouchEnded) ) { - this._iosDeferredAdjustment += delta + // An index scroll retargets from fresh measurements in reconcileScroll; + // replaying this delta after it lands would count it twice. + if (this.scrollState?.index == null) { + this._iosDeferredAdjustment += delta + } return false } else { const target = this.getScrollOffset() + this.scrollAdjustments + delta @@ -1219,6 +1223,10 @@ export class Virtualizer< return } + // A resize since the last render leaves measurementsCache stale until + // something reads getMeasurements(); rebuild it (memoized) so an index + // target reflects it even when no render ran in between. + this.getMeasurements() const targetOffset = this.getScrollStateTarget(this.scrollState) // Index targets can still depend on cached sizes during the first rAF, diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index e2511385b..0dc7db3fb 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -2153,6 +2153,73 @@ test('iOS deferral: an absolute scroll command invalidates a pending deferred ad }) }) +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: flushed delta is rolled into scrollAdjustments so back-to-back resizes stay consistent', () => { // Regression: the deferred flush used to write `adjustments: delta` // directly without updating `this.scrollAdjustments`. If a second resize From 84c1502c3a6904b52b82892793fa4c274014fd90 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:40:15 +0200 Subject: [PATCH 03/46] ci: Version Packages (#1303) --- .changeset/ios-index-scroll-deferral.md | 5 - .changeset/lanes-row-alignment.md | 8 -- benchmarks/CHANGELOG.md | 7 + benchmarks/package.json | 2 +- examples/angular/chat/package.json | 2 +- examples/angular/dynamic/package.json | 2 +- examples/angular/fixed/package.json | 2 +- examples/angular/infinite-scroll/package.json | 2 +- examples/angular/padding/package.json | 2 +- examples/angular/smooth-scroll/package.json | 2 +- examples/angular/sticky/package.json | 2 +- examples/angular/table/package.json | 2 +- examples/angular/variable/package.json | 2 +- examples/angular/window/package.json | 2 +- examples/lit/dynamic/package.json | 4 +- examples/lit/fixed/package.json | 4 +- examples/marko/chat-pretext/package.json | 2 +- examples/marko/chat/package.json | 2 +- examples/marko/dynamic/package.json | 2 +- examples/marko/fixed/package.json | 2 +- examples/marko/grid/package.json | 2 +- examples/marko/infinite-scroll/package.json | 2 +- examples/marko/padding/package.json | 2 +- examples/marko/pretext/package.json | 2 +- examples/marko/scroll-padding/package.json | 2 +- examples/marko/smooth-scroll/package.json | 2 +- examples/marko/ssr-fetch/package.json | 2 +- examples/marko/ssr-restore/package.json | 2 +- examples/marko/ssr-slice/package.json | 2 +- examples/marko/ssr/package.json | 2 +- examples/marko/sticky/package.json | 2 +- examples/marko/table/package.json | 2 +- examples/marko/variable/package.json | 2 +- examples/marko/window-ssr-slice/package.json | 2 +- examples/marko/window/package.json | 2 +- examples/react/chat/package.json | 2 +- examples/react/dynamic/package.json | 2 +- examples/react/fixed/package.json | 2 +- examples/react/infinite-scroll/package.json | 2 +- examples/react/padding/package.json | 2 +- examples/react/pretext/package.json | 2 +- examples/react/scroll-padding/package.json | 2 +- examples/react/smooth-scroll/package.json | 2 +- examples/react/sticky/package.json | 2 +- examples/react/table/package.json | 2 +- examples/react/variable/package.json | 2 +- examples/react/window/package.json | 2 +- examples/svelte/dynamic/package.json | 2 +- examples/svelte/fixed/package.json | 2 +- examples/svelte/infinite-scroll/package.json | 2 +- examples/svelte/smooth-scroll/package.json | 2 +- examples/svelte/sticky/package.json | 2 +- examples/svelte/table/package.json | 2 +- examples/vue/dynamic/package.json | 2 +- examples/vue/fixed/package.json | 2 +- examples/vue/infinite-scroll/package.json | 2 +- examples/vue/padding/package.json | 2 +- examples/vue/scroll-padding/package.json | 2 +- examples/vue/smooth-scroll/package.json | 2 +- examples/vue/sticky/package.json | 2 +- examples/vue/table/package.json | 2 +- examples/vue/variable/package.json | 2 +- packages/angular-virtual/CHANGELOG.md | 7 + packages/angular-virtual/package.json | 2 +- packages/lit-virtual/CHANGELOG.md | 7 + packages/lit-virtual/package.json | 2 +- packages/marko-virtual/CHANGELOG.md | 7 + packages/marko-virtual/package.json | 2 +- packages/react-virtual/CHANGELOG.md | 7 + packages/react-virtual/package.json | 2 +- packages/solid-virtual/CHANGELOG.md | 7 + packages/solid-virtual/package.json | 2 +- packages/svelte-virtual/CHANGELOG.md | 7 + packages/svelte-virtual/package.json | 2 +- packages/virtual-core/CHANGELOG.md | 12 ++ packages/virtual-core/package.json | 2 +- packages/vue-virtual/CHANGELOG.md | 7 + packages/vue-virtual/package.json | 2 +- pnpm-lock.yaml | 120 +++++++++--------- 79 files changed, 197 insertions(+), 142 deletions(-) delete mode 100644 .changeset/ios-index-scroll-deferral.md delete mode 100644 .changeset/lanes-row-alignment.md diff --git a/.changeset/ios-index-scroll-deferral.md b/.changeset/ios-index-scroll-deferral.md deleted file mode 100644 index 311c334cc..000000000 --- a/.changeset/ios-index-scroll-deferral.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/virtual-core': patch ---- - -fix(virtual-core): don't replay iOS-deferred size corrections after an in-flight `scrollToIndex` lands diff --git a/.changeset/lanes-row-alignment.md b/.changeset/lanes-row-alignment.md deleted file mode 100644 index d7e81619a..000000000 --- a/.changeset/lanes-row-alignment.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@tanstack/virtual-core': minor ---- - -fix(virtual-core): keep lanes aligned after changing `lanes` with `measureElement` (#1036) - -- `overscan` now counts whole rows when `lanes > 1`, so every lane renders (and measures) the same number of extra items. This renders more items than before: `lanes: 4, overscan: 2` now adds 8 items on each side instead of 2. `Range` gains an optional `lanes` field that `defaultRangeExtractor` reads; a custom `rangeExtractor` gets row overscan only if it delegates to `defaultRangeExtractor`. -- A ResizeObserver callback now measures all of its entries before notifying, so a synchronous re-render can no longer unmount part of a row before it is measured. diff --git a/benchmarks/CHANGELOG.md b/benchmarks/CHANGELOG.md index 2073055fd..3e56a1f9b 100644 --- a/benchmarks/CHANGELOG.md +++ b/benchmarks/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/virtual-benchmarks +## 0.0.18 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/react-virtual@3.14.15 + ## 0.0.17 ### Patch Changes diff --git a/benchmarks/package.json b/benchmarks/package.json index cca47b080..74307612a 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -1,7 +1,7 @@ { "name": "@tanstack/virtual-benchmarks", "private": true, - "version": "0.0.17", + "version": "0.0.18", "type": "module", "scripts": { "dev": "vite", diff --git a/examples/angular/chat/package.json b/examples/angular/chat/package.json index eb468a248..2b6d1f633 100644 --- a/examples/angular/chat/package.json +++ b/examples/angular/chat/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.1", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/dynamic/package.json b/examples/angular/dynamic/package.json index 026b95cdf..7e575ea12 100644 --- a/examples/angular/dynamic/package.json +++ b/examples/angular/dynamic/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", "@faker-js/faker": "^8.4.1", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.1", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/fixed/package.json b/examples/angular/fixed/package.json index 1d39df0ea..85e367611 100644 --- a/examples/angular/fixed/package.json +++ b/examples/angular/fixed/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.1", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/infinite-scroll/package.json b/examples/angular/infinite-scroll/package.json index cc7f623c3..d6575e22a 100644 --- a/examples/angular/infinite-scroll/package.json +++ b/examples/angular/infinite-scroll/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", "@tanstack/angular-query-experimental": "5.80.7", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.1", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/padding/package.json b/examples/angular/padding/package.json index 875fd1489..72d1e3185 100644 --- a/examples/angular/padding/package.json +++ b/examples/angular/padding/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.1", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/smooth-scroll/package.json b/examples/angular/smooth-scroll/package.json index f543416b1..c7920d7f0 100644 --- a/examples/angular/smooth-scroll/package.json +++ b/examples/angular/smooth-scroll/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.1", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/sticky/package.json b/examples/angular/sticky/package.json index 8b542c6be..5d6eb0265 100644 --- a/examples/angular/sticky/package.json +++ b/examples/angular/sticky/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", "@faker-js/faker": "^8.4.1", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.1", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/table/package.json b/examples/angular/table/package.json index 272967781..545ed2f8c 100644 --- a/examples/angular/table/package.json +++ b/examples/angular/table/package.json @@ -19,7 +19,7 @@ "@angular/router": "^20.2.0", "@faker-js/faker": "^8.4.1", "@tanstack/angular-table": "8.21.3", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.1", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/variable/package.json b/examples/angular/variable/package.json index 93a3b0bb2..dae776b89 100644 --- a/examples/angular/variable/package.json +++ b/examples/angular/variable/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.1", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/window/package.json b/examples/angular/window/package.json index ed78a33c9..24b79163c 100644 --- a/examples/angular/window/package.json +++ b/examples/angular/window/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.0", + "@tanstack/angular-virtual": "^6.1.1", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/lit/dynamic/package.json b/examples/lit/dynamic/package.json index 39ec6af1c..b31d25035 100644 --- a/examples/lit/dynamic/package.json +++ b/examples/lit/dynamic/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/lit-virtual": "^3.14.3", - "@tanstack/virtual-core": "^3.18.0", + "@tanstack/lit-virtual": "^3.14.4", + "@tanstack/virtual-core": "^3.19.0", "lit": "^3.3.0" }, "devDependencies": { diff --git a/examples/lit/fixed/package.json b/examples/lit/fixed/package.json index ae7e8df12..f8ccc8598 100644 --- a/examples/lit/fixed/package.json +++ b/examples/lit/fixed/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/lit-virtual": "^3.14.3", - "@tanstack/virtual-core": "^3.18.0", + "@tanstack/lit-virtual": "^3.14.4", + "@tanstack/virtual-core": "^3.19.0", "lit": "^3.3.0" }, "devDependencies": { diff --git a/examples/marko/chat-pretext/package.json b/examples/marko/chat-pretext/package.json index 27135709c..b3003ea0a 100644 --- a/examples/marko/chat-pretext/package.json +++ b/examples/marko/chat-pretext/package.json @@ -11,7 +11,7 @@ "dependencies": { "@chenglou/pretext": "^0.0.7", "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/chat/package.json b/examples/marko/chat/package.json index 227de73b2..195c0826b 100644 --- a/examples/marko/chat/package.json +++ b/examples/marko/chat/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/dynamic/package.json b/examples/marko/dynamic/package.json index e204a4a6d..c64376e57 100644 --- a/examples/marko/dynamic/package.json +++ b/examples/marko/dynamic/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/fixed/package.json b/examples/marko/fixed/package.json index 33b8b8bff..522ab219b 100644 --- a/examples/marko/fixed/package.json +++ b/examples/marko/fixed/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/grid/package.json b/examples/marko/grid/package.json index 9e3fdbd08..5df9eb341 100644 --- a/examples/marko/grid/package.json +++ b/examples/marko/grid/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/infinite-scroll/package.json b/examples/marko/infinite-scroll/package.json index 60d103e07..4d22f7720 100644 --- a/examples/marko/infinite-scroll/package.json +++ b/examples/marko/infinite-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/padding/package.json b/examples/marko/padding/package.json index 63bf00f81..7bd84f75f 100644 --- a/examples/marko/padding/package.json +++ b/examples/marko/padding/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/pretext/package.json b/examples/marko/pretext/package.json index 514bcd3f0..6d5c73f82 100644 --- a/examples/marko/pretext/package.json +++ b/examples/marko/pretext/package.json @@ -11,7 +11,7 @@ "dependencies": { "@chenglou/pretext": "^0.0.7", "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/scroll-padding/package.json b/examples/marko/scroll-padding/package.json index bd4ebbe2f..689787789 100644 --- a/examples/marko/scroll-padding/package.json +++ b/examples/marko/scroll-padding/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/smooth-scroll/package.json b/examples/marko/smooth-scroll/package.json index 5865c7f24..375534edd 100644 --- a/examples/marko/smooth-scroll/package.json +++ b/examples/marko/smooth-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr-fetch/package.json b/examples/marko/ssr-fetch/package.json index 4b41fffe6..c62aeb49e 100644 --- a/examples/marko/ssr-fetch/package.json +++ b/examples/marko/ssr-fetch/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr-restore/package.json b/examples/marko/ssr-restore/package.json index d1a1a1283..563f380a7 100644 --- a/examples/marko/ssr-restore/package.json +++ b/examples/marko/ssr-restore/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr-slice/package.json b/examples/marko/ssr-slice/package.json index 7df0e402b..a9710d49b 100644 --- a/examples/marko/ssr-slice/package.json +++ b/examples/marko/ssr-slice/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr/package.json b/examples/marko/ssr/package.json index 19e4399c7..61379f7a8 100644 --- a/examples/marko/ssr/package.json +++ b/examples/marko/ssr/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/sticky/package.json b/examples/marko/sticky/package.json index 8d729f46c..5e6c07cc0 100644 --- a/examples/marko/sticky/package.json +++ b/examples/marko/sticky/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/table/package.json b/examples/marko/table/package.json index 99658e48e..8f7198af9 100644 --- a/examples/marko/table/package.json +++ b/examples/marko/table/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/variable/package.json b/examples/marko/variable/package.json index d7b9c1e4b..5bd49907e 100644 --- a/examples/marko/variable/package.json +++ b/examples/marko/variable/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/window-ssr-slice/package.json b/examples/marko/window-ssr-slice/package.json index 3ef2690dd..03a4cb0eb 100644 --- a/examples/marko/window-ssr-slice/package.json +++ b/examples/marko/window-ssr-slice/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/window/package.json b/examples/marko/window/package.json index 886bb11cc..946888005 100644 --- a/examples/marko/window/package.json +++ b/examples/marko/window/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.0", + "@tanstack/marko-virtual": "^3.16.1", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/react/chat/package.json b/examples/react/chat/package.json index 063ea0b5d..090cb9ed3 100644 --- a/examples/react/chat/package.json +++ b/examples/react/chat/package.json @@ -8,7 +8,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.14", + "@tanstack/react-virtual": "^3.14.15", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/dynamic/package.json b/examples/react/dynamic/package.json index 1323403e0..0b94c2d2b 100644 --- a/examples/react/dynamic/package.json +++ b/examples/react/dynamic/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/react-virtual": "^3.14.14", + "@tanstack/react-virtual": "^3.14.15", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/fixed/package.json b/examples/react/fixed/package.json index 568aa435f..022851072 100644 --- a/examples/react/fixed/package.json +++ b/examples/react/fixed/package.json @@ -8,7 +8,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.14", + "@tanstack/react-virtual": "^3.14.15", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/infinite-scroll/package.json b/examples/react/infinite-scroll/package.json index 07d09bb46..06b089ba8 100644 --- a/examples/react/infinite-scroll/package.json +++ b/examples/react/infinite-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.80.7", - "@tanstack/react-virtual": "^3.14.14", + "@tanstack/react-virtual": "^3.14.15", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/padding/package.json b/examples/react/padding/package.json index 2eaf5f177..538ab2d35 100644 --- a/examples/react/padding/package.json +++ b/examples/react/padding/package.json @@ -9,7 +9,7 @@ "start": "vite" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.14", + "@tanstack/react-virtual": "^3.14.15", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/pretext/package.json b/examples/react/pretext/package.json index 9f99f5ae1..f4d7cc568 100644 --- a/examples/react/pretext/package.json +++ b/examples/react/pretext/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@chenglou/pretext": "^0.0.7", - "@tanstack/react-virtual": "^3.14.14", + "@tanstack/react-virtual": "^3.14.15", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/scroll-padding/package.json b/examples/react/scroll-padding/package.json index b7fd225e3..0a15c2092 100644 --- a/examples/react/scroll-padding/package.json +++ b/examples/react/scroll-padding/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@react-hookz/web": "^25.1.1", - "@tanstack/react-virtual": "^3.14.14", + "@tanstack/react-virtual": "^3.14.15", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/smooth-scroll/package.json b/examples/react/smooth-scroll/package.json index 4e34be68a..121464f81 100644 --- a/examples/react/smooth-scroll/package.json +++ b/examples/react/smooth-scroll/package.json @@ -9,7 +9,7 @@ "start": "vite" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.14", + "@tanstack/react-virtual": "^3.14.15", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/sticky/package.json b/examples/react/sticky/package.json index 2df832c76..dc0af179f 100644 --- a/examples/react/sticky/package.json +++ b/examples/react/sticky/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/react-virtual": "^3.14.14", + "@tanstack/react-virtual": "^3.14.15", "lodash": "^4.17.21", "react": "^19.2.7", "react-dom": "^19.2.7" diff --git a/examples/react/table/package.json b/examples/react/table/package.json index e2c1f90a8..8cac0cb80 100644 --- a/examples/react/table/package.json +++ b/examples/react/table/package.json @@ -11,7 +11,7 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.14.14", + "@tanstack/react-virtual": "^3.14.15", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/variable/package.json b/examples/react/variable/package.json index 82e9c39ee..d7335d389 100644 --- a/examples/react/variable/package.json +++ b/examples/react/variable/package.json @@ -9,7 +9,7 @@ "start": "vite" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.14", + "@tanstack/react-virtual": "^3.14.15", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/window/package.json b/examples/react/window/package.json index 2f59ae87a..1084122f8 100644 --- a/examples/react/window/package.json +++ b/examples/react/window/package.json @@ -8,7 +8,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.14", + "@tanstack/react-virtual": "^3.14.15", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/svelte/dynamic/package.json b/examples/svelte/dynamic/package.json index 8d85430fd..300e4c2ab 100644 --- a/examples/svelte/dynamic/package.json +++ b/examples/svelte/dynamic/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/svelte-virtual": "^3.13.40" + "@tanstack/svelte-virtual": "^3.13.41" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/fixed/package.json b/examples/svelte/fixed/package.json index 993cc8952..4d9991db5 100644 --- a/examples/svelte/fixed/package.json +++ b/examples/svelte/fixed/package.json @@ -9,7 +9,7 @@ "check": "svelte-check --tsconfig ./tsconfig.json" }, "dependencies": { - "@tanstack/svelte-virtual": "^3.13.40" + "@tanstack/svelte-virtual": "^3.13.41" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/infinite-scroll/package.json b/examples/svelte/infinite-scroll/package.json index daa55601e..77fec32a4 100644 --- a/examples/svelte/infinite-scroll/package.json +++ b/examples/svelte/infinite-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@tanstack/svelte-query": "^5.80.7", - "@tanstack/svelte-virtual": "^3.13.40" + "@tanstack/svelte-virtual": "^3.13.41" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/smooth-scroll/package.json b/examples/svelte/smooth-scroll/package.json index 1aaaf956c..40f6a14d6 100644 --- a/examples/svelte/smooth-scroll/package.json +++ b/examples/svelte/smooth-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/svelte-virtual": "^3.13.40" + "@tanstack/svelte-virtual": "^3.13.41" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/sticky/package.json b/examples/svelte/sticky/package.json index 3c17d0a16..7302b6563 100644 --- a/examples/svelte/sticky/package.json +++ b/examples/svelte/sticky/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/svelte-virtual": "^3.13.40", + "@tanstack/svelte-virtual": "^3.13.41", "lodash": "^4.17.21" }, "devDependencies": { diff --git a/examples/svelte/table/package.json b/examples/svelte/table/package.json index c3a5389d2..5c0e94d0b 100644 --- a/examples/svelte/table/package.json +++ b/examples/svelte/table/package.json @@ -11,7 +11,7 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@tanstack/svelte-table": "^8.21.3", - "@tanstack/svelte-virtual": "^3.13.40" + "@tanstack/svelte-virtual": "^3.13.41" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/vue/dynamic/package.json b/examples/vue/dynamic/package.json index 3355ed90b..01a1a2fca 100644 --- a/examples/vue/dynamic/package.json +++ b/examples/vue/dynamic/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/vue-virtual": "^3.13.40", + "@tanstack/vue-virtual": "^3.13.41", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/fixed/package.json b/examples/vue/fixed/package.json index 53ededb18..1abe750e4 100644 --- a/examples/vue/fixed/package.json +++ b/examples/vue/fixed/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.40", + "@tanstack/vue-virtual": "^3.13.41", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/infinite-scroll/package.json b/examples/vue/infinite-scroll/package.json index 8394a030a..a54929221 100644 --- a/examples/vue/infinite-scroll/package.json +++ b/examples/vue/infinite-scroll/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@tanstack/vue-query": "^5.80.7", - "@tanstack/vue-virtual": "^3.13.40", + "@tanstack/vue-virtual": "^3.13.41", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/padding/package.json b/examples/vue/padding/package.json index edda613b8..46add00e4 100644 --- a/examples/vue/padding/package.json +++ b/examples/vue/padding/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.40", + "@tanstack/vue-virtual": "^3.13.41", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/scroll-padding/package.json b/examples/vue/scroll-padding/package.json index 48f99be0e..ef4fcbb9a 100644 --- a/examples/vue/scroll-padding/package.json +++ b/examples/vue/scroll-padding/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.40", + "@tanstack/vue-virtual": "^3.13.41", "@vueuse/core": "^12.8.2", "vue": "^3.5.16" }, diff --git a/examples/vue/smooth-scroll/package.json b/examples/vue/smooth-scroll/package.json index b06c986a4..64ea4fb62 100644 --- a/examples/vue/smooth-scroll/package.json +++ b/examples/vue/smooth-scroll/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.40", + "@tanstack/vue-virtual": "^3.13.41", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/sticky/package.json b/examples/vue/sticky/package.json index cc37a8399..4c10cc1e5 100644 --- a/examples/vue/sticky/package.json +++ b/examples/vue/sticky/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/vue-virtual": "^3.13.40", + "@tanstack/vue-virtual": "^3.13.41", "lodash": "^4.17.21", "vue": "^3.5.16" }, diff --git a/examples/vue/table/package.json b/examples/vue/table/package.json index ac57d49d8..bcb052bf4 100644 --- a/examples/vue/table/package.json +++ b/examples/vue/table/package.json @@ -10,7 +10,7 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@tanstack/vue-table": "^8.21.3", - "@tanstack/vue-virtual": "^3.13.40", + "@tanstack/vue-virtual": "^3.13.41", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/variable/package.json b/examples/vue/variable/package.json index a6e53e03d..ee163fdf5 100644 --- a/examples/vue/variable/package.json +++ b/examples/vue/variable/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.40", + "@tanstack/vue-virtual": "^3.13.41", "vue": "^3.5.16" }, "devDependencies": { diff --git a/packages/angular-virtual/CHANGELOG.md b/packages/angular-virtual/CHANGELOG.md index c3b52b22a..71660c70c 100644 --- a/packages/angular-virtual/CHANGELOG.md +++ b/packages/angular-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/angular-virtual +## 6.1.1 + +### Patch Changes + +- Updated dependencies [[`28a2174`](https://github.com/TanStack/virtual/commit/28a21741bcc5055bbda9891655630989dc6b7517), [`92c697f`](https://github.com/TanStack/virtual/commit/92c697f7b9c5189137b14f0b0fb41a29eddedbc6)]: + - @tanstack/virtual-core@3.19.0 + ## 6.1.0 ### Minor Changes diff --git a/packages/angular-virtual/package.json b/packages/angular-virtual/package.json index 7097ed2be..e72b82fea 100644 --- a/packages/angular-virtual/package.json +++ b/packages/angular-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/angular-virtual", - "version": "6.1.0", + "version": "6.1.1", "description": "Headless UI for virtualizing scrollable elements in Angular", "author": "Garrett Darnell", "license": "MIT", diff --git a/packages/lit-virtual/CHANGELOG.md b/packages/lit-virtual/CHANGELOG.md index 444ed6773..8057d1ad5 100644 --- a/packages/lit-virtual/CHANGELOG.md +++ b/packages/lit-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/lit-virtual +## 3.14.4 + +### Patch Changes + +- Updated dependencies [[`28a2174`](https://github.com/TanStack/virtual/commit/28a21741bcc5055bbda9891655630989dc6b7517), [`92c697f`](https://github.com/TanStack/virtual/commit/92c697f7b9c5189137b14f0b0fb41a29eddedbc6)]: + - @tanstack/virtual-core@3.19.0 + ## 3.14.3 ### Patch Changes diff --git a/packages/lit-virtual/package.json b/packages/lit-virtual/package.json index 6c95b658f..69afe2619 100644 --- a/packages/lit-virtual/package.json +++ b/packages/lit-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/lit-virtual", - "version": "3.14.3", + "version": "3.14.4", "description": "Headless UI for virtualizing scrollable elements in Lit", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/marko-virtual/CHANGELOG.md b/packages/marko-virtual/CHANGELOG.md index 876fb913a..0689e1637 100644 --- a/packages/marko-virtual/CHANGELOG.md +++ b/packages/marko-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/marko-virtual +## 3.16.1 + +### Patch Changes + +- Updated dependencies [[`28a2174`](https://github.com/TanStack/virtual/commit/28a21741bcc5055bbda9891655630989dc6b7517), [`92c697f`](https://github.com/TanStack/virtual/commit/92c697f7b9c5189137b14f0b0fb41a29eddedbc6)]: + - @tanstack/virtual-core@3.19.0 + ## 3.16.0 ### Minor Changes diff --git a/packages/marko-virtual/package.json b/packages/marko-virtual/package.json index 68ed27c97..8a29c079e 100644 --- a/packages/marko-virtual/package.json +++ b/packages/marko-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/marko-virtual", - "version": "3.16.0", + "version": "3.16.1", "description": "Headless UI for virtualizing scrollable elements in Marko 6", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/react-virtual/CHANGELOG.md b/packages/react-virtual/CHANGELOG.md index c8e6b21fb..96d03e021 100644 --- a/packages/react-virtual/CHANGELOG.md +++ b/packages/react-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/react-virtual +## 3.14.15 + +### Patch Changes + +- Updated dependencies [[`28a2174`](https://github.com/TanStack/virtual/commit/28a21741bcc5055bbda9891655630989dc6b7517), [`92c697f`](https://github.com/TanStack/virtual/commit/92c697f7b9c5189137b14f0b0fb41a29eddedbc6)]: + - @tanstack/virtual-core@3.19.0 + ## 3.14.14 ### Patch Changes diff --git a/packages/react-virtual/package.json b/packages/react-virtual/package.json index 2fa093ac0..3b5f843e7 100644 --- a/packages/react-virtual/package.json +++ b/packages/react-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/react-virtual", - "version": "3.14.14", + "version": "3.14.15", "description": "Headless UI for virtualizing scrollable elements in React", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/solid-virtual/CHANGELOG.md b/packages/solid-virtual/CHANGELOG.md index 4a3aa795f..9e8ef99a2 100644 --- a/packages/solid-virtual/CHANGELOG.md +++ b/packages/solid-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/solid-virtual +## 3.13.42 + +### Patch Changes + +- Updated dependencies [[`28a2174`](https://github.com/TanStack/virtual/commit/28a21741bcc5055bbda9891655630989dc6b7517), [`92c697f`](https://github.com/TanStack/virtual/commit/92c697f7b9c5189137b14f0b0fb41a29eddedbc6)]: + - @tanstack/virtual-core@3.19.0 + ## 3.13.41 ### Patch Changes diff --git a/packages/solid-virtual/package.json b/packages/solid-virtual/package.json index 651b2e701..4295141ea 100644 --- a/packages/solid-virtual/package.json +++ b/packages/solid-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/solid-virtual", - "version": "3.13.41", + "version": "3.13.42", "description": "Headless UI for virtualizing scrollable elements in Solid", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/svelte-virtual/CHANGELOG.md b/packages/svelte-virtual/CHANGELOG.md index 492904698..88645fbec 100644 --- a/packages/svelte-virtual/CHANGELOG.md +++ b/packages/svelte-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/svelte-virtual +## 3.13.41 + +### Patch Changes + +- Updated dependencies [[`28a2174`](https://github.com/TanStack/virtual/commit/28a21741bcc5055bbda9891655630989dc6b7517), [`92c697f`](https://github.com/TanStack/virtual/commit/92c697f7b9c5189137b14f0b0fb41a29eddedbc6)]: + - @tanstack/virtual-core@3.19.0 + ## 3.13.40 ### Patch Changes diff --git a/packages/svelte-virtual/package.json b/packages/svelte-virtual/package.json index 12f8feabe..8c7ce4c21 100644 --- a/packages/svelte-virtual/package.json +++ b/packages/svelte-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/svelte-virtual", - "version": "3.13.40", + "version": "3.13.41", "description": "Headless UI for virtualizing scrollable elements in Svelte", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/virtual-core/CHANGELOG.md b/packages/virtual-core/CHANGELOG.md index 8974432e5..22baed2f6 100644 --- a/packages/virtual-core/CHANGELOG.md +++ b/packages/virtual-core/CHANGELOG.md @@ -1,5 +1,17 @@ # @tanstack/virtual-core +## 3.19.0 + +### Minor Changes + +- [#1302](https://github.com/TanStack/virtual/pull/1302) [`92c697f`](https://github.com/TanStack/virtual/commit/92c697f7b9c5189137b14f0b0fb41a29eddedbc6) - fix(virtual-core): keep lanes aligned after changing `lanes` with `measureElement` ([#1036](https://github.com/TanStack/virtual/issues/1036)) + - `overscan` now counts whole rows when `lanes > 1`, so every lane renders (and measures) the same number of extra items. This renders more items than before: `lanes: 4, overscan: 2` now adds 8 items on each side instead of 2. `Range` gains an optional `lanes` field that `defaultRangeExtractor` reads; a custom `rangeExtractor` gets row overscan only if it delegates to `defaultRangeExtractor`. + - A ResizeObserver callback now measures all of its entries before notifying, so a synchronous re-render can no longer unmount part of a row before it is measured. + +### Patch Changes + +- [#1305](https://github.com/TanStack/virtual/pull/1305) [`28a2174`](https://github.com/TanStack/virtual/commit/28a21741bcc5055bbda9891655630989dc6b7517) - fix(virtual-core): don't replay iOS-deferred size corrections after an in-flight `scrollToIndex` lands + ## 3.18.0 ### Minor Changes diff --git a/packages/virtual-core/package.json b/packages/virtual-core/package.json index e8c5ca581..d125b91d6 100644 --- a/packages/virtual-core/package.json +++ b/packages/virtual-core/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/virtual-core", - "version": "3.18.0", + "version": "3.19.0", "description": "Headless UI for virtualizing scrollable elements in TS/JS + Frameworks", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/vue-virtual/CHANGELOG.md b/packages/vue-virtual/CHANGELOG.md index d2e35c5bd..3a537ad24 100644 --- a/packages/vue-virtual/CHANGELOG.md +++ b/packages/vue-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/vue-virtual +## 3.13.41 + +### Patch Changes + +- Updated dependencies [[`28a2174`](https://github.com/TanStack/virtual/commit/28a21741bcc5055bbda9891655630989dc6b7517), [`92c697f`](https://github.com/TanStack/virtual/commit/92c697f7b9c5189137b14f0b0fb41a29eddedbc6)]: + - @tanstack/virtual-core@3.19.0 + ## 3.13.40 ### Patch Changes diff --git a/packages/vue-virtual/package.json b/packages/vue-virtual/package.json index 4fea6d02d..dc015349a 100644 --- a/packages/vue-virtual/package.json +++ b/packages/vue-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/vue-virtual", - "version": "3.13.40", + "version": "3.13.41", "description": "Headless UI for virtualizing scrollable elements in Vue", "author": "Tanner Linsley", "license": "MIT", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0af0bc17..a3ecf3c82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,7 +145,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.0 + specifier: ^6.1.1 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -200,7 +200,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/angular-virtual': - specifier: ^6.1.0 + specifier: ^6.1.1 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -252,7 +252,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.0 + specifier: ^6.1.1 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -307,7 +307,7 @@ importers: specifier: 5.80.7 version: 5.80.7(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)) '@tanstack/angular-virtual': - specifier: ^6.1.0 + specifier: ^6.1.1 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -359,7 +359,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.0 + specifier: ^6.1.1 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -411,7 +411,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.0 + specifier: ^6.1.1 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -466,7 +466,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/angular-virtual': - specifier: ^6.1.0 + specifier: ^6.1.1 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -524,7 +524,7 @@ importers: specifier: 8.21.3 version: 8.21.3(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)) '@tanstack/angular-virtual': - specifier: ^6.1.0 + specifier: ^6.1.1 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -576,7 +576,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.0 + specifier: ^6.1.1 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -628,7 +628,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.0 + specifier: ^6.1.1 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -659,10 +659,10 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/lit-virtual': - specifier: ^3.14.3 + specifier: ^3.14.4 version: link:../../../packages/lit-virtual '@tanstack/virtual-core': - specifier: ^3.18.0 + specifier: ^3.19.0 version: link:../../../packages/virtual-core lit: specifier: ^3.3.0 @@ -684,10 +684,10 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/lit-virtual': - specifier: ^3.14.3 + specifier: ^3.14.4 version: link:../../../packages/lit-virtual '@tanstack/virtual-core': - specifier: ^3.18.0 + specifier: ^3.19.0 version: link:../../../packages/virtual-core lit: specifier: ^3.3.0 @@ -709,7 +709,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -737,7 +737,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -762,7 +762,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -787,7 +787,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -812,7 +812,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -837,7 +837,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -862,7 +862,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -890,7 +890,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -915,7 +915,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -940,7 +940,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -965,7 +965,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -990,7 +990,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1015,7 +1015,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1040,7 +1040,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1065,7 +1065,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1090,7 +1090,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1115,7 +1115,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1140,7 +1140,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1165,7 +1165,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.0 + specifier: ^3.16.1 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1187,7 +1187,7 @@ importers: examples/react/chat: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.14 + specifier: ^3.14.15 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1218,7 +1218,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/react-virtual': - specifier: ^3.14.14 + specifier: ^3.14.15 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1249,7 +1249,7 @@ importers: examples/react/fixed: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.14 + specifier: ^3.14.15 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1283,7 +1283,7 @@ importers: specifier: ^5.80.7 version: 5.90.5(react@19.2.7) '@tanstack/react-virtual': - specifier: ^3.14.14 + specifier: ^3.14.15 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1308,7 +1308,7 @@ importers: examples/react/padding: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.14 + specifier: ^3.14.15 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1336,7 +1336,7 @@ importers: specifier: ^0.0.7 version: 0.0.7 '@tanstack/react-virtual': - specifier: ^3.14.14 + specifier: ^3.14.15 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1370,7 +1370,7 @@ importers: specifier: ^25.1.1 version: 25.1.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-virtual': - specifier: ^3.14.14 + specifier: ^3.14.15 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1395,7 +1395,7 @@ importers: examples/react/smooth-scroll: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.14 + specifier: ^3.14.15 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1423,7 +1423,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/react-virtual': - specifier: ^3.14.14 + specifier: ^3.14.15 version: link:../../../packages/react-virtual lodash: specifier: ^4.17.21 @@ -1460,7 +1460,7 @@ importers: specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-virtual': - specifier: ^3.14.14 + specifier: ^3.14.15 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1485,7 +1485,7 @@ importers: examples/react/variable: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.14 + specifier: ^3.14.15 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1510,7 +1510,7 @@ importers: examples/react/window: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.14 + specifier: ^3.14.15 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1544,7 +1544,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/svelte-virtual': - specifier: ^3.13.40 + specifier: ^3.13.41 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1572,7 +1572,7 @@ importers: examples/svelte/fixed: dependencies: '@tanstack/svelte-virtual': - specifier: ^3.13.40 + specifier: ^3.13.41 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1603,7 +1603,7 @@ importers: specifier: ^5.80.7 version: 5.90.2(svelte@4.2.20) '@tanstack/svelte-virtual': - specifier: ^3.13.40 + specifier: ^3.13.41 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1634,7 +1634,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/svelte-virtual': - specifier: ^3.13.40 + specifier: ^3.13.41 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1665,7 +1665,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/svelte-virtual': - specifier: ^3.13.40 + specifier: ^3.13.41 version: link:../../../packages/svelte-virtual lodash: specifier: ^4.17.21 @@ -1702,7 +1702,7 @@ importers: specifier: ^8.21.3 version: 8.21.3(svelte@4.2.20) '@tanstack/svelte-virtual': - specifier: ^3.13.40 + specifier: ^3.13.41 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1733,7 +1733,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/vue-virtual': - specifier: ^3.13.40 + specifier: ^3.13.41 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1758,7 +1758,7 @@ importers: examples/vue/fixed: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.40 + specifier: ^3.13.41 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1786,7 +1786,7 @@ importers: specifier: ^5.80.7 version: 5.90.5(vue@3.5.22(typescript@5.9.3)) '@tanstack/vue-virtual': - specifier: ^3.13.40 + specifier: ^3.13.41 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1811,7 +1811,7 @@ importers: examples/vue/padding: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.40 + specifier: ^3.13.41 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1836,7 +1836,7 @@ importers: examples/vue/scroll-padding: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.40 + specifier: ^3.13.41 version: link:../../../packages/vue-virtual '@vueuse/core': specifier: ^12.8.2 @@ -1864,7 +1864,7 @@ importers: examples/vue/smooth-scroll: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.40 + specifier: ^3.13.41 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1892,7 +1892,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/vue-virtual': - specifier: ^3.13.40 + specifier: ^3.13.41 version: link:../../../packages/vue-virtual lodash: specifier: ^4.17.21 @@ -1929,7 +1929,7 @@ importers: specifier: ^8.21.3 version: 8.21.3(vue@3.5.22(typescript@5.9.3)) '@tanstack/vue-virtual': - specifier: ^3.13.40 + specifier: ^3.13.41 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1954,7 +1954,7 @@ importers: examples/vue/variable: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.40 + specifier: ^3.13.41 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 From 6732e00f9baa0a155321e788503bc2f487ea3b00 Mon Sep 17 00:00:00 2001 From: phcorp Date: Sun, 11 Oct 2026 16:48:24 +0200 Subject: [PATCH 04/46] test(virtual-core): pin the iOS deferral and index-scroll orderings measured for #1307 (#1308) --- .../e2e/app/ios-deferral/index.html | 11 + .../e2e/app/ios-deferral/main.tsx | 343 ++++++++++++++++++ packages/react-virtual/e2e/app/vite.config.ts | 1 + packages/virtual-core/tests/index.test.ts | 272 ++++++++++++++ 4 files changed, 627 insertions(+) create mode 100644 packages/react-virtual/e2e/app/ios-deferral/index.html create mode 100644 packages/react-virtual/e2e/app/ios-deferral/main.tsx diff --git a/packages/react-virtual/e2e/app/ios-deferral/index.html b/packages/react-virtual/e2e/app/ios-deferral/index.html new file mode 100644 index 000000000..d47577ab6 --- /dev/null +++ b/packages/react-virtual/e2e/app/ios-deferral/index.html @@ -0,0 +1,11 @@ + + + + + + + +
+ + + diff --git a/packages/react-virtual/e2e/app/ios-deferral/main.tsx b/packages/react-virtual/e2e/app/ios-deferral/main.tsx new file mode 100644 index 000000000..3fe72768b --- /dev/null +++ b/packages/react-virtual/e2e/app/ios-deferral/main.tsx @@ -0,0 +1,343 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { flushSync } from 'react-dom' +import { elementScroll, useVirtualizer } from '@tanstack/react-virtual' + +// Instrumented page for measuring the iOS scroll-correction deferral on a real +// device or simulator (#1307). It records a timeline of scroll events, touch +// events, scrollTop writes and resizes; a driver (or a finger) provides the +// gestures. Nothing here asserts anything. + +const params = new URLSearchParams(window.location.search) +const num = (name: string, fallback: number) => + Number(params.get(name) ?? fallback) + +const COUNT = num('count', 400) +const EST = num('est', 50) +const VIEWPORT = num('h', 400) +const OVERSCAN = num('overscan', 5) +const INITIAL_OFFSET = num('initialOffset', 0) +// `tall=a-b:px` renders rows a..b at `px` instead of the estimate, so their +// first measurement differs from it. +const tall = (params.get('tall') ?? '').match(/^(\d+)-(\d+):(\d+)$/) + +const name = params.get('name') ?? params.get('mode') ?? 'run' + +const heights = new Map() +const heightOf = (index: number) => + heights.get(index) ?? + (tall && index >= Number(tall[1]) && index <= Number(tall[2]) + ? Number(tall[3]) + : EST) + +type Entry = Record + +const t: any = { + log: [] as Array, + el: null as HTMLElement | null, + v: null as any, + watch: [] as Array, + hooks: [] as Array<{ left: number; when: string; fn: () => void }>, + touching: false, + touchEnded: false, +} +;(window as any).__t = t + +const state = () => { + const v = t.v + const el = t.el as HTMLElement | null + const entry: Entry = { top: el ? el.scrollTop : null } + if (v) { + entry.off = v.scrollOffset + entry.scrolling = v.isScrolling + entry.dir = v.scrollDirection + entry.def = v._iosDeferredAdjustment + entry.adj = v.scrollAdjustments + const s = v.scrollState + entry.st = s + ? [s.index, s.lastTargetOffset, s.hasReachedTarget, s.stableFrames] + : null + if (t.watch.length > 0 && el) { + const m = v.getMeasurements() + entry.rows = t.watch.map((i: number) => m[i].start - el.scrollTop) + } + } + return entry +} + +const log = (type: string, data: Entry = {}) => { + t.log.push({ + t: Math.round(performance.now() * 10) / 10, + type, + ...data, + ...state(), + }) +} +t.mark = log +t.clear = () => { + t.log.length = 0 +} +// Viewport-relative top of a mounted row, read from the DOM. +t.rowTop = (index: number) => { + const row = t.el?.querySelector(`[data-index="${index}"]`) + if (!row) return null + return row.getBoundingClientRect().top - t.el.getBoundingClientRect().top +} +t.snap = (label: string, rows: Array = t.watch) => + log('snap', { label, dom: rows.map((i) => t.rowTop(i)) }) +// Run `fn` on the n-th scroll event from now, or on the n-th one after the +// next touchend (i.e. during momentum). +t.onScroll = (n: number, fn: () => void) => + t.hooks.push({ left: n, when: 'any', fn }) +t.onMomentumScroll = (n: number, fn: () => void) => + t.hooks.push({ left: n, when: 'momentum', fn }) +// Same, counting only scroll events while a finger is down. +t.onTouchScroll = (n: number, fn: () => void) => + t.hooks.push({ left: n, when: 'touching', fn }) +t.frames = (n: number) => + new Promise((resolve) => { + const step = () => (n-- > 0 ? requestAnimationFrame(step) : resolve()) + step() + }) +t.wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) +t.send = (label: string) => + fetch(`/log?name=${encodeURIComponent(label)}`, { + method: 'POST', + body: JSON.stringify({ + name: label, + ua: navigator.userAgent, + url: location.href, + log: t.log, + }), + }).then( + (response) => (response.ok ? 'sent' : `failed: HTTP ${response.status}`), + (e) => `failed: ${e}`, + ) + +// `autosend=ms` posts the log once the scroller has been quiet for `ms` +// after a touch, so a run needs no WebDriver session (which blocks touches). +const AUTOSEND = num('autosend', 0) +let quietTimer = 0 +const quiet = () => { + if (!AUTOSEND || !t.touchEnded) return + clearTimeout(quietTimer) + quietTimer = window.setTimeout(() => { + t.snap('quiet') + t.send(name).then((status: string) => { + document.title = status + const out = document.getElementById('status') + if (out) out.textContent = `${status} (${t.log.length} entries)` + }) + t.touchEnded = false + }, AUTOSEND) +} + +const attach = (el: HTMLElement) => { + t.el = el + el.addEventListener( + 'scroll', + () => { + log('scroll') + for (const hook of t.hooks.slice()) { + if (hook.when === 'momentum' && (!t.touchEnded || t.touching)) continue + if (hook.when === 'touching' && !t.touching) continue + if (--hook.left > 0) continue + t.hooks.splice(t.hooks.indexOf(hook), 1) + hook.fn() + } + quiet() + }, + { passive: true }, + ) + el.addEventListener( + 'touchstart', + () => { + t.touching = true + log('touchstart') + }, + { passive: true }, + ) + const end = (type: string) => () => { + t.touching = false + t.touchEnded = true + log(type) + quiet() + } + el.addEventListener('touchend', end('touchend'), { passive: true }) + el.addEventListener('touchcancel', end('touchcancel'), { passive: true }) +} + +// Presets for runs driven by a finger instead of a WebDriver session, each +// fired on the `n`-th momentum scroll event: +// `arm=write` writes scrollTop (+`d`); `arm=resize` changes row `row` +// (default: a mounted one above the viewport) to `px`; `arm=index` calls +// scrollToIndex(`to`). `arm=smooth` starts a smooth scrollToIndex(`to`) on +// load, to be interrupted by a drag. +const arm = () => { + const n = num('n', 5) + const preset = params.get('arm') + t.watch = params.get('watch')?.split(',').map(Number) ?? [] + if (preset === 'write') { + t.onMomentumScroll(n, () => { + t.el.scrollTop += num('d', 0) + log('rawWrite', { d: num('d', 0) }) + }) + } else if (preset === 'resize') { + t.onMomentumScroll(n, () => { + // A mounted row above the viewport that stays mounted for a few frames. + const above = t.v.getVirtualItems()[0].index + num('skip', 2) + t.setH(num('row', above), num('px', EST * 2), true) + }) + } else if (preset === 'index') { + // `any=1` counts every scroll event (wheel), `touching=1` only those + // under a finger, instead of momentum ones. + const on = params.has('any') + ? t.onScroll + : params.has('touching') + ? t.onTouchScroll + : t.onMomentumScroll + on(n, () => { + log('scrollToIndex', { index: num('to', 0) }) + t.v.scrollToIndex(num('to', 0), { align: 'start' }) + // `late=ms` then grows the first mounted row, which moves the target. + if (params.has('late')) { + window.setTimeout( + () => t.setH(t.v.getVirtualItems()[0].index, EST * 3), + num('late', 0), + ) + } + }) + } else if (preset === 'smooth') { + window.setTimeout( + () => { + log('scrollToIndex', { index: num('to', 0) }) + t.v.scrollToIndex(num('to', 0), { align: 'start', behavior: 'smooth' }) + }, + num('delay', 1000), + ) + } +} + +const Controls = ({ label }: { label: string }) => { + const [status, setStatus] = React.useState('') + return ( +
+ {' '} + {' '} + {label} {status} +
+ ) +} + +// No virtualizer: checks what a scrollTop write does to iOS momentum. +const Plain = () => { + const ref = React.useRef(null) + React.useEffect(() => { + attach(ref.current!) + arm() + }, []) + return ( +
+ {Array.from({ length: COUNT }, (_, i) => ( +
+ Row {i} +
+ ))} +
+ ) +} + +const App = () => { + const parentRef = React.useRef(null) + const [, bump] = React.useReducer((x: number) => x + 1, 0) + + const rowVirtualizer = useVirtualizer({ + count: COUNT, + getScrollElement: () => parentRef.current, + estimateSize: () => EST, + overscan: OVERSCAN, + initialOffset: INITIAL_OFFSET, + scrollToFn: (offset, options, instance) => { + log('write', { + to: offset + (options.adjustments ?? 0), + behavior: options.behavior, + }) + elementScroll(offset, options, instance) + }, + }) + t.v = rowVirtualizer + + React.useEffect(() => { + attach(parentRef.current!) + const resizeItem = rowVirtualizer.resizeItem + rowVirtualizer.resizeItem = (index, size) => { + const before = t.v.getMeasurements()[index]?.size + resizeItem(index, size) + if (before !== size) log('resizeItem', { index, size, before }) + } + // Changes a mounted row's height; the ResizeObserver reports it. + t.setH = (index: number, px: number, sync = false) => { + heights.set(index, px) + log('setH', { index, px, sync }) + if (sync) flushSync(bump) + else bump() + } + arm() + }, []) + + return ( +
+
+ {rowVirtualizer.getVirtualItems().map((v) => ( +
+
+ Row {v.index} +
+
+ ))} +
+
+ ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render( +
+ + {params.get('mode') === 'plain' ? : } +
, +) diff --git a/packages/react-virtual/e2e/app/vite.config.ts b/packages/react-virtual/e2e/app/vite.config.ts index a7910bcf5..9fab88e93 100644 --- a/packages/react-virtual/e2e/app/vite.config.ts +++ b/packages/react-virtual/e2e/app/vite.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ 'direct-dom-updates/index.html', ), 'lanes-change': path.resolve(__dirname, 'lanes-change/index.html'), + 'ios-deferral': path.resolve(__dirname, 'ios-deferral/index.html'), 'cached-measurements': path.resolve( __dirname, 'cached-measurements/index.html', diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 0dc7db3fb..46012e3c8 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -2371,6 +2371,278 @@ test('iOS deferral: a positive delta at the end clamp still replays (growth abov }) }) +// ─── #1307: orderings the summed deferral gets wrong, and ones to keep ────── +// +// `test.fails` cases document current behaviour that #1307 is meant to fix: +// each one asserts the wanted outcome and fails today. 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, + start = 0, +}: { ios?: boolean; start?: number } = {}) { + const rafCallbacks: Array = [] + let now = 0 + const mockWindow = { + requestAnimationFrame: (cb: FrameRequestCallback) => rafCallbacks.push(cb), + cancelAnimationFrame: () => { + rafCallbacks.length = 0 + }, + performance: { now: () => now }, + } + const el = { + 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) : fn()) + const v = run(() => { + const instance = new Virtualizer({ + count: 100, + estimateSize: () => 50, + initialOffset: start, + 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(start, false) + return () => {} + }, + }) + instance._willUpdate() + instance['getMeasurements']() + return instance + }) + return { + v, + el, + run, + // 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 + }, + // 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.fails( + 'iOS deferral (#1307): a gesture before the index scroll retargets keeps the resize correction', + () => { + const h = landedIndexScrollWithResizeAbove() + h.run(() => { + h.scroll(true, 1010) + h.frame() + h.scroll(false) + // 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.fails( + '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.fails( + '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.fails( + '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) + }) + }, +) + +test.fails( + 'iOS deferral (#1307): a correction is not replayed once the viewport is above the resized row', + () => { + const h = makeDeferralHarness({ start: 2000 }) + h.run(() => { + h.scroll(true, 2010) + h.v.resizeItem(30, 100) + expect(h.v['_iosDeferredAdjustment']).toBe(50) + // The gesture carries the viewport above row 30, then settles. + h.scroll(true, 1000) + h.scroll(false) + expect(h.rowTop(20)).toBe(0) + }) + }, +) + +test.fails( + '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. + 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.fails( + '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.fails( + '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.run(() => { + h.scroll(true, 1010) + h.v.resizeItem(5, 100) + h.scroll(true, 3000) + h.scroll(false) + // 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.scroll(true, 1990) + h.v.resizeItem(5, 100) + expect(h.v['_iosDeferredAdjustment']).toBe(0) + h.scroll(true, 2020) + h.v.resizeItem(5, 120) + expect(h.v['_iosDeferredAdjustment']).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.scroll(true, 2010) + h.v.resizeItem(5, 100) + expect(h.v['_iosDeferredAdjustment']).toBe(0) + }) +}) + +test('iOS deferral: the first measure of the row spanning the viewport top is compensated', () => { + const h = makeDeferralHarness({ start: 2000 }) + 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['_iosDeferredAdjustment']).toBe(50) + h.scroll(false) + expect(h.rowTop(41)).toBe(40) + }) +}) + // ─── Phase 1: touch event distinction ──────────────────────────────────────── // Mock EventTarget that records listeners so tests can dispatch events From e7a0a92e4bec5ecd0a37659e951c8e8ff72e0207 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Mon, 12 Oct 2026 12:14:47 +0200 Subject: [PATCH 05/46] fix(virtual-core): leave resizes measured before a scroll command's read-back to reconcile (#1309) --- .changeset/last-written-scroll-offset.md | 5 + packages/virtual-core/src/index.ts | 53 +- packages/virtual-core/tests/index.test.ts | 252 ++++++++- .../tests/ios-differential.test.ts | 510 ++++++++++++++++++ 4 files changed, 799 insertions(+), 21 deletions(-) create mode 100644 .changeset/last-written-scroll-offset.md create mode 100644 packages/virtual-core/tests/ios-differential.test.ts diff --git a/.changeset/last-written-scroll-offset.md b/.changeset/last-written-scroll-offset.md new file mode 100644 index 000000000..535bcee45 --- /dev/null +++ b/.changeset/last-written-scroll-offset.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): leave resizes measured before a scroll command's write is read back to reconcile, so an upward `scrollToIndex` no longer jumps back and stalls until the safety valve, and build `scrollBy` on a just-written offset diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 38989f0f0..e42eff8f2 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -406,6 +406,14 @@ type ScrollState = { stableFrames: number // Keep arrival across measurement-driven retargets so external scrolls can cancel. hasReachedTarget: boolean + + // Where our last non-smooth write for this command put the element, until + // its scroll event arrives. Until then `scrollOffset` still holds the old + // position, and the rows on screen are the ones rendered for it. A smooth + // write leaves it null: the element is somewhere between the two offsets. + // It goes with the scroll state, so a write the browser ignored (no read-back) + // does not outlive its command. + writtenOffset?: number | null } type PendingScrollAnchor = [ @@ -973,6 +981,7 @@ export class Virtualizer< this.scrollState = null } this._intendedScrollOffset = null + if (this.scrollState) this.scrollState.writtenOffset = null // A pending clamped compensation write (#1258) survives only its // own read-back, which the browser reports at the scroll max we @@ -1835,13 +1844,33 @@ export class Virtualizer< this.itemSizeCache.set(key, size) this.itemSizeCacheVersion++ - let adjustedSync = false - if (wasAtEnd) { - adjustedSync = this.applyScrollAdjustment( - this.getTotalSize() - prevTotalSize, + // A scroll command (or reconcile) wrote a new position that neither a + // scroll event nor an eager update has reflected in `scrollOffset` yet. + // The rows being measured were rendered for the old position, so + // "above the viewport" means nothing against either offset. An index + // or end target is retargeted from fresh measurements by + // reconcileScroll next frame; an offset target (scrollToOffset, + // scrollBy) lands where it was written and the delta is not applied, + // since compensating against the stale offset would overwrite the + // command. Compensation writes and the prepend anchor sync update + // `scrollOffset` eagerly, and the iOS flush writes exactly + // `scrollOffset + scrollAdjustments`, so none of them count. + const awaitingCommandReadBack = + this.scrollState?.writtenOffset != null && + !approxEqual( + this.scrollState.writtenOffset, + this.getScrollOffset() + this.scrollAdjustments, ) - } else if (shouldAdjustScroll) { - adjustedSync = this.applyScrollAdjustment(delta) + + let adjustedSync = false + if (!awaitingCommandReadBack) { + if (wasAtEnd) { + adjustedSync = this.applyScrollAdjustment( + this.getTotalSize() - prevTotalSize, + ) + } else if (shouldAdjustScroll) { + adjustedSync = this.applyScrollAdjustment(delta) + } } // When we just moved `scrollTop` to compensate for an above-viewport @@ -2080,7 +2109,13 @@ export class Virtualizer< delta: number, { behavior = 'auto' }: ScrollToOffsetOptions = {}, ) => { - const offset = this.getScrollOffset() + delta + // Build on a position we just wrote even before its scroll event arrives. + // Clamp: a write past either edge that leaves the element where it was + // fires no scroll event, so nothing would clear an unreachable offset. + const offset = this.getOffsetForAlignment( + (this.scrollState?.writtenOffset ?? this.getScrollOffset()) + delta, + 'start', + ) const now = this.now() this.scrollState = { @@ -2227,6 +2262,10 @@ export class Virtualizer< // Record the intended logical scroll target so the next scroll event // can reconcile against subpixel rounding by the browser. this._intendedScrollOffset = offset + (adjustments ?? 0) + if (this.scrollState) { + this.scrollState.writtenOffset = + behavior === 'smooth' ? null : this._intendedScrollOffset + } this.options.scrollToFn(offset, { behavior, adjustments }, this) } diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 46012e3c8..7fdcad521 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -8,6 +8,7 @@ import { observeWindowOffset, windowScroll, } from '../src/index' +import type { VirtualizerOptions } from '../src/index' test('should export the Virtualizer class', () => { expect(Virtualizer).toBeDefined() @@ -2537,20 +2538,18 @@ test.fails( }, ) -test.fails( - '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. - 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 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.fails( 'scrollToIndex (#1307): a drag that never reached the target is not pulled back when the target moves', @@ -5425,3 +5424,228 @@ test('ResizeObserver batch: a sync re-render does not drop queued entries (#1036 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 settle = () => { + for (let i = 0; i < 10 && rafCallbacks.length > 0; i++) { + rafCallbacks.splice(0).forEach((cb) => cb(0)) + 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, 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) +}) + +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) +}) diff --git a/packages/virtual-core/tests/ios-differential.test.ts b/packages/virtual-core/tests/ios-differential.test.ts new file mode 100644 index 000000000..b985c6d44 --- /dev/null +++ b/packages/virtual-core/tests/ios-differential.test.ts @@ -0,0 +1,510 @@ +import { describe, expect, test } from 'vitest' +import { Virtualizer, _resetIOSDetectionForTests } from '../src/index' +import type { ScrollAlignment, VirtualizerOptions } from '../src/index' + +// Differential check for the iOS scroll deferral (#1307). +// +// On iOS WebKit a scrollTop write cancels momentum, so size corrections made +// while the scroller is live are deferred and written once it settles. On +// every other platform the same correction is written immediately. Deferral +// should change only *when* a correction lands, never *how much*: after the +// same sequence of events, both platforms must settle at the same scrollTop. +// +// Each case runs on two fresh virtualizers, one with an iOS user agent, and +// compares the settled scrollTop. + +type Step = + | { t: 'at'; top: number } // the element starts here, at rest + | { t: 'index'; i: number; align?: ScrollAlignment } + | { t: 'end' } + | { t: 'by'; d: number } + | { t: 'cancel' } + | { t: 'resize'; i: number; size: number } + | { t: 'prepend'; n: number } + | { t: 'event' } // read-back of the current position, still scrolling + | { t: 'idle' } // a scroll event that reports the scroller at rest + | { t: 'drag'; d: number } // native scroll by the user + | { t: 'touchstart' } + | { t: 'touchend' } + | { t: 'frame' } + | { t: 'valve' } // jump past the reconcile safety valve, then a frame + +type Options = Partial> + +const IOS_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)' +const VIEWPORT = 200 + +function run(ios: boolean, c: Case) { + if (ios) { + Object.defineProperty(navigator, 'userAgent', { + value: IOS_UA, + configurable: true, + }) + } + _resetIOSDetectionForTests() + try { + return runSteps(c.steps, c.options ?? {}, c.setup) + } finally { + delete (navigator as any).userAgent + _resetIOSDetectionForTests() + } +} + +function runSteps( + steps: Array, + options: Options, + setup?: (v: Virtualizer) => void, +) { + const rafs: Array = [] + const timers = new Map void>() + let timerId = 0 + let clock = 0 + const listeners: Record void>> = {} + const win = { + requestAnimationFrame: (cb: FrameRequestCallback) => rafs.push(cb), + cancelAnimationFrame: () => {}, + setTimeout: (fn: () => void) => { + timers.set(++timerId, fn) + return timerId + }, + clearTimeout: (id: number) => timers.delete(id), + performance: { now: () => clock }, + } + const el = { + scrollTop: 0, + scrollLeft: 0, + scrollHeight: 0, + clientHeight: VIEWPORT, + offsetHeight: VIEWPORT, + ownerDocument: { defaultView: win }, + addEventListener: (type: string, fn: () => void) => { + ;(listeners[type] ??= []).push(fn) + }, + removeEventListener: () => {}, + } + const max = () => el.scrollHeight - el.clientHeight + let ids = Array.from({ length: options.count ?? 100 }, (_, i) => i) + let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = + null + let dragClamped = false + + const v = new Virtualizer({ + count: ids.length, + estimateSize: () => 50, + getItemKey: (i) => ids[i]!, + getScrollElement: () => el as any, + scrollToFn: (offset, { adjustments = 0 }) => { + el.scrollTop = Math.max(0, Math.min(offset + adjustments, max())) + }, + observeElementRect: (_inst, cb) => { + cb({ width: 400, height: VIEWPORT }) + return () => {} + }, + observeElementOffset: (_inst, cb) => { + scrollCallback = cb + cb(el.scrollTop, false) + return () => {} + }, + ...options, + }) + setup?.(v) + + // What a framework adapter does on every render: rebuild measurements, + // run the update hook, and size the scroll container to the total. + const render = () => { + v['getMeasurements']() + v._willUpdate() + el.scrollHeight = v.getTotalSize() + (v.options.scrollMargin ?? 0) + } + const scroll = (isScrolling: boolean) => + scrollCallback!(el.scrollTop, isScrolling) + const frame = () => rafs.splice(0).forEach((cb) => cb(clock)) + const fire = (type: string) => listeners[type]?.forEach((fn) => fn()) + + render() + + for (const step of steps) { + switch (step.t) { + case 'at': + el.scrollTop = step.top + scroll(false) + break + case 'index': + v.scrollToIndex(step.i, { align: step.align ?? 'start' }) + break + case 'end': + v.scrollToEnd() + break + case 'by': + v.scrollBy(step.d) + break + case 'cancel': + v.cancelScroll() + break + case 'resize': + v.resizeItem(step.i, step.size) + render() + break + case 'prepend': + ids = [ + ...Array.from({ length: step.n }, (_, i) => i - step.n - ids.length), + ...ids, + ] + v.setOptions({ ...v.options, count: ids.length }) + render() + break + case 'event': + scroll(true) + break + case 'idle': + scroll(false) + break + case 'drag': { + const next = el.scrollTop + step.d + el.scrollTop = Math.max(0, Math.min(next, max())) + if (el.scrollTop !== next) dragClamped = true + scroll(true) + break + } + case 'touchstart': + fire('touchstart') + break + case 'touchend': + fire('touchend') + break + case 'frame': + frame() + break + case 'valve': + clock += 6000 + frame() + break + } + } + + // Settle: let reconcile finish while the scroller still reports motion, + // report rest, let the touch grace timer expire, and finish reconcile at + // rest. A reconcile still running then is stuck off its target; push it + // through the safety valve so the positions compare, but report it. + for (let i = 0; i < 20 && rafs.length > 0; i++) { + frame() + scroll(true) + } + scroll(false) + timers.forEach((fn) => fn()) + timers.clear() + for (let i = 0; i < 20 && rafs.length > 0; i++) { + frame() + scroll(false) + } + const stuck = v.scrollState !== null + if (stuck) { + clock += 6000 + frame() + scroll(false) + } + + return { top: el.scrollTop, dragClamped, stuck } +} + +type Case = { + name: string + steps: Array + options?: Options + setup?: (v: Virtualizer) => void +} + +const at1000 = (): Step => ({ t: 'at', top: 1000 }) +const landedOn20 = (align: ScrollAlignment = 'start'): Array => [ + { t: 'index', i: 20, align }, + { t: 'event' }, + { t: 'frame' }, // reached the target, still waiting for stable frames +] + +const keyedEnd: Options = { anchorTo: 'end' } + +const cases: Array = [ + { + name: 'A: resize above the target during an index scroll, lands (#1304)', + steps: [ + { t: 'index', i: 20 }, + { t: 'event' }, + { t: 'resize', i: 10, size: 100 }, + ], + }, + { + name: 'B: resize after the last reconcile write, gesture interrupts', + steps: [ + ...landedOn20(), + { t: 'resize', i: 10, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'C: scroll up, row between target and old viewport grows before the read-back, gesture', + steps: [ + { t: 'at', top: 2000 }, + { t: 'index', i: 20 }, + { t: 'resize', i: 30, size: 100 }, + { t: 'event' }, + { t: 'frame' }, + { t: 'drag', d: -50 }, + ], + }, + { + name: 'D: scroll up, row between target and old viewport grows before the read-back, lands', + steps: [ + { t: 'at', top: 2000 }, + { t: 'index', i: 20 }, + { t: 'resize', i: 30, size: 100 }, + ], + }, + { + name: 'D2: scroll up, row above the target grows before the read-back, lands', + steps: [ + { t: 'at', top: 2000 }, + { t: 'index', i: 20 }, + { t: 'resize', i: 10, size: 100 }, + ], + }, + { + name: 'E: user scroll, row above grows, user flings past it', + steps: [ + { t: 'drag', d: 1000 }, + { t: 'resize', i: 10, size: 100 }, + { t: 'drag', d: -700 }, + ], + }, + { + name: 'F: align end, a visible row grows, gesture', + steps: [ + ...landedOn20('end'), + { t: 'resize', i: 19, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'G: align end, a row above grows, gesture', + steps: [ + ...landedOn20('end'), + { t: 'resize', i: 10, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'H: align center, a row above grows, gesture', + steps: [ + ...landedOn20('center'), + { t: 'resize', i: 10, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'I: lanes 2 measured, the target changes lane, gesture', + options: { + lanes: 2, + laneAssignmentMode: 'measured', + estimateSize: (i) => (i === 19 ? 80 : 50), + }, + steps: [ + ...landedOn20(), + { t: 'resize', i: 18, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'J: scrollPaddingStart, a visible row above the target grows, gesture', + options: { scrollPaddingStart: 100 }, + steps: [ + ...landedOn20(), + { t: 'resize', i: 19, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'K: scrollMargin, target near the end, a row above grows, gesture', + options: { scrollMargin: 300 }, + steps: [ + { t: 'index', i: 93 }, + { t: 'event' }, + { t: 'frame' }, + { t: 'resize', i: 10, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'L: custom predicate opting out, gesture', + setup: (v) => { + v.shouldAdjustScrollPositionOnItemSizeChange = () => false + }, + steps: [ + ...landedOn20(), + { t: 'resize', i: 10, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'M: cancelScroll after the scroll settled', + steps: [ + { t: 'index', i: 20 }, + { t: 'event' }, + { t: 'resize', i: 10, size: 100 }, + { t: 'idle' }, + { t: 'cancel' }, + ], + }, + { + name: 'M2: cancelScroll while still scrolling', + steps: [ + { t: 'index', i: 20 }, + { t: 'event' }, + { t: 'resize', i: 10, size: 100 }, + { t: 'cancel' }, + ], + }, + { + name: 'N: safety valve after the scroll settled', + steps: [ + { t: 'index', i: 20 }, + { t: 'event' }, + { t: 'resize', i: 10, size: 100 }, + { t: 'idle' }, + { t: 'valve' }, + ], + }, + { + name: 'N2: safety valve while still scrolling', + steps: [ + { t: 'index', i: 20 }, + { t: 'event' }, + { t: 'resize', i: 10, size: 100 }, + { t: 'valve' }, + ], + }, + { + name: 'O: prepend during an index scroll, lands', + options: keyedEnd, + steps: [...landedOn20(), { t: 'prepend', n: 10 }], + }, + { + name: 'O2: prepend during an index scroll, gesture', + options: keyedEnd, + steps: [...landedOn20(), { t: 'prepend', n: 10 }, { t: 'drag', d: -100 }], + }, + { + name: 'O3: prepend and a resize during an index scroll, gesture', + options: keyedEnd, + steps: [ + ...landedOn20(), + { t: 'prepend', n: 10 }, + { t: 'resize', i: 5, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'P: user scroll, a row above grows, then a row at the new fold is measured', + steps: [ + { t: 'drag', d: 1000 }, + { t: 'resize', i: 10, size: 100 }, + { t: 'resize', i: 19, size: 120 }, + ], + }, + { + name: 'T: scrollBy right after scrollToIndex', + steps: [ + { t: 'index', i: 20 }, + { t: 'by', d: 100 }, + ], + }, + { + name: 'U: touch drag, a row above grows mid-touch, release', + steps: [ + at1000(), + { t: 'touchstart' }, + { t: 'drag', d: -30 }, + { t: 'resize', i: 10, size: 100 }, + { t: 'drag', d: -30 }, + { t: 'touchend' }, + ], + }, + { + name: 'X: scrollToEnd with anchorTo end, the last row grows, gesture', + options: keyedEnd, + steps: [ + { t: 'end' }, + { t: 'event' }, + { t: 'frame' }, + { t: 'resize', i: 99, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'X2: scrollToEnd, rows at the old position measured before the read-back', + steps: [ + { t: 'end' }, + { t: 'resize', i: 0, size: 30 }, + { t: 'resize', i: 1, size: 30 }, + { t: 'resize', i: 2, size: 30 }, + ], + }, + { + name: 'Y: user scroll, a row above shrinks', + steps: [ + { t: 'drag', d: 1000 }, + { t: 'resize', i: 10, size: 20 }, + ], + }, + { + name: 'Z: two resizes after the last reconcile write, gesture', + steps: [ + ...landedOn20(), + { t: 'resize', i: 10, size: 100 }, + { t: 'resize', i: 11, size: 70 }, + { t: 'drag', d: -100 }, + ], + }, +] + +// Cases where iOS still diverges, by id. All but P drop a correction deferred +// while an index scroll runs (#1305) instead of applying it the way non-iOS +// does; P misses the pending deferral when classifying the next resize. +// #1307 fixes them and must remove them from this list: the test asserts +// that each one still diverges, so a fix can't go unnoticed. +const KNOWN_DIVERGENT = new Set([ + 'B', + 'G', + 'H', + 'I', + 'K', + 'M', + 'M2', + 'N', + 'N2', + 'O3', + 'P', + 'Z', +]) + +describe('iOS deferral settles where non-iOS does', () => { + for (const c of cases) { + test(c.name, () => { + const plain = run(false, c) + const ios = run(true, c) + const result = JSON.stringify({ plain: plain.top, ios: ios.top }) + // A drag the clamp cut short is not additive with a later correction, + // so the two platforms legitimately differ; keep cases clear of it. + expect(plain.dragClamped || ios.dragClamped, result).toBe(false) + // Both platforms share a reconcile; a bug there matches on both sides, + // so check it separately: settling never needs the safety valve. + expect({ plain: plain.stuck, ios: ios.stuck }).toEqual({ + plain: false, + ios: false, + }) + if (KNOWN_DIVERGENT.has(c.name.split(':')[0]!)) { + expect(Math.abs(plain.top - ios.top), result).toBeGreaterThan(1) + } else { + expect(ios.top, result).toBe(plain.top) + } + }) + } +}) From 7aa34f62587aab20938dbc84ba88a25e59552036 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Mon, 12 Oct 2026 12:46:07 +0200 Subject: [PATCH 06/46] fix(virtual-core): let a user scroll end a non-smooth scrollToIndex before it lands (#1310) --- .changeset/index-scroll-user-interrupt.md | 5 + packages/virtual-core/src/index.ts | 30 ++++- packages/virtual-core/tests/index.test.ts | 127 +++++++++++++++------- 3 files changed, 120 insertions(+), 42 deletions(-) create mode 100644 .changeset/index-scroll-user-interrupt.md diff --git a/.changeset/index-scroll-user-interrupt.md b/.changeset/index-scroll-user-interrupt.md new file mode 100644 index 000000000..219eca40f --- /dev/null +++ b/.changeset/index-scroll-user-interrupt.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): let a user scroll end a non-smooth `scrollToIndex` before reconcile has seen it land, so a later measurement no longer pulls the user back to the old target diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index e42eff8f2..4720c705c 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -406,6 +406,10 @@ type ScrollState = { stableFrames: number // Keep arrival across measurement-driven retargets so external scrolls can cancel. hasReachedTarget: boolean + // The command asked for a smooth scroll. Unlike `behavior`, which reconcile + // switches to 'auto' for the final approach, this stays set: the smooth + // animation can still deliver scroll events after that switch. + startedSmooth: boolean // Where our last non-smooth write for this command put the element, until // its scroll event arrives. Until then `scrollOffset` still holds the old @@ -969,15 +973,29 @@ export class Virtualizer< offset = this._intendedScrollOffset } else if ( this.scrollState?.index != null && - this.scrollState.hasReachedTarget && + // A non-smooth write lands at once, so any other scroll event is + // the user's, even before reconcile saw the target reached (the + // target may keep moving, or iOS ignored the write under a + // finger). A write the browser clamped because the sizer hasn't + // grown yet reads back at the max, which is also where the fresh + // target clamps, so the check below keeps it reconciling (#1294). + // Not when rows above the target shrink before the read-back and + // pull the target below the new max: the read-back then looks + // like the user and the scroll stops at the max. That needs the + // sizer re-rendered before the scroll event, which browsers have + // not been seen to do (pinned by a `.fails` test). + // A smooth scroll's own animation events look like a user's, so + // it only yields once it has reached the target. + (this.scrollState.hasReachedTarget || + !this.scrollState.startedSmooth) && !approxEqual(offset, this.scrollState.lastTargetOffset) && !approxEqual(offset, this.getScrollStateTarget(this.scrollState)) && (this._clampedAdjustment === null || Math.abs(offset - this._clampedAdjustment.maxAtWrite) >= 1.5) ) { - // The index scroll reached its target, but an external scroll moved - // away while we waited for measurements. Our own measurement updates - // (including clamped writes) still need to reconcile. + // An external scroll moved away from the index scroll's target. + // Our own measurement updates (including clamped writes) still + // need to reconcile. this.scrollState = null } this._intendedScrollOffset = null @@ -2063,6 +2081,7 @@ export class Virtualizer< lastTargetOffset: offset, stableFrames: 0, hasReachedTarget: false, + startedSmooth: behavior === 'smooth', } this._scrollToOffset(offset, { adjustments: undefined, behavior }) @@ -2098,6 +2117,7 @@ export class Virtualizer< lastTargetOffset: offset, stableFrames: 0, hasReachedTarget: approxEqual(offset, this.getScrollOffset()), + startedSmooth: behavior === 'smooth', } this._scrollToOffset(offset, { adjustments: undefined, behavior }) @@ -2126,6 +2146,7 @@ export class Virtualizer< lastTargetOffset: offset, stableFrames: 0, hasReachedTarget: false, + startedSmooth: behavior === 'smooth', } this._scrollToOffset(offset, { adjustments: undefined, behavior }) @@ -2150,6 +2171,7 @@ export class Virtualizer< lastTargetOffset: offset, stableFrames: 0, hasReachedTarget: approxEqual(offset, this.getScrollOffset()), + startedSmooth: behavior === 'smooth', } this._scrollToOffset(offset, { adjustments: undefined, behavior }) diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 7fdcad521..17066043e 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -543,6 +543,13 @@ test.each(['auto', 'smooth'] as const)( 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() @@ -2551,46 +2558,40 @@ test('scrollToIndex (#1307): a resize classified against the stale offset does n expect(h.el.scrollTop).toBe(500) }) -test.fails( - '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.fails( - '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 }) +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) - h.v.resizeItem(10, 100) + h.scroll(true, 20) 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.scroll(true, 60) h.frame() - h.v.resizeItem(5, 100) + // 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(1050) - }, -) + 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 }) @@ -5474,9 +5475,10 @@ function setupHandDrivenScroll( 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++) { - rafCallbacks.splice(0).forEach((cb) => cb(0)) + frame() scroll(el.scrollTop, true) } scroll(el.scrollTop, false) @@ -5488,7 +5490,7 @@ function setupHandDrivenScroll( rafCallbacks.splice(0).forEach((cb) => cb(0)) } } - return { v, el, scroll, settle, frames, browser } + return { v, el, scroll, frame, settle, frames, browser } } test('a resize before the read-back of a scrollToIndex write is left to reconcile', () => { @@ -5649,3 +5651,52 @@ test('scrollBy after cancelScroll dropped an ignored write builds on the real po 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.fails( + 'a scrollToIndex write clamped by a sizer that shrank below it still reaches the fresh target', + () => { + // Not seen in a real browser: the sizer has to re-render before the + // scroll event reports the write. 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 neither our write nor the target, so it + // reads as a user scroll and the index scroll stops 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) + }, +) From 46f73fb419185536ee34ec1c6eba8ad8824d77b7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:04:22 +0200 Subject: [PATCH 07/46] ci: Version Packages (#1312) --- .changeset/index-scroll-user-interrupt.md | 5 - .changeset/last-written-scroll-offset.md | 5 - benchmarks/CHANGELOG.md | 7 + benchmarks/package.json | 2 +- examples/angular/chat/package.json | 2 +- examples/angular/dynamic/package.json | 2 +- examples/angular/fixed/package.json | 2 +- examples/angular/infinite-scroll/package.json | 2 +- examples/angular/padding/package.json | 2 +- examples/angular/smooth-scroll/package.json | 2 +- examples/angular/sticky/package.json | 2 +- examples/angular/table/package.json | 2 +- examples/angular/variable/package.json | 2 +- examples/angular/window/package.json | 2 +- examples/lit/dynamic/package.json | 4 +- examples/lit/fixed/package.json | 4 +- examples/marko/chat-pretext/package.json | 2 +- examples/marko/chat/package.json | 2 +- examples/marko/dynamic/package.json | 2 +- examples/marko/fixed/package.json | 2 +- examples/marko/grid/package.json | 2 +- examples/marko/infinite-scroll/package.json | 2 +- examples/marko/padding/package.json | 2 +- examples/marko/pretext/package.json | 2 +- examples/marko/scroll-padding/package.json | 2 +- examples/marko/smooth-scroll/package.json | 2 +- examples/marko/ssr-fetch/package.json | 2 +- examples/marko/ssr-restore/package.json | 2 +- examples/marko/ssr-slice/package.json | 2 +- examples/marko/ssr/package.json | 2 +- examples/marko/sticky/package.json | 2 +- examples/marko/table/package.json | 2 +- examples/marko/variable/package.json | 2 +- examples/marko/window-ssr-slice/package.json | 2 +- examples/marko/window/package.json | 2 +- examples/react/chat/package.json | 2 +- examples/react/dynamic/package.json | 2 +- examples/react/fixed/package.json | 2 +- examples/react/infinite-scroll/package.json | 2 +- examples/react/padding/package.json | 2 +- examples/react/pretext/package.json | 2 +- examples/react/scroll-padding/package.json | 2 +- examples/react/smooth-scroll/package.json | 2 +- examples/react/sticky/package.json | 2 +- examples/react/table/package.json | 2 +- examples/react/variable/package.json | 2 +- examples/react/window/package.json | 2 +- examples/svelte/dynamic/package.json | 2 +- examples/svelte/fixed/package.json | 2 +- examples/svelte/infinite-scroll/package.json | 2 +- examples/svelte/smooth-scroll/package.json | 2 +- examples/svelte/sticky/package.json | 2 +- examples/svelte/table/package.json | 2 +- examples/vue/dynamic/package.json | 2 +- examples/vue/fixed/package.json | 2 +- examples/vue/infinite-scroll/package.json | 2 +- examples/vue/padding/package.json | 2 +- examples/vue/scroll-padding/package.json | 2 +- examples/vue/smooth-scroll/package.json | 2 +- examples/vue/sticky/package.json | 2 +- examples/vue/table/package.json | 2 +- examples/vue/variable/package.json | 2 +- packages/angular-virtual/CHANGELOG.md | 7 + packages/angular-virtual/package.json | 2 +- packages/lit-virtual/CHANGELOG.md | 7 + packages/lit-virtual/package.json | 2 +- packages/marko-virtual/CHANGELOG.md | 7 + packages/marko-virtual/package.json | 2 +- packages/react-virtual/CHANGELOG.md | 7 + packages/react-virtual/package.json | 2 +- packages/solid-virtual/CHANGELOG.md | 7 + packages/solid-virtual/package.json | 2 +- packages/svelte-virtual/CHANGELOG.md | 7 + packages/svelte-virtual/package.json | 2 +- packages/virtual-core/CHANGELOG.md | 8 ++ packages/virtual-core/package.json | 2 +- packages/vue-virtual/CHANGELOG.md | 7 + packages/vue-virtual/package.json | 2 +- pnpm-lock.yaml | 120 +++++++++--------- 79 files changed, 193 insertions(+), 139 deletions(-) delete mode 100644 .changeset/index-scroll-user-interrupt.md delete mode 100644 .changeset/last-written-scroll-offset.md diff --git a/.changeset/index-scroll-user-interrupt.md b/.changeset/index-scroll-user-interrupt.md deleted file mode 100644 index 219eca40f..000000000 --- a/.changeset/index-scroll-user-interrupt.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/virtual-core': patch ---- - -fix(virtual-core): let a user scroll end a non-smooth `scrollToIndex` before reconcile has seen it land, so a later measurement no longer pulls the user back to the old target diff --git a/.changeset/last-written-scroll-offset.md b/.changeset/last-written-scroll-offset.md deleted file mode 100644 index 535bcee45..000000000 --- a/.changeset/last-written-scroll-offset.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/virtual-core': patch ---- - -fix(virtual-core): leave resizes measured before a scroll command's write is read back to reconcile, so an upward `scrollToIndex` no longer jumps back and stalls until the safety valve, and build `scrollBy` on a just-written offset diff --git a/benchmarks/CHANGELOG.md b/benchmarks/CHANGELOG.md index 3e56a1f9b..153b806b0 100644 --- a/benchmarks/CHANGELOG.md +++ b/benchmarks/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/virtual-benchmarks +## 0.0.19 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/react-virtual@3.14.16 + ## 0.0.18 ### Patch Changes diff --git a/benchmarks/package.json b/benchmarks/package.json index 74307612a..7a0f1be80 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -1,7 +1,7 @@ { "name": "@tanstack/virtual-benchmarks", "private": true, - "version": "0.0.18", + "version": "0.0.19", "type": "module", "scripts": { "dev": "vite", diff --git a/examples/angular/chat/package.json b/examples/angular/chat/package.json index 2b6d1f633..bc0ebb298 100644 --- a/examples/angular/chat/package.json +++ b/examples/angular/chat/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.1", + "@tanstack/angular-virtual": "^6.1.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/dynamic/package.json b/examples/angular/dynamic/package.json index 7e575ea12..72fec1776 100644 --- a/examples/angular/dynamic/package.json +++ b/examples/angular/dynamic/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", "@faker-js/faker": "^8.4.1", - "@tanstack/angular-virtual": "^6.1.1", + "@tanstack/angular-virtual": "^6.1.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/fixed/package.json b/examples/angular/fixed/package.json index 85e367611..c2a42f9d6 100644 --- a/examples/angular/fixed/package.json +++ b/examples/angular/fixed/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.1", + "@tanstack/angular-virtual": "^6.1.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/infinite-scroll/package.json b/examples/angular/infinite-scroll/package.json index d6575e22a..5cb6b3fd7 100644 --- a/examples/angular/infinite-scroll/package.json +++ b/examples/angular/infinite-scroll/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", "@tanstack/angular-query-experimental": "5.80.7", - "@tanstack/angular-virtual": "^6.1.1", + "@tanstack/angular-virtual": "^6.1.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/padding/package.json b/examples/angular/padding/package.json index 72d1e3185..ae515317b 100644 --- a/examples/angular/padding/package.json +++ b/examples/angular/padding/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.1", + "@tanstack/angular-virtual": "^6.1.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/smooth-scroll/package.json b/examples/angular/smooth-scroll/package.json index c7920d7f0..d467f1ed8 100644 --- a/examples/angular/smooth-scroll/package.json +++ b/examples/angular/smooth-scroll/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.1", + "@tanstack/angular-virtual": "^6.1.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/sticky/package.json b/examples/angular/sticky/package.json index 5d6eb0265..57a15d963 100644 --- a/examples/angular/sticky/package.json +++ b/examples/angular/sticky/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", "@faker-js/faker": "^8.4.1", - "@tanstack/angular-virtual": "^6.1.1", + "@tanstack/angular-virtual": "^6.1.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/table/package.json b/examples/angular/table/package.json index 545ed2f8c..d11f8a84f 100644 --- a/examples/angular/table/package.json +++ b/examples/angular/table/package.json @@ -19,7 +19,7 @@ "@angular/router": "^20.2.0", "@faker-js/faker": "^8.4.1", "@tanstack/angular-table": "8.21.3", - "@tanstack/angular-virtual": "^6.1.1", + "@tanstack/angular-virtual": "^6.1.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/variable/package.json b/examples/angular/variable/package.json index dae776b89..93760eb34 100644 --- a/examples/angular/variable/package.json +++ b/examples/angular/variable/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.1", + "@tanstack/angular-virtual": "^6.1.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/window/package.json b/examples/angular/window/package.json index 24b79163c..d4d982054 100644 --- a/examples/angular/window/package.json +++ b/examples/angular/window/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.1", + "@tanstack/angular-virtual": "^6.1.2", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/lit/dynamic/package.json b/examples/lit/dynamic/package.json index b31d25035..f64574c13 100644 --- a/examples/lit/dynamic/package.json +++ b/examples/lit/dynamic/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/lit-virtual": "^3.14.4", - "@tanstack/virtual-core": "^3.19.0", + "@tanstack/lit-virtual": "^3.14.5", + "@tanstack/virtual-core": "^3.19.1", "lit": "^3.3.0" }, "devDependencies": { diff --git a/examples/lit/fixed/package.json b/examples/lit/fixed/package.json index f8ccc8598..a0d22366d 100644 --- a/examples/lit/fixed/package.json +++ b/examples/lit/fixed/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/lit-virtual": "^3.14.4", - "@tanstack/virtual-core": "^3.19.0", + "@tanstack/lit-virtual": "^3.14.5", + "@tanstack/virtual-core": "^3.19.1", "lit": "^3.3.0" }, "devDependencies": { diff --git a/examples/marko/chat-pretext/package.json b/examples/marko/chat-pretext/package.json index b3003ea0a..3ca826c23 100644 --- a/examples/marko/chat-pretext/package.json +++ b/examples/marko/chat-pretext/package.json @@ -11,7 +11,7 @@ "dependencies": { "@chenglou/pretext": "^0.0.7", "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/chat/package.json b/examples/marko/chat/package.json index 195c0826b..efe45ba7d 100644 --- a/examples/marko/chat/package.json +++ b/examples/marko/chat/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/dynamic/package.json b/examples/marko/dynamic/package.json index c64376e57..6cf60e308 100644 --- a/examples/marko/dynamic/package.json +++ b/examples/marko/dynamic/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/fixed/package.json b/examples/marko/fixed/package.json index 522ab219b..9abb88d12 100644 --- a/examples/marko/fixed/package.json +++ b/examples/marko/fixed/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/grid/package.json b/examples/marko/grid/package.json index 5df9eb341..0ee0a6ad3 100644 --- a/examples/marko/grid/package.json +++ b/examples/marko/grid/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/infinite-scroll/package.json b/examples/marko/infinite-scroll/package.json index 4d22f7720..2cf47f872 100644 --- a/examples/marko/infinite-scroll/package.json +++ b/examples/marko/infinite-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/padding/package.json b/examples/marko/padding/package.json index 7bd84f75f..4c5211b98 100644 --- a/examples/marko/padding/package.json +++ b/examples/marko/padding/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/pretext/package.json b/examples/marko/pretext/package.json index 6d5c73f82..abac525d3 100644 --- a/examples/marko/pretext/package.json +++ b/examples/marko/pretext/package.json @@ -11,7 +11,7 @@ "dependencies": { "@chenglou/pretext": "^0.0.7", "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/scroll-padding/package.json b/examples/marko/scroll-padding/package.json index 689787789..f66b5cbec 100644 --- a/examples/marko/scroll-padding/package.json +++ b/examples/marko/scroll-padding/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/smooth-scroll/package.json b/examples/marko/smooth-scroll/package.json index 375534edd..af3eefdb4 100644 --- a/examples/marko/smooth-scroll/package.json +++ b/examples/marko/smooth-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr-fetch/package.json b/examples/marko/ssr-fetch/package.json index c62aeb49e..d60d61ab0 100644 --- a/examples/marko/ssr-fetch/package.json +++ b/examples/marko/ssr-fetch/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr-restore/package.json b/examples/marko/ssr-restore/package.json index 563f380a7..4faddddf4 100644 --- a/examples/marko/ssr-restore/package.json +++ b/examples/marko/ssr-restore/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr-slice/package.json b/examples/marko/ssr-slice/package.json index a9710d49b..b73a21d7f 100644 --- a/examples/marko/ssr-slice/package.json +++ b/examples/marko/ssr-slice/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr/package.json b/examples/marko/ssr/package.json index 61379f7a8..3723dee3b 100644 --- a/examples/marko/ssr/package.json +++ b/examples/marko/ssr/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/sticky/package.json b/examples/marko/sticky/package.json index 5e6c07cc0..db34341e3 100644 --- a/examples/marko/sticky/package.json +++ b/examples/marko/sticky/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/table/package.json b/examples/marko/table/package.json index 8f7198af9..3eadb0ba4 100644 --- a/examples/marko/table/package.json +++ b/examples/marko/table/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/variable/package.json b/examples/marko/variable/package.json index 5bd49907e..419bdb875 100644 --- a/examples/marko/variable/package.json +++ b/examples/marko/variable/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/window-ssr-slice/package.json b/examples/marko/window-ssr-slice/package.json index 03a4cb0eb..2b6188a07 100644 --- a/examples/marko/window-ssr-slice/package.json +++ b/examples/marko/window-ssr-slice/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/window/package.json b/examples/marko/window/package.json index 946888005..48bd814b6 100644 --- a/examples/marko/window/package.json +++ b/examples/marko/window/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.1", + "@tanstack/marko-virtual": "^3.16.2", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/react/chat/package.json b/examples/react/chat/package.json index 090cb9ed3..e55ff1321 100644 --- a/examples/react/chat/package.json +++ b/examples/react/chat/package.json @@ -8,7 +8,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.15", + "@tanstack/react-virtual": "^3.14.16", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/dynamic/package.json b/examples/react/dynamic/package.json index 0b94c2d2b..6b93d29e9 100644 --- a/examples/react/dynamic/package.json +++ b/examples/react/dynamic/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/react-virtual": "^3.14.15", + "@tanstack/react-virtual": "^3.14.16", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/fixed/package.json b/examples/react/fixed/package.json index 022851072..7c2ed6a81 100644 --- a/examples/react/fixed/package.json +++ b/examples/react/fixed/package.json @@ -8,7 +8,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.15", + "@tanstack/react-virtual": "^3.14.16", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/infinite-scroll/package.json b/examples/react/infinite-scroll/package.json index 06b089ba8..774a916a1 100644 --- a/examples/react/infinite-scroll/package.json +++ b/examples/react/infinite-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.80.7", - "@tanstack/react-virtual": "^3.14.15", + "@tanstack/react-virtual": "^3.14.16", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/padding/package.json b/examples/react/padding/package.json index 538ab2d35..d360b54e0 100644 --- a/examples/react/padding/package.json +++ b/examples/react/padding/package.json @@ -9,7 +9,7 @@ "start": "vite" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.15", + "@tanstack/react-virtual": "^3.14.16", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/pretext/package.json b/examples/react/pretext/package.json index f4d7cc568..682b4ede4 100644 --- a/examples/react/pretext/package.json +++ b/examples/react/pretext/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@chenglou/pretext": "^0.0.7", - "@tanstack/react-virtual": "^3.14.15", + "@tanstack/react-virtual": "^3.14.16", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/scroll-padding/package.json b/examples/react/scroll-padding/package.json index 0a15c2092..53d56ec87 100644 --- a/examples/react/scroll-padding/package.json +++ b/examples/react/scroll-padding/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@react-hookz/web": "^25.1.1", - "@tanstack/react-virtual": "^3.14.15", + "@tanstack/react-virtual": "^3.14.16", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/smooth-scroll/package.json b/examples/react/smooth-scroll/package.json index 121464f81..8f15d12f0 100644 --- a/examples/react/smooth-scroll/package.json +++ b/examples/react/smooth-scroll/package.json @@ -9,7 +9,7 @@ "start": "vite" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.15", + "@tanstack/react-virtual": "^3.14.16", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/sticky/package.json b/examples/react/sticky/package.json index dc0af179f..c388ba2af 100644 --- a/examples/react/sticky/package.json +++ b/examples/react/sticky/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/react-virtual": "^3.14.15", + "@tanstack/react-virtual": "^3.14.16", "lodash": "^4.17.21", "react": "^19.2.7", "react-dom": "^19.2.7" diff --git a/examples/react/table/package.json b/examples/react/table/package.json index 8cac0cb80..272c91ba2 100644 --- a/examples/react/table/package.json +++ b/examples/react/table/package.json @@ -11,7 +11,7 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.14.15", + "@tanstack/react-virtual": "^3.14.16", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/variable/package.json b/examples/react/variable/package.json index d7335d389..7e6a03438 100644 --- a/examples/react/variable/package.json +++ b/examples/react/variable/package.json @@ -9,7 +9,7 @@ "start": "vite" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.15", + "@tanstack/react-virtual": "^3.14.16", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/window/package.json b/examples/react/window/package.json index 1084122f8..c7efeefa1 100644 --- a/examples/react/window/package.json +++ b/examples/react/window/package.json @@ -8,7 +8,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.15", + "@tanstack/react-virtual": "^3.14.16", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/svelte/dynamic/package.json b/examples/svelte/dynamic/package.json index 300e4c2ab..bd528b8f5 100644 --- a/examples/svelte/dynamic/package.json +++ b/examples/svelte/dynamic/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/svelte-virtual": "^3.13.41" + "@tanstack/svelte-virtual": "^3.13.42" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/fixed/package.json b/examples/svelte/fixed/package.json index 4d9991db5..5f13b4f93 100644 --- a/examples/svelte/fixed/package.json +++ b/examples/svelte/fixed/package.json @@ -9,7 +9,7 @@ "check": "svelte-check --tsconfig ./tsconfig.json" }, "dependencies": { - "@tanstack/svelte-virtual": "^3.13.41" + "@tanstack/svelte-virtual": "^3.13.42" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/infinite-scroll/package.json b/examples/svelte/infinite-scroll/package.json index 77fec32a4..c0968e9d5 100644 --- a/examples/svelte/infinite-scroll/package.json +++ b/examples/svelte/infinite-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@tanstack/svelte-query": "^5.80.7", - "@tanstack/svelte-virtual": "^3.13.41" + "@tanstack/svelte-virtual": "^3.13.42" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/smooth-scroll/package.json b/examples/svelte/smooth-scroll/package.json index 40f6a14d6..e0c23dbe1 100644 --- a/examples/svelte/smooth-scroll/package.json +++ b/examples/svelte/smooth-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/svelte-virtual": "^3.13.41" + "@tanstack/svelte-virtual": "^3.13.42" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/sticky/package.json b/examples/svelte/sticky/package.json index 7302b6563..38f735043 100644 --- a/examples/svelte/sticky/package.json +++ b/examples/svelte/sticky/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/svelte-virtual": "^3.13.41", + "@tanstack/svelte-virtual": "^3.13.42", "lodash": "^4.17.21" }, "devDependencies": { diff --git a/examples/svelte/table/package.json b/examples/svelte/table/package.json index 5c0e94d0b..36f3be4ef 100644 --- a/examples/svelte/table/package.json +++ b/examples/svelte/table/package.json @@ -11,7 +11,7 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@tanstack/svelte-table": "^8.21.3", - "@tanstack/svelte-virtual": "^3.13.41" + "@tanstack/svelte-virtual": "^3.13.42" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/vue/dynamic/package.json b/examples/vue/dynamic/package.json index 01a1a2fca..cae4f9c49 100644 --- a/examples/vue/dynamic/package.json +++ b/examples/vue/dynamic/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/vue-virtual": "^3.13.41", + "@tanstack/vue-virtual": "^3.13.42", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/fixed/package.json b/examples/vue/fixed/package.json index 1abe750e4..66b4e842c 100644 --- a/examples/vue/fixed/package.json +++ b/examples/vue/fixed/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.41", + "@tanstack/vue-virtual": "^3.13.42", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/infinite-scroll/package.json b/examples/vue/infinite-scroll/package.json index a54929221..e080aa1d0 100644 --- a/examples/vue/infinite-scroll/package.json +++ b/examples/vue/infinite-scroll/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@tanstack/vue-query": "^5.80.7", - "@tanstack/vue-virtual": "^3.13.41", + "@tanstack/vue-virtual": "^3.13.42", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/padding/package.json b/examples/vue/padding/package.json index 46add00e4..573d84276 100644 --- a/examples/vue/padding/package.json +++ b/examples/vue/padding/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.41", + "@tanstack/vue-virtual": "^3.13.42", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/scroll-padding/package.json b/examples/vue/scroll-padding/package.json index ef4fcbb9a..8a126e3af 100644 --- a/examples/vue/scroll-padding/package.json +++ b/examples/vue/scroll-padding/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.41", + "@tanstack/vue-virtual": "^3.13.42", "@vueuse/core": "^12.8.2", "vue": "^3.5.16" }, diff --git a/examples/vue/smooth-scroll/package.json b/examples/vue/smooth-scroll/package.json index 64ea4fb62..9af0e9e26 100644 --- a/examples/vue/smooth-scroll/package.json +++ b/examples/vue/smooth-scroll/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.41", + "@tanstack/vue-virtual": "^3.13.42", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/sticky/package.json b/examples/vue/sticky/package.json index 4c10cc1e5..fe66b47f7 100644 --- a/examples/vue/sticky/package.json +++ b/examples/vue/sticky/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/vue-virtual": "^3.13.41", + "@tanstack/vue-virtual": "^3.13.42", "lodash": "^4.17.21", "vue": "^3.5.16" }, diff --git a/examples/vue/table/package.json b/examples/vue/table/package.json index bcb052bf4..c19103562 100644 --- a/examples/vue/table/package.json +++ b/examples/vue/table/package.json @@ -10,7 +10,7 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@tanstack/vue-table": "^8.21.3", - "@tanstack/vue-virtual": "^3.13.41", + "@tanstack/vue-virtual": "^3.13.42", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/variable/package.json b/examples/vue/variable/package.json index ee163fdf5..25836d93c 100644 --- a/examples/vue/variable/package.json +++ b/examples/vue/variable/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.41", + "@tanstack/vue-virtual": "^3.13.42", "vue": "^3.5.16" }, "devDependencies": { diff --git a/packages/angular-virtual/CHANGELOG.md b/packages/angular-virtual/CHANGELOG.md index 71660c70c..28582fd6a 100644 --- a/packages/angular-virtual/CHANGELOG.md +++ b/packages/angular-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/angular-virtual +## 6.1.2 + +### Patch Changes + +- Updated dependencies [[`7aa34f6`](https://github.com/TanStack/virtual/commit/7aa34f62587aab20938dbc84ba88a25e59552036), [`e7a0a92`](https://github.com/TanStack/virtual/commit/e7a0a92e4bec5ecd0a37659e951c8e8ff72e0207)]: + - @tanstack/virtual-core@3.19.1 + ## 6.1.1 ### Patch Changes diff --git a/packages/angular-virtual/package.json b/packages/angular-virtual/package.json index e72b82fea..a48c78edb 100644 --- a/packages/angular-virtual/package.json +++ b/packages/angular-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/angular-virtual", - "version": "6.1.1", + "version": "6.1.2", "description": "Headless UI for virtualizing scrollable elements in Angular", "author": "Garrett Darnell", "license": "MIT", diff --git a/packages/lit-virtual/CHANGELOG.md b/packages/lit-virtual/CHANGELOG.md index 8057d1ad5..cd5678df3 100644 --- a/packages/lit-virtual/CHANGELOG.md +++ b/packages/lit-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/lit-virtual +## 3.14.5 + +### Patch Changes + +- Updated dependencies [[`7aa34f6`](https://github.com/TanStack/virtual/commit/7aa34f62587aab20938dbc84ba88a25e59552036), [`e7a0a92`](https://github.com/TanStack/virtual/commit/e7a0a92e4bec5ecd0a37659e951c8e8ff72e0207)]: + - @tanstack/virtual-core@3.19.1 + ## 3.14.4 ### Patch Changes diff --git a/packages/lit-virtual/package.json b/packages/lit-virtual/package.json index 69afe2619..a670f93ab 100644 --- a/packages/lit-virtual/package.json +++ b/packages/lit-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/lit-virtual", - "version": "3.14.4", + "version": "3.14.5", "description": "Headless UI for virtualizing scrollable elements in Lit", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/marko-virtual/CHANGELOG.md b/packages/marko-virtual/CHANGELOG.md index 0689e1637..c4f42b035 100644 --- a/packages/marko-virtual/CHANGELOG.md +++ b/packages/marko-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/marko-virtual +## 3.16.2 + +### Patch Changes + +- Updated dependencies [[`7aa34f6`](https://github.com/TanStack/virtual/commit/7aa34f62587aab20938dbc84ba88a25e59552036), [`e7a0a92`](https://github.com/TanStack/virtual/commit/e7a0a92e4bec5ecd0a37659e951c8e8ff72e0207)]: + - @tanstack/virtual-core@3.19.1 + ## 3.16.1 ### Patch Changes diff --git a/packages/marko-virtual/package.json b/packages/marko-virtual/package.json index 8a29c079e..0aea9312f 100644 --- a/packages/marko-virtual/package.json +++ b/packages/marko-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/marko-virtual", - "version": "3.16.1", + "version": "3.16.2", "description": "Headless UI for virtualizing scrollable elements in Marko 6", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/react-virtual/CHANGELOG.md b/packages/react-virtual/CHANGELOG.md index 96d03e021..789b82292 100644 --- a/packages/react-virtual/CHANGELOG.md +++ b/packages/react-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/react-virtual +## 3.14.16 + +### Patch Changes + +- Updated dependencies [[`7aa34f6`](https://github.com/TanStack/virtual/commit/7aa34f62587aab20938dbc84ba88a25e59552036), [`e7a0a92`](https://github.com/TanStack/virtual/commit/e7a0a92e4bec5ecd0a37659e951c8e8ff72e0207)]: + - @tanstack/virtual-core@3.19.1 + ## 3.14.15 ### Patch Changes diff --git a/packages/react-virtual/package.json b/packages/react-virtual/package.json index 3b5f843e7..23a08a0bf 100644 --- a/packages/react-virtual/package.json +++ b/packages/react-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/react-virtual", - "version": "3.14.15", + "version": "3.14.16", "description": "Headless UI for virtualizing scrollable elements in React", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/solid-virtual/CHANGELOG.md b/packages/solid-virtual/CHANGELOG.md index 9e8ef99a2..d8a293668 100644 --- a/packages/solid-virtual/CHANGELOG.md +++ b/packages/solid-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/solid-virtual +## 3.13.43 + +### Patch Changes + +- Updated dependencies [[`7aa34f6`](https://github.com/TanStack/virtual/commit/7aa34f62587aab20938dbc84ba88a25e59552036), [`e7a0a92`](https://github.com/TanStack/virtual/commit/e7a0a92e4bec5ecd0a37659e951c8e8ff72e0207)]: + - @tanstack/virtual-core@3.19.1 + ## 3.13.42 ### Patch Changes diff --git a/packages/solid-virtual/package.json b/packages/solid-virtual/package.json index 4295141ea..cfb9ae822 100644 --- a/packages/solid-virtual/package.json +++ b/packages/solid-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/solid-virtual", - "version": "3.13.42", + "version": "3.13.43", "description": "Headless UI for virtualizing scrollable elements in Solid", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/svelte-virtual/CHANGELOG.md b/packages/svelte-virtual/CHANGELOG.md index 88645fbec..02a0a540c 100644 --- a/packages/svelte-virtual/CHANGELOG.md +++ b/packages/svelte-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/svelte-virtual +## 3.13.42 + +### Patch Changes + +- Updated dependencies [[`7aa34f6`](https://github.com/TanStack/virtual/commit/7aa34f62587aab20938dbc84ba88a25e59552036), [`e7a0a92`](https://github.com/TanStack/virtual/commit/e7a0a92e4bec5ecd0a37659e951c8e8ff72e0207)]: + - @tanstack/virtual-core@3.19.1 + ## 3.13.41 ### Patch Changes diff --git a/packages/svelte-virtual/package.json b/packages/svelte-virtual/package.json index 8c7ce4c21..bc146dd1a 100644 --- a/packages/svelte-virtual/package.json +++ b/packages/svelte-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/svelte-virtual", - "version": "3.13.41", + "version": "3.13.42", "description": "Headless UI for virtualizing scrollable elements in Svelte", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/virtual-core/CHANGELOG.md b/packages/virtual-core/CHANGELOG.md index 22baed2f6..867c948ba 100644 --- a/packages/virtual-core/CHANGELOG.md +++ b/packages/virtual-core/CHANGELOG.md @@ -1,5 +1,13 @@ # @tanstack/virtual-core +## 3.19.1 + +### Patch Changes + +- [#1310](https://github.com/TanStack/virtual/pull/1310) [`7aa34f6`](https://github.com/TanStack/virtual/commit/7aa34f62587aab20938dbc84ba88a25e59552036) - fix(virtual-core): let a user scroll end a non-smooth `scrollToIndex` before reconcile has seen it land, so a later measurement no longer pulls the user back to the old target + +- [#1309](https://github.com/TanStack/virtual/pull/1309) [`e7a0a92`](https://github.com/TanStack/virtual/commit/e7a0a92e4bec5ecd0a37659e951c8e8ff72e0207) - fix(virtual-core): leave resizes measured before a scroll command's write is read back to reconcile, so an upward `scrollToIndex` no longer jumps back and stalls until the safety valve, and build `scrollBy` on a just-written offset + ## 3.19.0 ### Minor Changes diff --git a/packages/virtual-core/package.json b/packages/virtual-core/package.json index d125b91d6..12b5f1783 100644 --- a/packages/virtual-core/package.json +++ b/packages/virtual-core/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/virtual-core", - "version": "3.19.0", + "version": "3.19.1", "description": "Headless UI for virtualizing scrollable elements in TS/JS + Frameworks", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/vue-virtual/CHANGELOG.md b/packages/vue-virtual/CHANGELOG.md index 3a537ad24..9df595971 100644 --- a/packages/vue-virtual/CHANGELOG.md +++ b/packages/vue-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/vue-virtual +## 3.13.42 + +### Patch Changes + +- Updated dependencies [[`7aa34f6`](https://github.com/TanStack/virtual/commit/7aa34f62587aab20938dbc84ba88a25e59552036), [`e7a0a92`](https://github.com/TanStack/virtual/commit/e7a0a92e4bec5ecd0a37659e951c8e8ff72e0207)]: + - @tanstack/virtual-core@3.19.1 + ## 3.13.41 ### Patch Changes diff --git a/packages/vue-virtual/package.json b/packages/vue-virtual/package.json index dc015349a..f5b9daed3 100644 --- a/packages/vue-virtual/package.json +++ b/packages/vue-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/vue-virtual", - "version": "3.13.41", + "version": "3.13.42", "description": "Headless UI for virtualizing scrollable elements in Vue", "author": "Tanner Linsley", "license": "MIT", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3ecf3c82..52f848c25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,7 +145,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.1 + specifier: ^6.1.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -200,7 +200,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/angular-virtual': - specifier: ^6.1.1 + specifier: ^6.1.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -252,7 +252,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.1 + specifier: ^6.1.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -307,7 +307,7 @@ importers: specifier: 5.80.7 version: 5.80.7(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)) '@tanstack/angular-virtual': - specifier: ^6.1.1 + specifier: ^6.1.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -359,7 +359,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.1 + specifier: ^6.1.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -411,7 +411,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.1 + specifier: ^6.1.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -466,7 +466,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/angular-virtual': - specifier: ^6.1.1 + specifier: ^6.1.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -524,7 +524,7 @@ importers: specifier: 8.21.3 version: 8.21.3(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)) '@tanstack/angular-virtual': - specifier: ^6.1.1 + specifier: ^6.1.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -576,7 +576,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.1 + specifier: ^6.1.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -628,7 +628,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.1 + specifier: ^6.1.2 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -659,10 +659,10 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/lit-virtual': - specifier: ^3.14.4 + specifier: ^3.14.5 version: link:../../../packages/lit-virtual '@tanstack/virtual-core': - specifier: ^3.19.0 + specifier: ^3.19.1 version: link:../../../packages/virtual-core lit: specifier: ^3.3.0 @@ -684,10 +684,10 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/lit-virtual': - specifier: ^3.14.4 + specifier: ^3.14.5 version: link:../../../packages/lit-virtual '@tanstack/virtual-core': - specifier: ^3.19.0 + specifier: ^3.19.1 version: link:../../../packages/virtual-core lit: specifier: ^3.3.0 @@ -709,7 +709,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -737,7 +737,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -762,7 +762,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -787,7 +787,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -812,7 +812,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -837,7 +837,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -862,7 +862,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -890,7 +890,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -915,7 +915,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -940,7 +940,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -965,7 +965,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -990,7 +990,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1015,7 +1015,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1040,7 +1040,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1065,7 +1065,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1090,7 +1090,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1115,7 +1115,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1140,7 +1140,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1165,7 +1165,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.1 + specifier: ^3.16.2 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1187,7 +1187,7 @@ importers: examples/react/chat: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.15 + specifier: ^3.14.16 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1218,7 +1218,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/react-virtual': - specifier: ^3.14.15 + specifier: ^3.14.16 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1249,7 +1249,7 @@ importers: examples/react/fixed: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.15 + specifier: ^3.14.16 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1283,7 +1283,7 @@ importers: specifier: ^5.80.7 version: 5.90.5(react@19.2.7) '@tanstack/react-virtual': - specifier: ^3.14.15 + specifier: ^3.14.16 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1308,7 +1308,7 @@ importers: examples/react/padding: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.15 + specifier: ^3.14.16 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1336,7 +1336,7 @@ importers: specifier: ^0.0.7 version: 0.0.7 '@tanstack/react-virtual': - specifier: ^3.14.15 + specifier: ^3.14.16 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1370,7 +1370,7 @@ importers: specifier: ^25.1.1 version: 25.1.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-virtual': - specifier: ^3.14.15 + specifier: ^3.14.16 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1395,7 +1395,7 @@ importers: examples/react/smooth-scroll: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.15 + specifier: ^3.14.16 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1423,7 +1423,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/react-virtual': - specifier: ^3.14.15 + specifier: ^3.14.16 version: link:../../../packages/react-virtual lodash: specifier: ^4.17.21 @@ -1460,7 +1460,7 @@ importers: specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-virtual': - specifier: ^3.14.15 + specifier: ^3.14.16 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1485,7 +1485,7 @@ importers: examples/react/variable: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.15 + specifier: ^3.14.16 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1510,7 +1510,7 @@ importers: examples/react/window: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.15 + specifier: ^3.14.16 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1544,7 +1544,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/svelte-virtual': - specifier: ^3.13.41 + specifier: ^3.13.42 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1572,7 +1572,7 @@ importers: examples/svelte/fixed: dependencies: '@tanstack/svelte-virtual': - specifier: ^3.13.41 + specifier: ^3.13.42 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1603,7 +1603,7 @@ importers: specifier: ^5.80.7 version: 5.90.2(svelte@4.2.20) '@tanstack/svelte-virtual': - specifier: ^3.13.41 + specifier: ^3.13.42 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1634,7 +1634,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/svelte-virtual': - specifier: ^3.13.41 + specifier: ^3.13.42 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1665,7 +1665,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/svelte-virtual': - specifier: ^3.13.41 + specifier: ^3.13.42 version: link:../../../packages/svelte-virtual lodash: specifier: ^4.17.21 @@ -1702,7 +1702,7 @@ importers: specifier: ^8.21.3 version: 8.21.3(svelte@4.2.20) '@tanstack/svelte-virtual': - specifier: ^3.13.41 + specifier: ^3.13.42 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1733,7 +1733,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/vue-virtual': - specifier: ^3.13.41 + specifier: ^3.13.42 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1758,7 +1758,7 @@ importers: examples/vue/fixed: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.41 + specifier: ^3.13.42 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1786,7 +1786,7 @@ importers: specifier: ^5.80.7 version: 5.90.5(vue@3.5.22(typescript@5.9.3)) '@tanstack/vue-virtual': - specifier: ^3.13.41 + specifier: ^3.13.42 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1811,7 +1811,7 @@ importers: examples/vue/padding: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.41 + specifier: ^3.13.42 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1836,7 +1836,7 @@ importers: examples/vue/scroll-padding: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.41 + specifier: ^3.13.42 version: link:../../../packages/vue-virtual '@vueuse/core': specifier: ^12.8.2 @@ -1864,7 +1864,7 @@ importers: examples/vue/smooth-scroll: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.41 + specifier: ^3.13.42 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1892,7 +1892,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/vue-virtual': - specifier: ^3.13.41 + specifier: ^3.13.42 version: link:../../../packages/vue-virtual lodash: specifier: ^4.17.21 @@ -1929,7 +1929,7 @@ importers: specifier: ^8.21.3 version: 8.21.3(vue@3.5.22(typescript@5.9.3)) '@tanstack/vue-virtual': - specifier: ^3.13.41 + specifier: ^3.13.42 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1954,7 +1954,7 @@ importers: examples/vue/variable: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.41 + specifier: ^3.13.42 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 From cb510cbb50a3e10eaaa7ff8c3c51f320d1224c79 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Mon, 12 Oct 2026 13:20:01 +0200 Subject: [PATCH 08/46] refactor(virtual-core): describe an in-flight scroll command by one phase (#1311) --- .changeset/scroll-state-phase.md | 5 + .../e2e/app/ios-deferral/main.tsx | 4 +- packages/virtual-core/src/index.ts | 91 ++++++++++++------- packages/virtual-core/tests/index.test.ts | 2 +- 4 files changed, 66 insertions(+), 36 deletions(-) create mode 100644 .changeset/scroll-state-phase.md diff --git a/.changeset/scroll-state-phase.md b/.changeset/scroll-state-phase.md new file mode 100644 index 000000000..9625e6a30 --- /dev/null +++ b/.changeset/scroll-state-phase.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +refactor(virtual-core): track an in-flight scroll command's lifecycle as one phase instead of several flags (no behaviour change) diff --git a/packages/react-virtual/e2e/app/ios-deferral/main.tsx b/packages/react-virtual/e2e/app/ios-deferral/main.tsx index 3fe72768b..d5cf7f0fb 100644 --- a/packages/react-virtual/e2e/app/ios-deferral/main.tsx +++ b/packages/react-virtual/e2e/app/ios-deferral/main.tsx @@ -54,9 +54,7 @@ const state = () => { entry.def = v._iosDeferredAdjustment entry.adj = v.scrollAdjustments const s = v.scrollState - entry.st = s - ? [s.index, s.lastTargetOffset, s.hasReachedTarget, s.stableFrames] - : null + entry.st = s ? [s.index, s.lastTargetOffset, s.phase, s.stableFrames] : null if (t.watch.length > 0 && el) { const m = v.getMeasurements() entry.rows = t.watch.map((i: number) => m[i].start - el.scrollTop) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 4720c705c..44c39e2b0 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -388,11 +388,19 @@ export interface VirtualizerOptions< useCachedMeasurements?: boolean } +// Where an in-flight scroll command is in its lifecycle, which decides how +// its scroll events and measurements are read: +// smooth a smooth animation is travelling to the target +// smoothLanded a smooth scroll is at its target, waiting to retire +// smoothFinishing a smooth scroll switched to instant writes for the +// final approach; its animation can still report positions +// instant instant writes +type ScrollPhase = 'smooth' | 'smoothLanded' | 'smoothFinishing' | 'instant' + type ScrollState = { // what we want index: number | null align: ScrollAlignment - behavior: ScrollBehavior // scrollToEnd: track the DOM max scroll offset instead of the item's offset toEnd?: boolean @@ -404,12 +412,7 @@ type ScrollState = { // settling stableFrames: number - // Keep arrival across measurement-driven retargets so external scrolls can cancel. - hasReachedTarget: boolean - // The command asked for a smooth scroll. Unlike `behavior`, which reconcile - // switches to 'auto' for the final approach, this stays set: the smooth - // animation can still deliver scroll events after that switch. - startedSmooth: boolean + phase: ScrollPhase // Where our last non-smooth write for this command put the element, until // its scroll event arrives. Until then `scrollOffset` still holds the old @@ -420,6 +423,22 @@ type ScrollState = { writtenOffset?: number | null } +const initialScrollPhase = ( + behavior: ScrollBehavior, + atTarget: boolean, +): ScrollPhase => + behavior === 'smooth' ? (atTarget ? 'smoothLanded' : 'smooth') : 'instant' + +// A smooth scroll owns the position: size compensation, the prepend anchor +// sync and far measurements hold off until it switches to instant writes. +const isSmoothScroll = (state: ScrollState | null): state is ScrollState => + state?.phase === 'smooth' || state?.phase === 'smoothLanded' + +// The smooth animation may still be reporting positions, so a scroll event +// that isn't the read-back of our own write may not be the user. +const isAnimating = (state: ScrollState) => + state.phase === 'smooth' || state.phase === 'smoothFinishing' + type PendingScrollAnchor = [ key: Key | null, offset: number, @@ -984,10 +1003,9 @@ export class Virtualizer< // like the user and the scroll stops at the max. That needs the // sizer re-rendered before the scroll event, which browsers have // not been seen to do (pinned by a `.fails` test). - // A smooth scroll's own animation events look like a user's, so - // it only yields once it has reached the target. - (this.scrollState.hasReachedTarget || - !this.scrollState.startedSmooth) && + // While a smooth scroll's animation may still report, its events + // look like a user's, so it yields only once it has landed. + !isAnimating(this.scrollState) && !approxEqual(offset, this.scrollState.lastTargetOffset) && !approxEqual(offset, this.getScrollStateTarget(this.scrollState)) && (this._clampedAdjustment === null || @@ -1120,7 +1138,7 @@ export class Virtualizer< this._iosDeferredAdjustment += anchorDelta } } else if ( - this.scrollState?.behavior === 'smooth' && + isSmoothScroll(this.scrollState) && !approxEqual( this.getScrollOffset() - anchorDelta, this.scrollState.lastTargetOffset, @@ -1270,7 +1288,13 @@ export class Virtualizer< const targetChanged = targetOffset !== this.scrollState.lastTargetOffset if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) { - this.scrollState.hasReachedTarget = true + // At the target: a travelling smooth scroll has landed, and a + // finishing one is now an instant scroll. + if (this.scrollState.phase === 'smooth') { + this.scrollState.phase = 'smoothLanded' + } else if (this.scrollState.phase === 'smoothFinishing') { + this.scrollState.phase = 'instant' + } this.scrollState.stableFrames++ if (this.scrollState.stableFrames >= STABLE_FRAMES) { // Final-pass exact landing. The reconcile-stable check uses a 1.01px @@ -1302,16 +1326,21 @@ export class Virtualizer< const viewport = this.getSize() || 600 const distance = Math.abs(targetOffset - this.getScrollOffset()) const keepSmooth = - this.scrollState.behavior === 'smooth' && distance > viewport + isSmoothScroll(this.scrollState) && distance > viewport this.scrollState.lastTargetOffset = targetOffset if (keepSmooth) { // A smooth retarget travels again, so its intermediate scroll // events must not read as an external scroll away from the target. - this.scrollState.hasReachedTarget = false - } else { - this.scrollState.behavior = 'auto' + this.scrollState.phase = 'smooth' + } else if (this.scrollState.phase === 'smooth') { + // The final approach is instant, but the animation can still report. + this.scrollState.phase = 'smoothFinishing' + } else if (this.scrollState.phase === 'smoothLanded') { + this.scrollState.phase = 'instant' } + // 'smoothFinishing' stays as it is (its animation may still report) + // until reconcile sees it at the target; 'instant' stays instant. this._scrollToOffset(targetOffset, { adjustments: undefined, @@ -1713,7 +1742,7 @@ export class Virtualizer< */ private shouldMeasureDuringScroll = (index: number): boolean => { // No scroll state or not smooth scroll - always allow measurements - if (!this.scrollState || this.scrollState.behavior !== 'smooth') { + if (!isSmoothScroll(this.scrollState)) { return true } @@ -1815,7 +1844,7 @@ export class Virtualizer< if (delta !== 0) { const wasAtEnd = this.options.anchorTo === 'end' && - this.scrollState?.behavior !== 'smooth' && + !isSmoothScroll(this.scrollState) && this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold const prevTotalSize = wasAtEnd ? this.getTotalSize() : 0 // Default anchoring predicate (used unless the consumer supplies a @@ -1838,7 +1867,7 @@ export class Virtualizer< itemStart + itemSize <= scrollOffsetWithAdj && this.scrollDirection !== 'backward' const shouldAdjustScroll = - this.scrollState?.behavior !== 'smooth' && + !isSmoothScroll(this.scrollState) && (this.shouldAdjustScrollPositionOnItemSizeChange !== undefined ? this.shouldAdjustScrollPositionOnItemSizeChange( // The callback expects a VirtualItem; build one lazily only @@ -2076,12 +2105,10 @@ export class Virtualizer< this.scrollState = { index: null, align, - behavior, startedAt: now, lastTargetOffset: offset, stableFrames: 0, - hasReachedTarget: false, - startedSmooth: behavior === 'smooth', + phase: initialScrollPhase(behavior, false), } this._scrollToOffset(offset, { adjustments: undefined, behavior }) @@ -2112,12 +2139,13 @@ export class Virtualizer< this.scrollState = { index, align, - behavior, startedAt: now, lastTargetOffset: offset, stableFrames: 0, - hasReachedTarget: approxEqual(offset, this.getScrollOffset()), - startedSmooth: behavior === 'smooth', + phase: initialScrollPhase( + behavior, + approxEqual(offset, this.getScrollOffset()), + ), } this._scrollToOffset(offset, { adjustments: undefined, behavior }) @@ -2141,12 +2169,10 @@ export class Virtualizer< this.scrollState = { index: null, align: 'start', - behavior, startedAt: now, lastTargetOffset: offset, stableFrames: 0, - hasReachedTarget: false, - startedSmooth: behavior === 'smooth', + phase: initialScrollPhase(behavior, false), } this._scrollToOffset(offset, { adjustments: undefined, behavior }) @@ -2165,13 +2191,14 @@ export class Virtualizer< this.scrollState = { index: this.options.count - 1, align: 'end', - behavior, toEnd: true, startedAt: this.now(), lastTargetOffset: offset, stableFrames: 0, - hasReachedTarget: approxEqual(offset, this.getScrollOffset()), - startedSmooth: behavior === 'smooth', + phase: initialScrollPhase( + behavior, + approxEqual(offset, this.getScrollOffset()), + ), } this._scrollToOffset(offset, { adjustments: undefined, behavior }) diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 17066043e..97b907390 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -5018,7 +5018,7 @@ test('a prepend mid-flight leaves a travelling smooth scrollToIndex alone', () = // 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, behavior: 'smooth' }) + expect(v['scrollState']).toMatchObject({ index: 0, phase: 'smooth' }) }) test('a prepend right after a smooth scroll landed still syncs the anchor', () => { From 3800fb3e76aa7e86bc8a8eba7ddde84aef3066eb Mon Sep 17 00:00:00 2001 From: Andreas Osberghaus <148758+mrosberghaus@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:46:30 +0200 Subject: [PATCH 09/46] fix(virtual-core): treat iOS touchcancel like touchend so interrupted gestures cannot leave touch state stuck (#1314) --- .changeset/ios-touchcancel-clears-touch.md | 5 +++ packages/virtual-core/src/index.ts | 15 +++++++-- packages/virtual-core/tests/index.test.ts | 38 +++++++++++++++++++++- 3 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 .changeset/ios-touchcancel-clears-touch.md diff --git a/.changeset/ios-touchcancel-clears-touch.md b/.changeset/ios-touchcancel-clears-touch.md new file mode 100644 index 000000000..36e3c49da --- /dev/null +++ b/.changeset/ios-touchcancel-clears-touch.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): treat iOS `touchcancel` like `touchend` so interrupted gestures cannot leave touch state stuck and keep scroll anchoring suppressed diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 44c39e2b0..50c0f118d 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -512,9 +512,10 @@ export class Virtualizer< private _iosDeferredAdjustment = 0 // Touch state. iOS WebKit cancels momentum when scrollTop is written, so // we defer adjustments not only during `isScrolling` but also through the - // touchstart→touchend window (active drag) and a short tail after - // touchend (early-momentum window — iOS only fires touch events once at - // the start of momentum, so we use a timer rather than another event). + // touchstart→touchend/touchcancel window (active drag) and a short tail + // after the touch ends (early-momentum window — iOS only fires touch + // events once at the start of momentum, so we use a timer rather than + // another event). private _iosTouching = false private _iosJustTouchEnded = false private _iosTouchEndTimerId: number | null = null @@ -1098,9 +1099,17 @@ export class Virtualizer< onTouchEnd, addEventListenerOptions, ) + // Same handler as touchend: iOS can end a touch with touchcancel + // (system gesture, interrupted scroll) and never fire touchend. + scrollEl.addEventListener( + 'touchcancel', + onTouchEnd, + addEventListenerOptions, + ) this.unsubs.push(() => { scrollEl.removeEventListener('touchstart', onTouchStart) scrollEl.removeEventListener('touchend', onTouchEnd) + scrollEl.removeEventListener('touchcancel', onTouchEnd) if (this._iosTouchEndTimerId !== null && this.targetWindow != null) { this.targetWindow.clearTimeout(this._iosTouchEndTimerId) this._iosTouchEndTimerId = null diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 97b907390..60fd7ec0f 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -2662,6 +2662,9 @@ function makeMockScrollElement(props: Record) { _dispatch(name: string) { listeners.get(name)?.forEach((fn) => fn({} as Event)) }, + _hasListener(name: string) { + return (listeners.get(name)?.size ?? 0) > 0 + }, } as any } @@ -2693,7 +2696,10 @@ function makeIOSVirtualizerWithRealEl( return { v, el } } -function dispatchTouchEvent(el: any, type: 'touchstart' | 'touchend') { +function dispatchTouchEvent( + el: any, + type: 'touchstart' | 'touchend' | 'touchcancel', +) { el._dispatch(type) } @@ -2711,6 +2717,36 @@ test('iOS Phase 1: touchstart sets _iosTouching=true and clears justTouchEnded', }) }) +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['_iosTouching']).toBe(true) + dispatchTouchEvent(el, 'touchcancel') + expect(v['_iosTouching']).toBe(false) + expect(v['_iosJustTouchEnded']).toBe(true) + expect(v['_iosTouchEndTimerId']).not.toBeNull() + + 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 From 7c20899ba738f898a95a259390bf189b5fc1e6b5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:08:43 +0200 Subject: [PATCH 10/46] ci: Version Packages (#1315) --- .changeset/ios-touchcancel-clears-touch.md | 5 - .changeset/scroll-state-phase.md | 5 - benchmarks/CHANGELOG.md | 7 + benchmarks/package.json | 2 +- examples/angular/chat/package.json | 2 +- examples/angular/dynamic/package.json | 2 +- examples/angular/fixed/package.json | 2 +- examples/angular/infinite-scroll/package.json | 2 +- examples/angular/padding/package.json | 2 +- examples/angular/smooth-scroll/package.json | 2 +- examples/angular/sticky/package.json | 2 +- examples/angular/table/package.json | 2 +- examples/angular/variable/package.json | 2 +- examples/angular/window/package.json | 2 +- examples/lit/dynamic/package.json | 4 +- examples/lit/fixed/package.json | 4 +- examples/marko/chat-pretext/package.json | 2 +- examples/marko/chat/package.json | 2 +- examples/marko/dynamic/package.json | 2 +- examples/marko/fixed/package.json | 2 +- examples/marko/grid/package.json | 2 +- examples/marko/infinite-scroll/package.json | 2 +- examples/marko/padding/package.json | 2 +- examples/marko/pretext/package.json | 2 +- examples/marko/scroll-padding/package.json | 2 +- examples/marko/smooth-scroll/package.json | 2 +- examples/marko/ssr-fetch/package.json | 2 +- examples/marko/ssr-restore/package.json | 2 +- examples/marko/ssr-slice/package.json | 2 +- examples/marko/ssr/package.json | 2 +- examples/marko/sticky/package.json | 2 +- examples/marko/table/package.json | 2 +- examples/marko/variable/package.json | 2 +- examples/marko/window-ssr-slice/package.json | 2 +- examples/marko/window/package.json | 2 +- examples/react/chat/package.json | 2 +- examples/react/dynamic/package.json | 2 +- examples/react/fixed/package.json | 2 +- examples/react/infinite-scroll/package.json | 2 +- examples/react/padding/package.json | 2 +- examples/react/pretext/package.json | 2 +- examples/react/scroll-padding/package.json | 2 +- examples/react/smooth-scroll/package.json | 2 +- examples/react/sticky/package.json | 2 +- examples/react/table/package.json | 2 +- examples/react/variable/package.json | 2 +- examples/react/window/package.json | 2 +- examples/svelte/dynamic/package.json | 2 +- examples/svelte/fixed/package.json | 2 +- examples/svelte/infinite-scroll/package.json | 2 +- examples/svelte/smooth-scroll/package.json | 2 +- examples/svelte/sticky/package.json | 2 +- examples/svelte/table/package.json | 2 +- examples/vue/dynamic/package.json | 2 +- examples/vue/fixed/package.json | 2 +- examples/vue/infinite-scroll/package.json | 2 +- examples/vue/padding/package.json | 2 +- examples/vue/scroll-padding/package.json | 2 +- examples/vue/smooth-scroll/package.json | 2 +- examples/vue/sticky/package.json | 2 +- examples/vue/table/package.json | 2 +- examples/vue/variable/package.json | 2 +- packages/angular-virtual/CHANGELOG.md | 7 + packages/angular-virtual/package.json | 2 +- packages/lit-virtual/CHANGELOG.md | 7 + packages/lit-virtual/package.json | 2 +- packages/marko-virtual/CHANGELOG.md | 7 + packages/marko-virtual/package.json | 2 +- packages/react-virtual/CHANGELOG.md | 7 + packages/react-virtual/package.json | 2 +- packages/solid-virtual/CHANGELOG.md | 7 + packages/solid-virtual/package.json | 2 +- packages/svelte-virtual/CHANGELOG.md | 7 + packages/svelte-virtual/package.json | 2 +- packages/virtual-core/CHANGELOG.md | 8 ++ packages/virtual-core/package.json | 2 +- packages/vue-virtual/CHANGELOG.md | 7 + packages/vue-virtual/package.json | 2 +- pnpm-lock.yaml | 120 +++++++++--------- 79 files changed, 193 insertions(+), 139 deletions(-) delete mode 100644 .changeset/ios-touchcancel-clears-touch.md delete mode 100644 .changeset/scroll-state-phase.md diff --git a/.changeset/ios-touchcancel-clears-touch.md b/.changeset/ios-touchcancel-clears-touch.md deleted file mode 100644 index 36e3c49da..000000000 --- a/.changeset/ios-touchcancel-clears-touch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/virtual-core': patch ---- - -fix(virtual-core): treat iOS `touchcancel` like `touchend` so interrupted gestures cannot leave touch state stuck and keep scroll anchoring suppressed diff --git a/.changeset/scroll-state-phase.md b/.changeset/scroll-state-phase.md deleted file mode 100644 index 9625e6a30..000000000 --- a/.changeset/scroll-state-phase.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/virtual-core': patch ---- - -refactor(virtual-core): track an in-flight scroll command's lifecycle as one phase instead of several flags (no behaviour change) diff --git a/benchmarks/CHANGELOG.md b/benchmarks/CHANGELOG.md index 153b806b0..1cd51a53e 100644 --- a/benchmarks/CHANGELOG.md +++ b/benchmarks/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/virtual-benchmarks +## 0.0.20 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/react-virtual@3.14.17 + ## 0.0.19 ### Patch Changes diff --git a/benchmarks/package.json b/benchmarks/package.json index 7a0f1be80..fcb0b7514 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -1,7 +1,7 @@ { "name": "@tanstack/virtual-benchmarks", "private": true, - "version": "0.0.19", + "version": "0.0.20", "type": "module", "scripts": { "dev": "vite", diff --git a/examples/angular/chat/package.json b/examples/angular/chat/package.json index bc0ebb298..2e68cb346 100644 --- a/examples/angular/chat/package.json +++ b/examples/angular/chat/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.2", + "@tanstack/angular-virtual": "^6.1.3", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/dynamic/package.json b/examples/angular/dynamic/package.json index 72fec1776..c12b3c5ef 100644 --- a/examples/angular/dynamic/package.json +++ b/examples/angular/dynamic/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", "@faker-js/faker": "^8.4.1", - "@tanstack/angular-virtual": "^6.1.2", + "@tanstack/angular-virtual": "^6.1.3", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/fixed/package.json b/examples/angular/fixed/package.json index c2a42f9d6..21e5ad3d9 100644 --- a/examples/angular/fixed/package.json +++ b/examples/angular/fixed/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.2", + "@tanstack/angular-virtual": "^6.1.3", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/infinite-scroll/package.json b/examples/angular/infinite-scroll/package.json index 5cb6b3fd7..12d13f822 100644 --- a/examples/angular/infinite-scroll/package.json +++ b/examples/angular/infinite-scroll/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", "@tanstack/angular-query-experimental": "5.80.7", - "@tanstack/angular-virtual": "^6.1.2", + "@tanstack/angular-virtual": "^6.1.3", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/padding/package.json b/examples/angular/padding/package.json index ae515317b..d720bce97 100644 --- a/examples/angular/padding/package.json +++ b/examples/angular/padding/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.2", + "@tanstack/angular-virtual": "^6.1.3", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/smooth-scroll/package.json b/examples/angular/smooth-scroll/package.json index d467f1ed8..ffbe1bc0c 100644 --- a/examples/angular/smooth-scroll/package.json +++ b/examples/angular/smooth-scroll/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.2", + "@tanstack/angular-virtual": "^6.1.3", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/sticky/package.json b/examples/angular/sticky/package.json index 57a15d963..a982e329e 100644 --- a/examples/angular/sticky/package.json +++ b/examples/angular/sticky/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", "@faker-js/faker": "^8.4.1", - "@tanstack/angular-virtual": "^6.1.2", + "@tanstack/angular-virtual": "^6.1.3", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/table/package.json b/examples/angular/table/package.json index d11f8a84f..c8d3cde31 100644 --- a/examples/angular/table/package.json +++ b/examples/angular/table/package.json @@ -19,7 +19,7 @@ "@angular/router": "^20.2.0", "@faker-js/faker": "^8.4.1", "@tanstack/angular-table": "8.21.3", - "@tanstack/angular-virtual": "^6.1.2", + "@tanstack/angular-virtual": "^6.1.3", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/variable/package.json b/examples/angular/variable/package.json index 93760eb34..6bd3c36c1 100644 --- a/examples/angular/variable/package.json +++ b/examples/angular/variable/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.2", + "@tanstack/angular-virtual": "^6.1.3", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/window/package.json b/examples/angular/window/package.json index d4d982054..8de02ba8a 100644 --- a/examples/angular/window/package.json +++ b/examples/angular/window/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.2", + "@tanstack/angular-virtual": "^6.1.3", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/lit/dynamic/package.json b/examples/lit/dynamic/package.json index f64574c13..c514284c3 100644 --- a/examples/lit/dynamic/package.json +++ b/examples/lit/dynamic/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/lit-virtual": "^3.14.5", - "@tanstack/virtual-core": "^3.19.1", + "@tanstack/lit-virtual": "^3.14.6", + "@tanstack/virtual-core": "^3.19.2", "lit": "^3.3.0" }, "devDependencies": { diff --git a/examples/lit/fixed/package.json b/examples/lit/fixed/package.json index a0d22366d..436df85a8 100644 --- a/examples/lit/fixed/package.json +++ b/examples/lit/fixed/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/lit-virtual": "^3.14.5", - "@tanstack/virtual-core": "^3.19.1", + "@tanstack/lit-virtual": "^3.14.6", + "@tanstack/virtual-core": "^3.19.2", "lit": "^3.3.0" }, "devDependencies": { diff --git a/examples/marko/chat-pretext/package.json b/examples/marko/chat-pretext/package.json index 3ca826c23..e7caaab9d 100644 --- a/examples/marko/chat-pretext/package.json +++ b/examples/marko/chat-pretext/package.json @@ -11,7 +11,7 @@ "dependencies": { "@chenglou/pretext": "^0.0.7", "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/chat/package.json b/examples/marko/chat/package.json index efe45ba7d..aaa8894e5 100644 --- a/examples/marko/chat/package.json +++ b/examples/marko/chat/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/dynamic/package.json b/examples/marko/dynamic/package.json index 6cf60e308..1c767b52c 100644 --- a/examples/marko/dynamic/package.json +++ b/examples/marko/dynamic/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/fixed/package.json b/examples/marko/fixed/package.json index 9abb88d12..7affe0dfc 100644 --- a/examples/marko/fixed/package.json +++ b/examples/marko/fixed/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/grid/package.json b/examples/marko/grid/package.json index 0ee0a6ad3..f2bd17488 100644 --- a/examples/marko/grid/package.json +++ b/examples/marko/grid/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/infinite-scroll/package.json b/examples/marko/infinite-scroll/package.json index 2cf47f872..596218c17 100644 --- a/examples/marko/infinite-scroll/package.json +++ b/examples/marko/infinite-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/padding/package.json b/examples/marko/padding/package.json index 4c5211b98..07c7722c6 100644 --- a/examples/marko/padding/package.json +++ b/examples/marko/padding/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/pretext/package.json b/examples/marko/pretext/package.json index abac525d3..5dadfb1ec 100644 --- a/examples/marko/pretext/package.json +++ b/examples/marko/pretext/package.json @@ -11,7 +11,7 @@ "dependencies": { "@chenglou/pretext": "^0.0.7", "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/scroll-padding/package.json b/examples/marko/scroll-padding/package.json index f66b5cbec..a5bdf47f1 100644 --- a/examples/marko/scroll-padding/package.json +++ b/examples/marko/scroll-padding/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/smooth-scroll/package.json b/examples/marko/smooth-scroll/package.json index af3eefdb4..447f25423 100644 --- a/examples/marko/smooth-scroll/package.json +++ b/examples/marko/smooth-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr-fetch/package.json b/examples/marko/ssr-fetch/package.json index d60d61ab0..664223d0c 100644 --- a/examples/marko/ssr-fetch/package.json +++ b/examples/marko/ssr-fetch/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr-restore/package.json b/examples/marko/ssr-restore/package.json index 4faddddf4..0bfaba423 100644 --- a/examples/marko/ssr-restore/package.json +++ b/examples/marko/ssr-restore/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr-slice/package.json b/examples/marko/ssr-slice/package.json index b73a21d7f..7edbef812 100644 --- a/examples/marko/ssr-slice/package.json +++ b/examples/marko/ssr-slice/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr/package.json b/examples/marko/ssr/package.json index 3723dee3b..45bc3886f 100644 --- a/examples/marko/ssr/package.json +++ b/examples/marko/ssr/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/sticky/package.json b/examples/marko/sticky/package.json index db34341e3..f681f51eb 100644 --- a/examples/marko/sticky/package.json +++ b/examples/marko/sticky/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/table/package.json b/examples/marko/table/package.json index 3eadb0ba4..e21385598 100644 --- a/examples/marko/table/package.json +++ b/examples/marko/table/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/variable/package.json b/examples/marko/variable/package.json index 419bdb875..5173d26bd 100644 --- a/examples/marko/variable/package.json +++ b/examples/marko/variable/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/window-ssr-slice/package.json b/examples/marko/window-ssr-slice/package.json index 2b6188a07..af7a6a41b 100644 --- a/examples/marko/window-ssr-slice/package.json +++ b/examples/marko/window-ssr-slice/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/window/package.json b/examples/marko/window/package.json index 48bd814b6..af2059bc8 100644 --- a/examples/marko/window/package.json +++ b/examples/marko/window/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.2", + "@tanstack/marko-virtual": "^3.16.3", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/react/chat/package.json b/examples/react/chat/package.json index e55ff1321..7e53bf135 100644 --- a/examples/react/chat/package.json +++ b/examples/react/chat/package.json @@ -8,7 +8,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.16", + "@tanstack/react-virtual": "^3.14.17", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/dynamic/package.json b/examples/react/dynamic/package.json index 6b93d29e9..6d157cbfc 100644 --- a/examples/react/dynamic/package.json +++ b/examples/react/dynamic/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/react-virtual": "^3.14.16", + "@tanstack/react-virtual": "^3.14.17", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/fixed/package.json b/examples/react/fixed/package.json index 7c2ed6a81..f35e42ddf 100644 --- a/examples/react/fixed/package.json +++ b/examples/react/fixed/package.json @@ -8,7 +8,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.16", + "@tanstack/react-virtual": "^3.14.17", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/infinite-scroll/package.json b/examples/react/infinite-scroll/package.json index 774a916a1..80a16c001 100644 --- a/examples/react/infinite-scroll/package.json +++ b/examples/react/infinite-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.80.7", - "@tanstack/react-virtual": "^3.14.16", + "@tanstack/react-virtual": "^3.14.17", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/padding/package.json b/examples/react/padding/package.json index d360b54e0..f66c55b07 100644 --- a/examples/react/padding/package.json +++ b/examples/react/padding/package.json @@ -9,7 +9,7 @@ "start": "vite" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.16", + "@tanstack/react-virtual": "^3.14.17", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/pretext/package.json b/examples/react/pretext/package.json index 682b4ede4..d49b3467e 100644 --- a/examples/react/pretext/package.json +++ b/examples/react/pretext/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@chenglou/pretext": "^0.0.7", - "@tanstack/react-virtual": "^3.14.16", + "@tanstack/react-virtual": "^3.14.17", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/scroll-padding/package.json b/examples/react/scroll-padding/package.json index 53d56ec87..750461085 100644 --- a/examples/react/scroll-padding/package.json +++ b/examples/react/scroll-padding/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@react-hookz/web": "^25.1.1", - "@tanstack/react-virtual": "^3.14.16", + "@tanstack/react-virtual": "^3.14.17", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/smooth-scroll/package.json b/examples/react/smooth-scroll/package.json index 8f15d12f0..3e49051b7 100644 --- a/examples/react/smooth-scroll/package.json +++ b/examples/react/smooth-scroll/package.json @@ -9,7 +9,7 @@ "start": "vite" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.16", + "@tanstack/react-virtual": "^3.14.17", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/sticky/package.json b/examples/react/sticky/package.json index c388ba2af..ba92a154c 100644 --- a/examples/react/sticky/package.json +++ b/examples/react/sticky/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/react-virtual": "^3.14.16", + "@tanstack/react-virtual": "^3.14.17", "lodash": "^4.17.21", "react": "^19.2.7", "react-dom": "^19.2.7" diff --git a/examples/react/table/package.json b/examples/react/table/package.json index 272c91ba2..6a77ae747 100644 --- a/examples/react/table/package.json +++ b/examples/react/table/package.json @@ -11,7 +11,7 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.14.16", + "@tanstack/react-virtual": "^3.14.17", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/variable/package.json b/examples/react/variable/package.json index 7e6a03438..dfef8c9a0 100644 --- a/examples/react/variable/package.json +++ b/examples/react/variable/package.json @@ -9,7 +9,7 @@ "start": "vite" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.16", + "@tanstack/react-virtual": "^3.14.17", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/window/package.json b/examples/react/window/package.json index c7efeefa1..e971f0d62 100644 --- a/examples/react/window/package.json +++ b/examples/react/window/package.json @@ -8,7 +8,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.16", + "@tanstack/react-virtual": "^3.14.17", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/svelte/dynamic/package.json b/examples/svelte/dynamic/package.json index bd528b8f5..a05078641 100644 --- a/examples/svelte/dynamic/package.json +++ b/examples/svelte/dynamic/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/svelte-virtual": "^3.13.42" + "@tanstack/svelte-virtual": "^3.13.43" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/fixed/package.json b/examples/svelte/fixed/package.json index 5f13b4f93..cbe3ec261 100644 --- a/examples/svelte/fixed/package.json +++ b/examples/svelte/fixed/package.json @@ -9,7 +9,7 @@ "check": "svelte-check --tsconfig ./tsconfig.json" }, "dependencies": { - "@tanstack/svelte-virtual": "^3.13.42" + "@tanstack/svelte-virtual": "^3.13.43" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/infinite-scroll/package.json b/examples/svelte/infinite-scroll/package.json index c0968e9d5..877239938 100644 --- a/examples/svelte/infinite-scroll/package.json +++ b/examples/svelte/infinite-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@tanstack/svelte-query": "^5.80.7", - "@tanstack/svelte-virtual": "^3.13.42" + "@tanstack/svelte-virtual": "^3.13.43" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/smooth-scroll/package.json b/examples/svelte/smooth-scroll/package.json index e0c23dbe1..dd33fa081 100644 --- a/examples/svelte/smooth-scroll/package.json +++ b/examples/svelte/smooth-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/svelte-virtual": "^3.13.42" + "@tanstack/svelte-virtual": "^3.13.43" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/sticky/package.json b/examples/svelte/sticky/package.json index 38f735043..064ddf61f 100644 --- a/examples/svelte/sticky/package.json +++ b/examples/svelte/sticky/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/svelte-virtual": "^3.13.42", + "@tanstack/svelte-virtual": "^3.13.43", "lodash": "^4.17.21" }, "devDependencies": { diff --git a/examples/svelte/table/package.json b/examples/svelte/table/package.json index 36f3be4ef..86ebdc02d 100644 --- a/examples/svelte/table/package.json +++ b/examples/svelte/table/package.json @@ -11,7 +11,7 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@tanstack/svelte-table": "^8.21.3", - "@tanstack/svelte-virtual": "^3.13.42" + "@tanstack/svelte-virtual": "^3.13.43" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/vue/dynamic/package.json b/examples/vue/dynamic/package.json index cae4f9c49..436edabc9 100644 --- a/examples/vue/dynamic/package.json +++ b/examples/vue/dynamic/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/vue-virtual": "^3.13.42", + "@tanstack/vue-virtual": "^3.13.43", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/fixed/package.json b/examples/vue/fixed/package.json index 66b4e842c..9a725780b 100644 --- a/examples/vue/fixed/package.json +++ b/examples/vue/fixed/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.42", + "@tanstack/vue-virtual": "^3.13.43", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/infinite-scroll/package.json b/examples/vue/infinite-scroll/package.json index e080aa1d0..0936671a0 100644 --- a/examples/vue/infinite-scroll/package.json +++ b/examples/vue/infinite-scroll/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@tanstack/vue-query": "^5.80.7", - "@tanstack/vue-virtual": "^3.13.42", + "@tanstack/vue-virtual": "^3.13.43", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/padding/package.json b/examples/vue/padding/package.json index 573d84276..f16708156 100644 --- a/examples/vue/padding/package.json +++ b/examples/vue/padding/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.42", + "@tanstack/vue-virtual": "^3.13.43", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/scroll-padding/package.json b/examples/vue/scroll-padding/package.json index 8a126e3af..20f347d2e 100644 --- a/examples/vue/scroll-padding/package.json +++ b/examples/vue/scroll-padding/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.42", + "@tanstack/vue-virtual": "^3.13.43", "@vueuse/core": "^12.8.2", "vue": "^3.5.16" }, diff --git a/examples/vue/smooth-scroll/package.json b/examples/vue/smooth-scroll/package.json index 9af0e9e26..b7c364ec9 100644 --- a/examples/vue/smooth-scroll/package.json +++ b/examples/vue/smooth-scroll/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.42", + "@tanstack/vue-virtual": "^3.13.43", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/sticky/package.json b/examples/vue/sticky/package.json index fe66b47f7..f8584ba31 100644 --- a/examples/vue/sticky/package.json +++ b/examples/vue/sticky/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/vue-virtual": "^3.13.42", + "@tanstack/vue-virtual": "^3.13.43", "lodash": "^4.17.21", "vue": "^3.5.16" }, diff --git a/examples/vue/table/package.json b/examples/vue/table/package.json index c19103562..853d8a0b4 100644 --- a/examples/vue/table/package.json +++ b/examples/vue/table/package.json @@ -10,7 +10,7 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@tanstack/vue-table": "^8.21.3", - "@tanstack/vue-virtual": "^3.13.42", + "@tanstack/vue-virtual": "^3.13.43", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/variable/package.json b/examples/vue/variable/package.json index 25836d93c..28dad187d 100644 --- a/examples/vue/variable/package.json +++ b/examples/vue/variable/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.42", + "@tanstack/vue-virtual": "^3.13.43", "vue": "^3.5.16" }, "devDependencies": { diff --git a/packages/angular-virtual/CHANGELOG.md b/packages/angular-virtual/CHANGELOG.md index 28582fd6a..b65edcb29 100644 --- a/packages/angular-virtual/CHANGELOG.md +++ b/packages/angular-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/angular-virtual +## 6.1.3 + +### Patch Changes + +- Updated dependencies [[`3800fb3`](https://github.com/TanStack/virtual/commit/3800fb3e76aa7e86bc8a8eba7ddde84aef3066eb), [`cb510cb`](https://github.com/TanStack/virtual/commit/cb510cbb50a3e10eaaa7ff8c3c51f320d1224c79)]: + - @tanstack/virtual-core@3.19.2 + ## 6.1.2 ### Patch Changes diff --git a/packages/angular-virtual/package.json b/packages/angular-virtual/package.json index a48c78edb..29180e3ac 100644 --- a/packages/angular-virtual/package.json +++ b/packages/angular-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/angular-virtual", - "version": "6.1.2", + "version": "6.1.3", "description": "Headless UI for virtualizing scrollable elements in Angular", "author": "Garrett Darnell", "license": "MIT", diff --git a/packages/lit-virtual/CHANGELOG.md b/packages/lit-virtual/CHANGELOG.md index cd5678df3..4dd0aaec4 100644 --- a/packages/lit-virtual/CHANGELOG.md +++ b/packages/lit-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/lit-virtual +## 3.14.6 + +### Patch Changes + +- Updated dependencies [[`3800fb3`](https://github.com/TanStack/virtual/commit/3800fb3e76aa7e86bc8a8eba7ddde84aef3066eb), [`cb510cb`](https://github.com/TanStack/virtual/commit/cb510cbb50a3e10eaaa7ff8c3c51f320d1224c79)]: + - @tanstack/virtual-core@3.19.2 + ## 3.14.5 ### Patch Changes diff --git a/packages/lit-virtual/package.json b/packages/lit-virtual/package.json index a670f93ab..113fefa5c 100644 --- a/packages/lit-virtual/package.json +++ b/packages/lit-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/lit-virtual", - "version": "3.14.5", + "version": "3.14.6", "description": "Headless UI for virtualizing scrollable elements in Lit", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/marko-virtual/CHANGELOG.md b/packages/marko-virtual/CHANGELOG.md index c4f42b035..354235b9c 100644 --- a/packages/marko-virtual/CHANGELOG.md +++ b/packages/marko-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/marko-virtual +## 3.16.3 + +### Patch Changes + +- Updated dependencies [[`3800fb3`](https://github.com/TanStack/virtual/commit/3800fb3e76aa7e86bc8a8eba7ddde84aef3066eb), [`cb510cb`](https://github.com/TanStack/virtual/commit/cb510cbb50a3e10eaaa7ff8c3c51f320d1224c79)]: + - @tanstack/virtual-core@3.19.2 + ## 3.16.2 ### Patch Changes diff --git a/packages/marko-virtual/package.json b/packages/marko-virtual/package.json index 0aea9312f..b666c28b3 100644 --- a/packages/marko-virtual/package.json +++ b/packages/marko-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/marko-virtual", - "version": "3.16.2", + "version": "3.16.3", "description": "Headless UI for virtualizing scrollable elements in Marko 6", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/react-virtual/CHANGELOG.md b/packages/react-virtual/CHANGELOG.md index 789b82292..a9ca6cd6a 100644 --- a/packages/react-virtual/CHANGELOG.md +++ b/packages/react-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/react-virtual +## 3.14.17 + +### Patch Changes + +- Updated dependencies [[`3800fb3`](https://github.com/TanStack/virtual/commit/3800fb3e76aa7e86bc8a8eba7ddde84aef3066eb), [`cb510cb`](https://github.com/TanStack/virtual/commit/cb510cbb50a3e10eaaa7ff8c3c51f320d1224c79)]: + - @tanstack/virtual-core@3.19.2 + ## 3.14.16 ### Patch Changes diff --git a/packages/react-virtual/package.json b/packages/react-virtual/package.json index 23a08a0bf..4449672d2 100644 --- a/packages/react-virtual/package.json +++ b/packages/react-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/react-virtual", - "version": "3.14.16", + "version": "3.14.17", "description": "Headless UI for virtualizing scrollable elements in React", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/solid-virtual/CHANGELOG.md b/packages/solid-virtual/CHANGELOG.md index d8a293668..b497f876d 100644 --- a/packages/solid-virtual/CHANGELOG.md +++ b/packages/solid-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/solid-virtual +## 3.13.44 + +### Patch Changes + +- Updated dependencies [[`3800fb3`](https://github.com/TanStack/virtual/commit/3800fb3e76aa7e86bc8a8eba7ddde84aef3066eb), [`cb510cb`](https://github.com/TanStack/virtual/commit/cb510cbb50a3e10eaaa7ff8c3c51f320d1224c79)]: + - @tanstack/virtual-core@3.19.2 + ## 3.13.43 ### Patch Changes diff --git a/packages/solid-virtual/package.json b/packages/solid-virtual/package.json index cfb9ae822..ec1e09c74 100644 --- a/packages/solid-virtual/package.json +++ b/packages/solid-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/solid-virtual", - "version": "3.13.43", + "version": "3.13.44", "description": "Headless UI for virtualizing scrollable elements in Solid", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/svelte-virtual/CHANGELOG.md b/packages/svelte-virtual/CHANGELOG.md index 02a0a540c..84d569db3 100644 --- a/packages/svelte-virtual/CHANGELOG.md +++ b/packages/svelte-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/svelte-virtual +## 3.13.43 + +### Patch Changes + +- Updated dependencies [[`3800fb3`](https://github.com/TanStack/virtual/commit/3800fb3e76aa7e86bc8a8eba7ddde84aef3066eb), [`cb510cb`](https://github.com/TanStack/virtual/commit/cb510cbb50a3e10eaaa7ff8c3c51f320d1224c79)]: + - @tanstack/virtual-core@3.19.2 + ## 3.13.42 ### Patch Changes diff --git a/packages/svelte-virtual/package.json b/packages/svelte-virtual/package.json index bc146dd1a..5eb982101 100644 --- a/packages/svelte-virtual/package.json +++ b/packages/svelte-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/svelte-virtual", - "version": "3.13.42", + "version": "3.13.43", "description": "Headless UI for virtualizing scrollable elements in Svelte", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/virtual-core/CHANGELOG.md b/packages/virtual-core/CHANGELOG.md index 867c948ba..c393af0b4 100644 --- a/packages/virtual-core/CHANGELOG.md +++ b/packages/virtual-core/CHANGELOG.md @@ -1,5 +1,13 @@ # @tanstack/virtual-core +## 3.19.2 + +### Patch Changes + +- [#1314](https://github.com/TanStack/virtual/pull/1314) [`3800fb3`](https://github.com/TanStack/virtual/commit/3800fb3e76aa7e86bc8a8eba7ddde84aef3066eb) - fix(virtual-core): treat iOS `touchcancel` like `touchend` so interrupted gestures cannot leave touch state stuck and keep scroll anchoring suppressed + +- [#1311](https://github.com/TanStack/virtual/pull/1311) [`cb510cb`](https://github.com/TanStack/virtual/commit/cb510cbb50a3e10eaaa7ff8c3c51f320d1224c79) - refactor(virtual-core): track an in-flight scroll command's lifecycle as one phase instead of several flags (no behaviour change) + ## 3.19.1 ### Patch Changes diff --git a/packages/virtual-core/package.json b/packages/virtual-core/package.json index 12b5f1783..77010e67e 100644 --- a/packages/virtual-core/package.json +++ b/packages/virtual-core/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/virtual-core", - "version": "3.19.1", + "version": "3.19.2", "description": "Headless UI for virtualizing scrollable elements in TS/JS + Frameworks", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/vue-virtual/CHANGELOG.md b/packages/vue-virtual/CHANGELOG.md index 9df595971..59948a7f7 100644 --- a/packages/vue-virtual/CHANGELOG.md +++ b/packages/vue-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/vue-virtual +## 3.13.43 + +### Patch Changes + +- Updated dependencies [[`3800fb3`](https://github.com/TanStack/virtual/commit/3800fb3e76aa7e86bc8a8eba7ddde84aef3066eb), [`cb510cb`](https://github.com/TanStack/virtual/commit/cb510cbb50a3e10eaaa7ff8c3c51f320d1224c79)]: + - @tanstack/virtual-core@3.19.2 + ## 3.13.42 ### Patch Changes diff --git a/packages/vue-virtual/package.json b/packages/vue-virtual/package.json index f5b9daed3..7ad377c0e 100644 --- a/packages/vue-virtual/package.json +++ b/packages/vue-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/vue-virtual", - "version": "3.13.42", + "version": "3.13.43", "description": "Headless UI for virtualizing scrollable elements in Vue", "author": "Tanner Linsley", "license": "MIT", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52f848c25..b78d8b9a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,7 +145,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.2 + specifier: ^6.1.3 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -200,7 +200,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/angular-virtual': - specifier: ^6.1.2 + specifier: ^6.1.3 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -252,7 +252,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.2 + specifier: ^6.1.3 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -307,7 +307,7 @@ importers: specifier: 5.80.7 version: 5.80.7(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)) '@tanstack/angular-virtual': - specifier: ^6.1.2 + specifier: ^6.1.3 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -359,7 +359,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.2 + specifier: ^6.1.3 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -411,7 +411,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.2 + specifier: ^6.1.3 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -466,7 +466,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/angular-virtual': - specifier: ^6.1.2 + specifier: ^6.1.3 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -524,7 +524,7 @@ importers: specifier: 8.21.3 version: 8.21.3(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)) '@tanstack/angular-virtual': - specifier: ^6.1.2 + specifier: ^6.1.3 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -576,7 +576,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.2 + specifier: ^6.1.3 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -628,7 +628,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.2 + specifier: ^6.1.3 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -659,10 +659,10 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/lit-virtual': - specifier: ^3.14.5 + specifier: ^3.14.6 version: link:../../../packages/lit-virtual '@tanstack/virtual-core': - specifier: ^3.19.1 + specifier: ^3.19.2 version: link:../../../packages/virtual-core lit: specifier: ^3.3.0 @@ -684,10 +684,10 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/lit-virtual': - specifier: ^3.14.5 + specifier: ^3.14.6 version: link:../../../packages/lit-virtual '@tanstack/virtual-core': - specifier: ^3.19.1 + specifier: ^3.19.2 version: link:../../../packages/virtual-core lit: specifier: ^3.3.0 @@ -709,7 +709,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -737,7 +737,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -762,7 +762,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -787,7 +787,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -812,7 +812,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -837,7 +837,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -862,7 +862,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -890,7 +890,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -915,7 +915,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -940,7 +940,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -965,7 +965,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -990,7 +990,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1015,7 +1015,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1040,7 +1040,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1065,7 +1065,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1090,7 +1090,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1115,7 +1115,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1140,7 +1140,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1165,7 +1165,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.2 + specifier: ^3.16.3 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1187,7 +1187,7 @@ importers: examples/react/chat: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.16 + specifier: ^3.14.17 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1218,7 +1218,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/react-virtual': - specifier: ^3.14.16 + specifier: ^3.14.17 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1249,7 +1249,7 @@ importers: examples/react/fixed: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.16 + specifier: ^3.14.17 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1283,7 +1283,7 @@ importers: specifier: ^5.80.7 version: 5.90.5(react@19.2.7) '@tanstack/react-virtual': - specifier: ^3.14.16 + specifier: ^3.14.17 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1308,7 +1308,7 @@ importers: examples/react/padding: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.16 + specifier: ^3.14.17 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1336,7 +1336,7 @@ importers: specifier: ^0.0.7 version: 0.0.7 '@tanstack/react-virtual': - specifier: ^3.14.16 + specifier: ^3.14.17 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1370,7 +1370,7 @@ importers: specifier: ^25.1.1 version: 25.1.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-virtual': - specifier: ^3.14.16 + specifier: ^3.14.17 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1395,7 +1395,7 @@ importers: examples/react/smooth-scroll: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.16 + specifier: ^3.14.17 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1423,7 +1423,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/react-virtual': - specifier: ^3.14.16 + specifier: ^3.14.17 version: link:../../../packages/react-virtual lodash: specifier: ^4.17.21 @@ -1460,7 +1460,7 @@ importers: specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-virtual': - specifier: ^3.14.16 + specifier: ^3.14.17 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1485,7 +1485,7 @@ importers: examples/react/variable: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.16 + specifier: ^3.14.17 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1510,7 +1510,7 @@ importers: examples/react/window: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.16 + specifier: ^3.14.17 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1544,7 +1544,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/svelte-virtual': - specifier: ^3.13.42 + specifier: ^3.13.43 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1572,7 +1572,7 @@ importers: examples/svelte/fixed: dependencies: '@tanstack/svelte-virtual': - specifier: ^3.13.42 + specifier: ^3.13.43 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1603,7 +1603,7 @@ importers: specifier: ^5.80.7 version: 5.90.2(svelte@4.2.20) '@tanstack/svelte-virtual': - specifier: ^3.13.42 + specifier: ^3.13.43 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1634,7 +1634,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/svelte-virtual': - specifier: ^3.13.42 + specifier: ^3.13.43 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1665,7 +1665,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/svelte-virtual': - specifier: ^3.13.42 + specifier: ^3.13.43 version: link:../../../packages/svelte-virtual lodash: specifier: ^4.17.21 @@ -1702,7 +1702,7 @@ importers: specifier: ^8.21.3 version: 8.21.3(svelte@4.2.20) '@tanstack/svelte-virtual': - specifier: ^3.13.42 + specifier: ^3.13.43 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1733,7 +1733,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/vue-virtual': - specifier: ^3.13.42 + specifier: ^3.13.43 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1758,7 +1758,7 @@ importers: examples/vue/fixed: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.42 + specifier: ^3.13.43 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1786,7 +1786,7 @@ importers: specifier: ^5.80.7 version: 5.90.5(vue@3.5.22(typescript@5.9.3)) '@tanstack/vue-virtual': - specifier: ^3.13.42 + specifier: ^3.13.43 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1811,7 +1811,7 @@ importers: examples/vue/padding: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.42 + specifier: ^3.13.43 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1836,7 +1836,7 @@ importers: examples/vue/scroll-padding: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.42 + specifier: ^3.13.43 version: link:../../../packages/vue-virtual '@vueuse/core': specifier: ^12.8.2 @@ -1864,7 +1864,7 @@ importers: examples/vue/smooth-scroll: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.42 + specifier: ^3.13.43 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1892,7 +1892,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/vue-virtual': - specifier: ^3.13.42 + specifier: ^3.13.43 version: link:../../../packages/vue-virtual lodash: specifier: ^4.17.21 @@ -1929,7 +1929,7 @@ importers: specifier: ^8.21.3 version: 8.21.3(vue@3.5.22(typescript@5.9.3)) '@tanstack/vue-virtual': - specifier: ^3.13.42 + specifier: ^3.13.43 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1954,7 +1954,7 @@ importers: examples/vue/variable: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.42 + specifier: ^3.13.43 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 From f211d3a64de6cbdd85469c9b97959db65df32eb6 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Mon, 12 Oct 2026 22:01:40 +0200 Subject: [PATCH 11/46] refactor(virtual-core): simplify the scroll-correction state (no behaviour change) (#1316) --- .changeset/pending-write-state.md | 5 + packages/virtual-core/src/index.ts | 384 ++++++------ .../__snapshots__/scroll-trace.test.ts.snap | 199 ++++++ packages/virtual-core/tests/index.test.ts | 203 +++++-- .../virtual-core/tests/scroll-trace.test.ts | 569 ++++++++++++++++++ 5 files changed, 1120 insertions(+), 240 deletions(-) create mode 100644 .changeset/pending-write-state.md create mode 100644 packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap create mode 100644 packages/virtual-core/tests/scroll-trace.test.ts diff --git a/.changeset/pending-write-state.md b/.changeset/pending-write-state.md new file mode 100644 index 000000000..f909931a6 --- /dev/null +++ b/.changeset/pending-write-state.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +refactor(virtual-core): simplify the scroll-correction state: a scroll write is tracked until its read-back as one record, the iOS touch gesture as one record, the pending prepend anchor as a tagged record, and every scroll command starts through one entry point (no behaviour change, with two exceptions: a second `touchend`/`touchcancel` inside the post-touch grace window now restarts the window instead of leaving the first timer to end it early, and after the scroll element is swapped or the virtualizer disabled, a write to the old element is no longer matched to the new element's first scroll report). `scrollAdjustments` becomes a read-only getter with the same value (assigning to it is no longer supported). diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 50c0f118d..cb5011392 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -413,14 +413,6 @@ type ScrollState = { // settling stableFrames: number phase: ScrollPhase - - // Where our last non-smooth write for this command put the element, until - // its scroll event arrives. Until then `scrollOffset` still holds the old - // position, and the rows on screen are the ones rendered for it. A smooth - // write leaves it null: the element is somewhere between the two offsets. - // It goes with the scroll state, so a write the browser ignored (no read-back) - // does not outlive its command. - writtenOffset?: number | null } const initialScrollPhase = ( @@ -439,12 +431,14 @@ const isSmoothScroll = (state: ScrollState | null): state is ScrollState => const isAnimating = (state: ScrollState) => state.phase === 'smooth' || state.phase === 'smoothFinishing' -type PendingScrollAnchor = [ - key: Key | null, - offset: number, - followOnAppend: ScrollBehavior | null, - anchorDelta: number, -] +// What `setOptions` decided about the scroll position for `_willUpdate` to +// carry out once the consumer has rendered: keep the reading anchor in place +// after a prepend (`scrollOffset` already moved by `delta`, the element has +// not), or follow an append to the end. A prepend that qualifies for both +// only follows. +type PendingScrollAnchor = + | { kind: 'anchor'; delta: number } + | { kind: 'follow'; behavior: ScrollBehavior } function isAppendWithTrim( prevCount: number, @@ -505,30 +499,49 @@ export class Virtualizer< scrollRect: Rect | null = null scrollOffset: number | null = null scrollDirection: ScrollDirection | null = null - scrollAdjustments = 0 + // Size compensation written to the element but not yet reflected in + // `scrollOffset`: the element is expected at `scrollOffset + + // scrollAdjustments` until the next scroll event reports it. + get scrollAdjustments() { + return this.pendingWrite?.adjustments ?? 0 + } // Sum of size-change deltas above-viewport that were skipped during // iOS momentum scroll (writing scrollTop mid-momentum cancels it). // Flushed in a single scrollTo when iOS is fully settled. private _iosDeferredAdjustment = 0 - // Touch state. iOS WebKit cancels momentum when scrollTop is written, so - // we defer adjustments not only during `isScrolling` but also through the - // touchstart→touchend/touchcancel window (active drag) and a short tail - // after the touch ends (early-momentum window — iOS only fires touch - // events once at the start of momentum, so we use a timer rather than - // another event). - private _iosTouching = false - private _iosJustTouchEnded = false - private _iosTouchEndTimerId: number | null = null - // Subpixel reconciliation. Safari (and Chrome/Firefox under certain DPRs) - // round scrollTop/scrollLeft writes to integer pixels. If we wrote 12345.5 - // but the browser reports back 12346, the next reconcileScroll sees a - // "target changed" and re-fires scrollTo — a feedback loop that the - // approxEqual(<1.01) tolerance otherwise absorbs as a workaround. - // By remembering the intended value of our most-recent self-driven - // scrollTo, we can match the browser's rounded read back to the intended - // value when the diff is < 1.5 px, distinguishing it from a real user - // scroll. The +0.5 over Math.abs lets us also absorb the +1 / -1 cases. - private _intendedScrollOffset: number | null = null + // The touch gesture in progress. iOS WebKit cancels momentum when + // scrollTop is written, so adjustments are deferred not only during + // `isScrolling` but through the whole gesture: `down` from touchstart to + // touchend/touchcancel (active drag), then `tail` for a short grace window + // after the finger lifts (early momentum — iOS fires touch events only + // once at the start of momentum, so the tail is a timer rather than + // another event). Null between gestures, and after touchend on non-iOS, + // which arms no tail. The tail record owns its timer: `_endIosTouch` is + // the only way to drop either, so a cleared record never leaves a timer + // behind, and a live timer always has its record. + private _iosTouch: + | { phase: 'down' } + | { phase: 'tail'; timerId: number } + | null = null + // Our last write to the element, from `_scrollToOffset` until the next + // scroll event reports it (the only place it is cleared). Until then + // `scrollOffset` may still hold the old position. + // + // - `target`: where the write puts the element (offset + adjustments). + // Safari (and Chrome/Firefox under certain DPRs) round writes to whole + // pixels: wrote 12345.5, read back 12346. A read-back within 1.5px of + // `target` is taken as our own and adopts the exact value, so reconcile + // does not see a moved target and write again; a user scroll has moved + // further by the time it reports. + // - `smooth`: the write animates, so until it lands the element is + // somewhere between the old offset and `target`. + // - `adjustments`: see `scrollAdjustments`. Writes that pass none keep the + // previous value. + private pendingWrite: { + target: number + smooth: boolean + adjustments: number + } | null = null // A compensation write from `applyScrollAdjustment` whose target exceeded // the element's scroll max at the moment of the write. The browser clamps // such a write because the consumer's sizer has not grown yet: an @@ -759,8 +772,7 @@ export class Virtualizer< // the virtualizer would render the wrong items for one frame (the // estimate-based positions are stale) and then correct in the next // frame, producing a visible "jump" on prepend with dynamic sizes. - let anchorResolved = false - let anchorDelta = 0 + let anchorDelta: number | null = null if (anchor && this.scrollOffset !== null) { const [anchorKey, anchorOffset] = anchor const newMeasurements = this.getMeasurements() @@ -781,19 +793,15 @@ export class Virtualizer< if (!followOnAppend && newOffset !== this.scrollOffset) { anchorDelta = newOffset - this.scrollOffset this.scrollOffset = newOffset - anchorResolved = true } } } } - if (anchorResolved || followOnAppend) { - this.pendingScrollAnchor = [ - anchorResolved ? anchor![0] : null, - anchorResolved ? anchor![1] : 0, - followOnAppend, - anchorDelta, - ] + if (followOnAppend) { + this.pendingScrollAnchor = { kind: 'follow', behavior: followOnAppend } + } else if (anchorDelta !== null) { + this.pendingScrollAnchor = { kind: 'anchor', delta: anchorDelta } } } @@ -815,10 +823,7 @@ export class Virtualizer< console.info('correction', delta) } - if ( - isIOSWebKit() && - (this.isScrolling || this._iosTouching || this._iosJustTouchEnded) - ) { + if (isIOSWebKit() && this._iosInMotion()) { // An index scroll retargets from fresh measurements in reconcileScroll; // replaying this delta after it lands would count it twice. if (this.scrollState?.index == null) { @@ -839,7 +844,7 @@ export class Virtualizer< ? { target, maxAtWrite } : null this._scrollToOffset(this.getScrollOffset(), { - adjustments: (this.scrollAdjustments += delta), + adjustments: this.scrollAdjustments + delta, behavior, }) // Eagerly carry the intended target in `scrollOffset` so callers that @@ -860,7 +865,7 @@ export class Virtualizer< // Upper-bound overflow stays untouched — it is transiently // legitimate mid-prepend while the consumer's sizer catches up. if (this.scrollOffset < 0) this.scrollOffset = 0 - this.scrollAdjustments = 0 + if (this.pendingWrite) this.pendingWrite.adjustments = 0 } return true } @@ -912,14 +917,14 @@ export class Virtualizer< // capture), so the new element never reports it. Carrying the state // over would replay a stale deferred delta on the new element's first // flush, and a cleanup that lands mid-touch or inside the post-touchend - // grace window would strand _iosTouching / _iosJustTouchEnded as true - // (the listener unsub clears the grace timer, and with it the only - // pending reset of the flag), deferring every adjustment on the new - // element until its next touch cycle. + // grace window would strand `_iosTouch` (the listeners that end it were + // just removed), deferring every adjustment on the new element until + // its next touch cycle. this._iosDeferredAdjustment = 0 - this._iosTouching = false - this._iosJustTouchEnded = false + this._endIosTouch() this._clampedAdjustment = null + // A write to the old element will never be read back. + this.pendingWrite = null this.scrollElement = null this.targetWindow = null } @@ -969,28 +974,21 @@ export class Virtualizer< // no-op re-emit that Safari/Firefox fire after a re-render's layout // (Chrome doesn't). Treating it as scrolling re-arms `isScrolling`, // which forces a render that triggers another such event: an - // infinite re-render loop. Ignore it. (Self-writes are handled by - // the `_intendedScrollOffset` reconciliation just below.) + // infinite re-render loop. Ignore it. (Self-writes are matched to + // `pendingWrite` just below.) if ( isScrolling && - this._intendedScrollOffset === null && + this.pendingWrite === null && offset === this.scrollOffset ) { return } - // If this scroll event looks like the browser's read-back of a - // value we just wrote, prefer our intended (sub-pixel-accurate) - // value over the browser's rounded one. The 1.5 px tolerance is - // tight enough to avoid mistaking a real user scroll for a - // self-write — by the time the user has moved 1.5 px, the - // intended value will already have been consumed by a prior - // scroll event and cleared. - if ( - this._intendedScrollOffset !== null && - Math.abs(offset - this._intendedScrollOffset) < 1.5 - ) { - offset = this._intendedScrollOffset + // The read-back of our own write: adopt the exact target over the + // browser's rounded value (see `pendingWrite`). + const pending = this.pendingWrite + if (pending !== null && Math.abs(offset - pending.target) < 1.5) { + offset = pending.target } else if ( this.scrollState?.index != null && // A non-smooth write lands at once, so any other scroll event is @@ -1017,8 +1015,7 @@ export class Virtualizer< // need to reconcile. this.scrollState = null } - this._intendedScrollOffset = null - if (this.scrollState) this.scrollState.writtenOffset = null + this.pendingWrite = null // A pending clamped compensation write (#1258) survives only its // own read-back, which the browser reports at the scroll max we @@ -1031,7 +1028,6 @@ export class Virtualizer< this._clampedAdjustment = null } - this.scrollAdjustments = 0 // If the offset hasn't moved, this is the echo of our own // adjustment write — `applyScrollAdjustment` already folded it // into `scrollOffset`. There's no direction to infer, so leave @@ -1066,28 +1062,27 @@ export class Virtualizer< if ('addEventListener' in this.scrollElement) { const scrollEl = this.scrollElement as unknown as EventTarget const onTouchStart = () => { - this._iosTouching = true - this._iosJustTouchEnded = false - if (this._iosTouchEndTimerId !== null && this.targetWindow != null) { - this.targetWindow.clearTimeout(this._iosTouchEndTimerId) - this._iosTouchEndTimerId = null - } + // A finger back down inside the grace window cancels its tail. + this._endIosTouch() + this._iosTouch = { phase: 'down' } } const onTouchEnd = () => { - this._iosTouching = false + this._endIosTouch() if (!isIOSWebKit() || this.targetWindow == null) { - // Non-iOS: nothing more to track. Just clear the touching flag. + // Non-iOS: nothing more to track. return } - this._iosJustTouchEnded = true // After ~150 ms with no scroll/touch events, momentum is done. - this._iosTouchEndTimerId = this.targetWindow.setTimeout(() => { - this._iosJustTouchEnded = false - this._iosTouchEndTimerId = null - // After the grace window, attempt to flush. The scroll event - // for momentum decay may have already fired before our timer. - this._flushIosDeferredIfReady() - }, 150) + const tail = { + phase: 'tail' as const, + timerId: this.targetWindow.setTimeout(() => { + if (this._iosTouch === tail) this._iosTouch = null + // After the grace window, attempt to flush. The scroll event + // for momentum decay may have already fired before our timer. + this._flushIosDeferredIfReady() + }, 150), + } + this._iosTouch = tail } scrollEl.addEventListener( 'touchstart', @@ -1110,10 +1105,7 @@ export class Virtualizer< scrollEl.removeEventListener('touchstart', onTouchStart) scrollEl.removeEventListener('touchend', onTouchEnd) scrollEl.removeEventListener('touchcancel', onTouchEnd) - if (this._iosTouchEndTimerId !== null && this.targetWindow != null) { - this.targetWindow.clearTimeout(this._iosTouchEndTimerId) - this._iosTouchEndTimerId = null - } + this._endIosTouch() }) } @@ -1127,29 +1119,21 @@ export class Virtualizer< this.pendingScrollAnchor = null if (anchor && this.scrollElement && this.options.enabled) { - const [key, _offset, followOnAppend, anchorDelta] = anchor - - if (key !== null && !followOnAppend) { + if (anchor.kind === 'anchor') { // scrollOffset was eagerly adjusted in setOptions so the // virtualizer already computed the correct range during render. // Now sync the browser's actual scroll position to match. - // Skip when followOnAppend is set — scrollToEnd will handle it. // // On iOS WebKit, writing scrollTop during touch/momentum cancels // the in-flight scroll. Defer the DOM sync the same way // applyScrollAdjustment does — accumulate the delta and let // _flushIosDeferredIfReady handle it once the scroll settles. - if ( - isIOSWebKit() && - (this.isScrolling || this._iosTouching || this._iosJustTouchEnded) - ) { - if (anchorDelta !== 0) { - this._iosDeferredAdjustment += anchorDelta - } + if (isIOSWebKit() && this._iosInMotion()) { + this._iosDeferredAdjustment += anchor.delta } else if ( isSmoothScroll(this.scrollState) && !approxEqual( - this.getScrollOffset() - anchorDelta, + this.getScrollOffset() - anchor.delta, this.scrollState.lastTargetOffset, ) ) { @@ -1167,10 +1151,8 @@ export class Virtualizer< behavior: undefined, }) } - } - - if (followOnAppend) { - this.scrollToEnd({ behavior: followOnAppend }) + } else { + this.scrollToEnd({ behavior: anchor.behavior }) } } @@ -1208,15 +1190,25 @@ export class Virtualizer< } } + // The element is moving or about to: a scroll in progress, or a touch + // gesture that may still turn into one (see `_iosTouch`). On iOS a write + // now would cancel the momentum, so adjustments wait for the flush below. + private _iosInMotion = () => this.isScrolling || this._iosTouch !== null + + private _endIosTouch = () => { + if (this._iosTouch?.phase === 'tail' && this.targetWindow != null) { + this.targetWindow.clearTimeout(this._iosTouch.timerId) + } + this._iosTouch = null + } + // Apply any accumulated iOS-deferred scroll adjustment, but only when we're // truly settled — not actively scrolling, not under an active touch, and // past the post-touchend grace window. Called from the scroll callback // and the touchend grace-timer. private _flushIosDeferredIfReady = () => { if (this._iosDeferredAdjustment === 0) return - if (this.isScrolling) return - if (this._iosTouching) return - if (this._iosJustTouchEnded) return + if (this._iosInMotion()) return // Phase 2b: Safari elastic-overscroll (rubber-band) lets scrollTop go // negative or beyond scrollHeight - clientHeight. Writing scrollTop // while in that zone snaps the page back to the clamped value at the @@ -1239,7 +1231,7 @@ export class Virtualizer< // landing between now and the resulting scroll event computes from the // post-flush offset rather than the stale one. this._scrollToOffset(cur, { - adjustments: (this.scrollAdjustments += delta), + adjustments: this.scrollAdjustments + delta, behavior: undefined, }) } @@ -1911,10 +1903,11 @@ export class Virtualizer< // command. Compensation writes and the prepend anchor sync update // `scrollOffset` eagerly, and the iOS flush writes exactly // `scrollOffset + scrollAdjustments`, so none of them count. + const writtenOffset = this.getCommandWrittenOffset() const awaitingCommandReadBack = - this.scrollState?.writtenOffset != null && + writtenOffset !== null && !approxEqual( - this.scrollState.writtenOffset, + writtenOffset, this.getScrollOffset() + this.scrollAdjustments, ) @@ -2100,29 +2093,12 @@ export class Virtualizer< toOffset: number, { align = 'start', behavior = 'auto' }: ScrollToOffsetOptions = {}, ) => { - // An absolute scroll command derives its target from current - // measurements, so any iOS-deferred compensation still pending is stale by - // definition — the command already accounts for the measurements the delta - // was compensating for. Drop it so _flushIosDeferredIfReady doesn't replay - // it onto the just-established position (relative commands like scrollBy - // intentionally keep the deferral, since they build on the current offset). - this._iosDeferredAdjustment = 0 - - const offset = this.getOffsetForAlignment(toOffset, align) - - const now = this.now() - this.scrollState = { - index: null, - align, - startedAt: now, - lastTargetOffset: offset, - stableFrames: 0, - phase: initialScrollPhase(behavior, false), - } - - this._scrollToOffset(offset, { adjustments: undefined, behavior }) - - this.scheduleScrollReconcile() + this._startScroll( + { index: null, align }, + this.getOffsetForAlignment(toOffset, align), + behavior, + 'absolute', + ) } scrollToIndex = ( @@ -2132,34 +2108,19 @@ export class Virtualizer< behavior = 'auto', }: ScrollToIndexOptions = {}, ) => { - // See scrollToOffset: an absolute target invalidates any pending - // iOS-deferred compensation. - this._iosDeferredAdjustment = 0 - index = Math.max(0, Math.min(index, this.options.count - 1)) const offsetInfo = this.getOffsetForIndex(index, initialAlign) if (!offsetInfo) { + // No target (an empty list), so nothing is written; still drop the + // deferral like every absolute command, or it replays once the list + // refills. + this._iosDeferredAdjustment = 0 return } const [offset, align] = offsetInfo - const now = this.now() - this.scrollState = { - index, - align, - startedAt: now, - lastTargetOffset: offset, - stableFrames: 0, - phase: initialScrollPhase( - behavior, - approxEqual(offset, this.getScrollOffset()), - ), - } - - this._scrollToOffset(offset, { adjustments: undefined, behavior }) - - this.scheduleScrollReconcile() + this._startScroll({ index, align }, offset, behavior, 'absolute') } scrollBy = ( @@ -2170,49 +2131,28 @@ export class Virtualizer< // Clamp: a write past either edge that leaves the element where it was // fires no scroll event, so nothing would clear an unreachable offset. const offset = this.getOffsetForAlignment( - (this.scrollState?.writtenOffset ?? this.getScrollOffset()) + delta, + (this.getCommandWrittenOffset() ?? this.getScrollOffset()) + delta, 'start', ) - const now = this.now() - - this.scrollState = { - index: null, - align: 'start', - startedAt: now, - lastTargetOffset: offset, - stableFrames: 0, - phase: initialScrollPhase(behavior, false), - } - this._scrollToOffset(offset, { adjustments: undefined, behavior }) - - this.scheduleScrollReconcile() + this._startScroll( + { index: null, align: 'start' }, + offset, + behavior, + 'relative', + ) } scrollToEnd = ({ behavior = 'auto' }: ScrollToEndOptions = {}) => { if (this.options.count > 0) { // Target the browser's max scroll rather than the last item's end-align // offset, so paddingEnd is included and isAtEnd() holds afterwards. - this._iosDeferredAdjustment = 0 - - const offset = Math.max(this.getMaxScrollOffset(), 0) - - this.scrollState = { - index: this.options.count - 1, - align: 'end', - toEnd: true, - startedAt: this.now(), - lastTargetOffset: offset, - stableFrames: 0, - phase: initialScrollPhase( - behavior, - approxEqual(offset, this.getScrollOffset()), - ), - } - - this._scrollToOffset(offset, { adjustments: undefined, behavior }) - - this.scheduleScrollReconcile() + this._startScroll( + { index: this.options.count - 1, align: 'end', toEnd: true }, + Math.max(this.getMaxScrollOffset(), 0), + behavior, + 'absolute', + ) return } @@ -2221,6 +2161,42 @@ export class Virtualizer< }) } + // Every scroll command starts here: it becomes the in-flight command, + // writes its first target and leaves reconcile to retarget and retire it. + // + // An absolute command derives its target from current measurements, so + // any iOS-deferred compensation still pending is stale by definition — + // the command already accounts for the measurements the delta was + // compensating for. Drop it so _flushIosDeferredIfReady doesn't replay it + // onto the just-established position. A relative command (scrollBy) + // builds on the current offset, so it keeps the deferral. + // + // An index or end target that is already at its offset starts a smooth + // scroll as landed; an offset target never does, so its first scroll + // event is read as the user's (see the scroll handler in `_willUpdate`). + private _startScroll( + target: Pick, + offset: number, + behavior: ScrollBehavior, + kind: 'absolute' | 'relative', + ) { + if (kind === 'absolute') { + this._iosDeferredAdjustment = 0 + } + this.scrollState = { + ...target, + startedAt: this.now(), + lastTargetOffset: offset, + stableFrames: 0, + phase: initialScrollPhase( + behavior, + target.index != null && approxEqual(offset, this.getScrollOffset()), + ), + } + this._scrollToOffset(offset, { adjustments: undefined, behavior }) + this.scheduleScrollReconcile() + } + // Drops the target of an in-flight scrollToIndex / scrollToOffset / // scrollBy / scrollToEnd so a user gesture can take over the viewport. // The scroll position is left untouched: writing it would stop iOS @@ -2317,16 +2293,26 @@ export class Virtualizer< behavior: ScrollBehavior | undefined }, ) => { - // Record the intended logical scroll target so the next scroll event - // can reconcile against subpixel rounding by the browser. - this._intendedScrollOffset = offset + (adjustments ?? 0) - if (this.scrollState) { - this.scrollState.writtenOffset = - behavior === 'smooth' ? null : this._intendedScrollOffset + this.pendingWrite = { + target: offset + (adjustments ?? 0), + smooth: behavior === 'smooth', + adjustments: adjustments ?? this.scrollAdjustments, } this.options.scrollToFn(offset, { behavior, adjustments }, this) } + // Where the in-flight command's last instant write put the element, until + // its scroll event arrives; the rows on screen are still the ones rendered + // for the old `scrollOffset`. Null after a smooth write (the element is + // somewhere on the way) and without a command, so a write the browser + // ignored (no read-back) does not outlive its command. + private getCommandWrittenOffset = () => + this.scrollState !== null && + this.pendingWrite !== null && + !this.pendingWrite.smooth + ? this.pendingWrite.target + : null + measure = () => { // Reset pendingMin so the next getMeasurements rebuilds from index 0. // Without this, a prior resizeItem() that left pendingMin > 0 would diff --git a/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap b/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap new file mode 100644 index 000000000..3c0509e86 --- /dev/null +++ b/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap @@ -0,0 +1,199 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`scroll behaviour snapshot > anchorEnd ios 1`] = ` +[ + "0: 4f298fd0 aad8d5a3 891c472b 6b3fd29c 55b588c8 22dcf00c 215b6b0d 7cce72d9 d0a82d58 bc037d54", + "10: d44a9bb5 586dd775 b430581a 6e281984 aa7c5657 153cee4a cfe99984 e2ca2c19 1367462c 5b539e6c", + "20: 1513df98 cfcd9542 62c8c74b f31baa86 64cc3f52 e4eea90c 0fe112b7 dc03acfc 9c5c547a 2f69dca1", + "30: 4b65af20 57a8311c 2fcb9406 dd2e21be 8a13a7ef 514b807f 68555950 bd7052a9 4e3b9609 b2cd4a3e", + "40: a37c95f7 af0b2c51 3cc0e1c8 8a237208 1e043fba 80baef90 493457fe f2410084 5fadb00a 30b3276f", + "50: 36d91b30 cfce66db b89abd4c deacf849 36079ddd 52742173 8499d80c 89bf911c 83ea3d20 49fdd4f9", +] +`; + +exports[`scroll behaviour snapshot > anchorEnd web 1`] = ` +[ + "0: 7e29eee4 689a8f9d 0be3bc81 0616f785 df872403 5dc6ddf6 efaed7ca 2eeb4a65 c98b85de 4d4f6d9c", + "10: 58d7e5b7 095832b0 675d7128 346f8f84 446602ce 153cee4a 8435976c bf6dff7a ca3469b5 fcd5167f", + "20: bd51101f 61474ab4 ae20db6d e6d6d477 1622bd05 0a125131 82351452 2db4f7e9 3b01d988 668a6a76", + "30: 187247d3 710fc3ad 314c91f8 dd2e21be a77f38e7 0065547f fefa6230 56e2fdaa 4e3b9609 720cd6cd", + "40: 930d4f71 5ca47fb5 8e942a09 b6fe40dd 2b9c8efc fcc62c79 3dc0fa80 9ec92707 3becd415 857463c5", + "50: 49d7572b 1ae7af8c 5370d3af 8103bbac 36079ddd f7788a1a c7c11306 93867ea2 02314a9e ddaed9da", +] +`; + +exports[`scroll behaviour snapshot > base ios 1`] = ` +[ + "0: 5887e219 abe3cd0e 891c472b 61a2f9b9 2cbfb4c4 7fb055d7 e8385a04 2d1bfe19 8f7607c0 9970e654", + "10: 7f6afed0 4dcb6d27 e91a7175 0f66079c dbb9cc42 f5d2f4d7 cb3749ad e2ca2c19 e24d273b c5b1dcc1", + "20: 6f1d9099 0d96c3bd 61e8fc8b 76d42dca 5dcca7a8 b105dbdd e1f9c925 c1f8e4ab 3d92177b 63757970", + "30: 4b65af20 eb152cd3 2c2b6a76 e2f91462 7e888af6 36d83add 68555950 bd7052a9 10edfac1 b2cd4a3e", + "40: a37c95f7 4bcf0d6d 533bd349 2c711ebe a5233478 acc8c54b 31c8b4eb 2c10d2fd eebc1cb1 488db66c", + "50: 36d91b30 6ce0ab7d a969c360 2c499c27 58dd9721 288a970e 37c83cd0 6d942e32 83ea3d20 32b19c85", +] +`; + +exports[`scroll behaviour snapshot > base web 1`] = ` +[ + "0: 487989ab 5b01c685 008a101f 987345a6 71d2dd97 0123605a 402cb4cd 6a913d03 d7a52f21 96c702b6", + "10: 30b67f8e 6665d259 043cf059 449c1d27 cb64d201 f5d2f4d7 8dd5e943 bf6dff7a 30031c34 a218de69", + "20: e256a75c 65844fed ae20db6d eef3b427 c0030496 d0eba513 82351452 10cc53a8 17976cbd 6e2f429c", + "30: a9fe18da a2431d61 3227e8b2 e2f91462 dff2fed9 7b794ad1 ea14f6e3 56e2fdaa 10edfac1 c2384cef", + "40: 4233947f 3aa25331 0fe80f3d 715efd76 cacc0bb6 dbc3e3ba 35c640e7 17678dfd 61acfd2f 4aeaefa7", + "50: 49d7572b 6ce0ab7d 14bb6efa be9301e2 58dd9721 d507a64a b2a3d459 4c0075f5 0d4a9e27 f210954f", +] +`; + +exports[`scroll behaviour snapshot > horizontal ios 1`] = ` +[ + "0: 5887e219 abe3cd0e 891c472b 61a2f9b9 2cbfb4c4 7fb055d7 e8385a04 2d1bfe19 8f7607c0 9970e654", + "10: 7f6afed0 4dcb6d27 e91a7175 0f66079c dbb9cc42 f5d2f4d7 cb3749ad e2ca2c19 e24d273b c5b1dcc1", + "20: 6f1d9099 0d96c3bd 61e8fc8b 76d42dca 5dcca7a8 b105dbdd e1f9c925 c1f8e4ab 3d92177b 63757970", + "30: 4b65af20 eb152cd3 2c2b6a76 e2f91462 7e888af6 36d83add 68555950 bd7052a9 10edfac1 b2cd4a3e", + "40: a37c95f7 4bcf0d6d 533bd349 2c711ebe a5233478 acc8c54b 31c8b4eb 2c10d2fd eebc1cb1 488db66c", + "50: 36d91b30 6ce0ab7d a969c360 2c499c27 58dd9721 288a970e 37c83cd0 6d942e32 83ea3d20 32b19c85", +] +`; + +exports[`scroll behaviour snapshot > horizontal web 1`] = ` +[ + "0: 487989ab 5b01c685 008a101f 987345a6 71d2dd97 0123605a 402cb4cd 6a913d03 d7a52f21 96c702b6", + "10: 30b67f8e 6665d259 043cf059 449c1d27 cb64d201 f5d2f4d7 8dd5e943 bf6dff7a 30031c34 a218de69", + "20: e256a75c 65844fed ae20db6d eef3b427 c0030496 d0eba513 82351452 10cc53a8 17976cbd 6e2f429c", + "30: a9fe18da a2431d61 3227e8b2 e2f91462 dff2fed9 7b794ad1 ea14f6e3 56e2fdaa 10edfac1 c2384cef", + "40: 4233947f 3aa25331 0fe80f3d 715efd76 cacc0bb6 dbc3e3ba 35c640e7 17678dfd 61acfd2f 4aeaefa7", + "50: 49d7572b 6ce0ab7d 14bb6efa be9301e2 58dd9721 d507a64a b2a3d459 4c0075f5 0d4a9e27 f210954f", +] +`; + +exports[`scroll behaviour snapshot > lanes ios 1`] = ` +[ + "0: c6dd08cb 147a2be3 13203d38 aa97ec29 544098f0 fb4d19c1 b895e017 2eb29692 6ef29cec 2b318c67", + "10: b8f614ec 2a990734 24e1cc10 ad1c0dc9 7b4b7b9d e9749cd5 a5f53b4e af772ff3 77b88ced 5b661f55", + "20: 16fa47be 01f0386a b1893d8e 1ed850a8 f23871ad ba3c88b6 d8984fc6 222168c3 86bac07d fcc3a928", + "30: ccf65223 b9edc885 b8a97e0b eab3a7a5 63dafad0 2d577a18 ff486842 6f222b0a 22a2c534 7b10519a", + "40: 49ebb7e4 953afd3a 16c879ff 827661aa f693ed31 55f47063 a741de85 f11984c1 19291542 799e7dd3", + "50: cb6a1e7d 84a561da 8cd6b03a 7a150d3e 9b4f0ed6 60f3b4d4 fbe950c5 873002f8 48b89640 0442e5e7", +] +`; + +exports[`scroll behaviour snapshot > lanes web 1`] = ` +[ + "0: 913fdc9a 44c91e8a de866186 dd261f52 9567771f 883fb01a ead92723 2eb29692 dc6ff20e 83acd62b", + "10: ec2808c8 1206b940 098712d0 0757677f edd71fea e9749cd5 61c90f24 af772ff3 35688b69 13731b17", + "20: 27c29bfd a31a22c8 7d53dcfb 6fc580cc faa00cb7 db732cad a5ee212e 0eebc6bd 30a86913 868f0885", + "30: acb6e31d e8c8e9fc 2f529382 eab3a7a5 90deb18a 6c26f17c 55e138ed 95816a75 b4eddb66 6982dc83", + "40: 973917b4 fb244062 a0aa1a22 436ccb21 1f20c834 d7ec176b e8117c25 30f35459 001073ef a16538f7", + "50: 052daa55 84a561da d8c34776 1f9f2c33 9b4f0ed6 e991a5c5 437a67cf 6955068d f7e64c83 ac5b1d69", +] +`; + +exports[`scroll behaviour snapshot > margin ios 1`] = ` +[ + "0: cfe5f103 e904cb37 9903a3b4 1a33cf4f 65bed3da a29439f7 17a9e3c6 245d5721 57acbeb2 32de7235", + "10: 683ab7bb 4a34d79c 9378872f 756820d4 6f276b57 ecee60a0 076ed8f1 53754f4c 871c5fdc 89cc8718", + "20: ac608363 a4a4c7ea 541dd3aa 1f8f8648 4fd6db33 2f3bb231 00818908 67d5bf34 31cb282c 48d9b0b5", + "30: 70377111 9c05ddf5 ab70c6d3 443556ba 58ecae93 a0b9e446 3df28c65 a762f728 143e728c 3d255499", + "40: eb7d03a8 45148c24 812b02f1 18886587 ae2f38a6 5a2dacf8 8a5405d6 e9a13eb5 922f4483 94e2085f", + "50: cd85f836 b84f25a9 44c5f003 573e0d17 5ea402c9 f356d904 5857417f 93b00875 5ed0daa5 b801680f", +] +`; + +exports[`scroll behaviour snapshot > margin web 1`] = ` +[ + "0: 49373050 e55d4ec9 cee1b367 a3254862 1ef733d1 58bff51e d68cc542 64ae8a6b f14542ec 4bd369ad", + "10: 2b352d93 04119bf1 13f22c53 fef4c6fa 9f9e6742 ecee60a0 43c89810 1dcffc84 95ab6afb 216d3915", + "20: bc80e48e 20db46ae 78cf9df4 17df29c9 b4fd89c4 c8813f01 b750a0b3 d9a4907f fae1bada a823f96b", + "30: eafca965 7578434d 19072ae6 443556ba 0444fbdd 297593ed 1b0ce1f2 2f6c5f87 143e728c 88cd8be1", + "40: a9344c4a 83de4592 d5ec42ee e7cf9b97 a24fffb3 25efdf06 db745b6d c6e0b773 426d0896 0969cb81", + "50: 3a707136 b84f25a9 f5e55c65 1869f200 5ea402c9 86e83842 73608dd3 83602b38 5cdf3ac0 2fcf8d37", +] +`; + +exports[`scroll behaviour snapshot > padding ios 1`] = ` +[ + "0: 7ce656c5 3f6396a2 ff1846c1 6a93b49f afd851fb 5d9a3036 19c9d0db 0b0cef6b 5baeb23d a31d5d4e", + "10: cd6de633 6dd688b7 4eedc487 5888b4aa d97787bc 11a5136d 629f4ec5 850708e0 c38dcf47 fda29dcb", + "20: fb42dc9d 7e1dbd8f d556b6de 24669a89 3e6c560c 87232ce3 713c008b 68b24170 ae553e4a de2d7260", + "30: 8a500602 21cbdddc fe42967c 090eb33f aa89e778 f0b9b29a fddaae98 c49dcc84 9e8a9ec6 7ffe530b", + "40: 6e49a752 c7ee6f01 a0cac638 0ea00ab4 48cc1cd6 11057618 7abf5573 9be1bed3 53635e4b fecdec2a", + "50: 2e0dd4b1 46031c46 3e3afa5e 39ba8c01 eafdfeea 49a44331 9f89e5ac 982b04fc ace495d9 5feb11bf", +] +`; + +exports[`scroll behaviour snapshot > padding web 1`] = ` +[ + "0: 262f9200 6cc65ec8 76d3e20e 59f6e440 3b2d9255 5f8ecbe5 dacfa504 0b0cef6b 2aeece4d c605d3e7", + "10: 9209f21f 9a76d74f b1525893 2d286847 7eb9a778 11a5136d 9d2b2319 301fcdae bf84b064 9ff484b0", + "20: ba67793f 39c3a659 79d02652 e65de77a f034a31e 2b525f87 32719c78 6dc03f3b 463a3308 d44c4b80", + "30: 5e0da9b0 3bfbd321 d24f1bc5 090eb33f c0552bc9 1ed26ae5 6758427f 44947dec 9e8a9ec6 8a2d1fda", + "40: 145e7cfe a49334ea a1717141 4be67f5c db3e39f2 8258a6e9 0f218eec 5f658c23 18a8d574 4fa2cd5e", + "50: c1d891be 46031c46 f6e8b31a 70e79ae5 eafdfeea a4afe30d 319c4097 74b5c434 038a4e91 f7d38c9a", +] +`; + +exports[`scroll behaviour snapshot > predicate ios 1`] = ` +[ + "0: 781ddc9b b30a23c2 93bad3b1 425fbffb 57ad1433 fc4b3cfc 81c8f4dd 80e2fe18 4b88cb8f 5b542fb3", + "10: d14bdad1 674c7db9 164b70b4 610f0a1d 24ee547c 3de8713e 7adb144b 57e0517c 650b9699 6e653156", + "20: 557ae3b2 89ac6afe 115a1a38 298ce6ec fd7eac41 3292ba3f 2fe1e7fa f66e796f 1061b08f f6652f37", + "30: 51076098 567ff44b 49cb7b84 f4883cc3 70ca1a9c 4cc6c949 af075e4f 7b2eaec8 ba70c93a 1d29977b", + "40: d4b4003c 16a048e8 2b24716c cc64a65e 023db0a6 68e5154e 806f5b9b 1cc9dae3 f715e0f3 17e93b0a", + "50: 34f20ab7 896571b0 79f3b432 dcd1b65b 5c8cff95 ff903d83 3359b798 6cd6c09b eb7ebc85 c7de5165", +] +`; + +exports[`scroll behaviour snapshot > predicate web 1`] = ` +[ + "0: ceb8aef1 4ea74420 bb609c62 de75aa51 5fc4f7d9 da63d4fc f9078e1c 9c09701d 64fa823a 8023cd25", + "10: afed87ee e49f63f7 abb793c9 a79e9a0b 20bd4665 561aec84 3e478366 57e0517c cdd88f3d 0f2b0911", + "20: d9eaf56d 9b5e6dab 2a5d09f5 0b96eee9 ad2d297a a6957b9a 43ddb32d e22c5e15 50199ebc 669bc632", + "30: 7ad89bbf 2a468fed 4fbf6499 f4883cc3 7e9faf3d e6adacc4 658f7ce7 ed93f986 063455d7 e76aff2c", + "40: 3fe9f887 f249d560 cfc5bd0a 5f2b5151 d9ce470c c93a259b b2cbaa3c ebc73663 c84e7f41 2b04619d", + "50: 3eee4840 6696c908 c7601071 7ccce5c6 68df2e14 0dc4fb86 fc4c3ef8 6cd6c09b 9b7f3b18 8d7cefab", +] +`; + +exports[`scroll behaviour snapshot > smooth ios 1`] = ` +[ + "0: f6b6d765 abe3cd0e a5810b75 449f0d02 8c4e0ffd 81c6109d 3baa79a2 4e69fa05 6da3b9f4 44548616", + "10: 566a5fdf 04ff1ed2 72330b80 9aa13f31 f4fdf51c 425c54ed af0a6f89 577590ad d9e2fc70 06c62243", + "20: 45a192f8 d2561f12 054dfac1 9bfef053 995d88d0 4863001d 3e67e00f aa102e37 4096c41f f9884ae7", + "30: 5105da32 69d46669 f8df5a7f c389598d ef52afb4 d277009e 70c0467b 25d3f80b 1130d5f2 4c1558fd", + "40: 517bd4ad 1d2b9887 534ef4db 1ccffcfd 115d3592 47a8fab6 d10aecd5 f3ade4e7 c3d0bf08 ab665d94", + "50: 7dba7d19 abc4d1a1 d806c049 7a29b27f 1bdbe3ee 22c69134 2d809341 fa65bc54 143f84f0 624c4099", +] +`; + +exports[`scroll behaviour snapshot > smooth web 1`] = ` +[ + "0: 8fbb0495 5b01c685 91a6c6e4 bf1ebb2f afbd6f06 c7477e32 fd7e58a4 4e69fa05 a2835af5 c91a5a3b", + "10: 0b70aa37 d8af31d2 78080d39 d0771b77 f4fdf51c 425c54ed 32840af5 577590ad d9e2fc70 24d0175d", + "20: 45a192f8 d2561f12 054dfac1 d4a92be6 88407c5b 4863001d 83239375 f8cc8acb b946b740 f9884ae7", + "30: b93d44fc 2912b3af 824ed264 c389598d 1f9cb3ea d277009e 54c9817c 9602c7e7 1130d5f2 d7f24592", + "40: 6fabd248 1d2b9887 534ef4db a8d62bed 17df6866 bac62a70 d10aecd5 dd6f23ef 311b7f10 753fa9df", + "50: 7583e0ce abc4d1a1 1a86e0f2 dc9a3a09 1bdbe3ee 22c69134 ffa64041 fa65bc54 143f84f0 ae02b7c4", +] +`; + +exports[`scroll behaviour snapshot > variable ios 1`] = ` +[ + "0: 3e794d55 ad98c0b2 927b911f 3b0cefe4 d885af90 ef69933d e4d0b78b 2ab62b32 05e9b62a 0cbf8311", + "10: 24f54188 282fb6ee d39ed06f 2c9e2f2b c40fc7dc a8ba89e9 334129e8 1411fce3 e51c5d1e 110c798a", + "20: 9738d290 270a631d 2a65846b cb11b412 1586781a c3e995e4 a39df847 0cd0ba75 ebf5fd1e bcde9cd4", + "30: 7acae8be 08a5362d b80fcb91 4cef1e03 9e86c65a 050a49b4 3a1286c7 1ae37890 717236aa d3d1a88c", + "40: 4a75a344 5a71ca13 5a3a092a 2a8e2e54 057eddc5 1ff70d54 db9c52ae e46be830 9776a720 0d85b7d8", + "50: 7a95c704 8844a6d3 64482217 c69b217c c2c92a5b 91b725d1 7a75f6ba 3b2552e7 10a5e16e 1fbb2f69", +] +`; + +exports[`scroll behaviour snapshot > variable web 1`] = ` +[ + "0: 4befed9d d60af59c b3f9031a e05e1391 66dde016 d0db0f5e 01b76a1e ca52ec1d 2035229a d7829734", + "10: 7180696c 6d90fc69 ff092a81 6f9efb4f 1d9fadb7 a8ba89e9 cb6c2752 1411fce3 f9d943d7 a71816ef", + "20: 0443f0f4 9baead9b 2aab582d 4765385b add24019 7686aa4d 21b8c09a 934d4019 d1137ceb a563038a", + "30: ea4d0eca 41c250c8 c6c0904b 4cef1e03 b994c59c 7685028b 260a5782 b2bfaa33 c56b35b2 0b87eb37", + "40: e94aefcf b68ec494 b0f3a0fb 4ee85f57 18c33c8c 2cd3bbde 0b5ef252 5be529a2 f5af3da2 8ea35dfa", + "50: 7b360174 8844a6d3 d8080fce 5f9c9870 c2c92a5b 3fb6fe01 908013b8 45bc6bde f68930b5 9960546c", +] +`; diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 60fd7ec0f..290d810a3 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -2106,6 +2106,51 @@ test('iOS deferral: multiple resizes during scroll accumulate and flush as one', }) }) +test('iOS deferral: scrollToIndex with no target still drops a pending deferred adjustment', () => { + // An absolute command drops the deferral even when it has no target to + // write (an empty list), as it did before the commands shared an entry + // point; otherwise the stale delta replays once the list refills (found by + // @phcorp's differential run on #1316). + withFakeIOSUserAgent(() => { + const scrollToFn = vi.fn() + let scrollCallback: + | ((offset: number, isScrolling: 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) => { + scrollCallback = cb + cb(200, true) + return () => {} + }, + }) + v._willUpdate() + v['getMeasurements']() + scrollToFn.mockClear() + + v.resizeItem(0, 90) // above the fold while scrolling: deferred + v.setOptions({ ...v.options, count: 0 }) + v['getMeasurements']() // the render for the empty list + v.scrollToIndex(0) // no target, nothing to write + v.setOptions({ ...v.options, count: 10 }) + v['getMeasurements']() + scrollCallback!(200, false) // settles: the flush would replay the delta + + expect(scrollToFn).not.toHaveBeenCalled() + }) +}) + test('iOS deferral: an absolute scroll command invalidates a pending deferred adjustment', () => { // Regression (#1233 manifestation A): scrollToOffset/scrollToIndex derive // their target from CURRENT measurements, so any deferred compensation still @@ -2282,6 +2327,50 @@ test('iOS deferral: flushed delta is rolled into scrollAdjustments so back-to-ba }) }) +test('iOS deferral: a write before the flush is read back keeps the flushed adjustment', () => { + // `scrollAdjustments` tracks compensation written but not yet reported. + // A later write that passes no adjustments (a command, a reconcile, the + // anchor sync) does not report anything, so the flushed delta stays until + // the scroll event (found by @phcorp's mutation run on #1316). + withFakeIOSUserAgent(() => { + const scrollToFn = vi.fn() + let scrollCallback: + | ((offset: number, isScrolling: 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) => { + scrollCallback = cb + cb(200, true) + return () => {} + }, + }) + v._willUpdate() + v['getMeasurements']() + + v.resizeItem(0, 100) // deferred while scrolling + scrollCallback!(200, false) // settles: the flush writes +50 + expect(v.scrollAdjustments).toBe(50) + + v.scrollToOffset(150) // a write with no adjustments of its own + expect(v.scrollAdjustments).toBe(50) + + scrollCallback!(150, false) // its read-back + expect(v.scrollAdjustments).toBe(0) + }) +}) + test('iOS deferral: a negative delta at the end clamp is dropped, not replayed', () => { // Regression (#1233 manifestation B): with anchorTo: 'end' and the reader // pinned at the bottom, a row above the viewport re-measuring *smaller* @@ -2703,17 +2792,18 @@ function dispatchTouchEvent( el._dispatch(type) } -test('iOS Phase 1: touchstart sets _iosTouching=true and clears justTouchEnded', () => { +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) - ;(v as any)._iosJustTouchEnded = true // pretend a prior touchend left this set dispatchTouchEvent(el, 'touchstart') - expect(v['_iosTouching']).toBe(true) - expect(v['_iosJustTouchEnded']).toBe(false) + dispatchTouchEvent(el, 'touchend') // leaves a tail armed + expect(v['_iosTouch']?.phase).toBe('tail') + dispatchTouchEvent(el, 'touchstart') + expect(v['_iosTouch']).toEqual({ phase: 'down' }) }) }) @@ -2733,11 +2823,9 @@ test('iOS Phase 1: touchcancel clears touching the same as touchend, and cleanup expect(el._hasListener('touchcancel')).toBe(true) dispatchTouchEvent(el, 'touchstart') - expect(v['_iosTouching']).toBe(true) + expect(v['_iosTouch']).toEqual({ phase: 'down' }) dispatchTouchEvent(el, 'touchcancel') - expect(v['_iosTouching']).toBe(false) - expect(v['_iosJustTouchEnded']).toBe(true) - expect(v['_iosTouchEndTimerId']).not.toBeNull() + expect(v['_iosTouch']?.phase).toBe('tail') const unmount = v._didMount() unmount() @@ -2762,15 +2850,13 @@ test('iOS Phase 1: touchend sets justTouchEnded + starts grace timer, then expir const { v, el } = makeIOSVirtualizerWithRealEl(vi.fn(), mockWindow) dispatchTouchEvent(el, 'touchstart') dispatchTouchEvent(el, 'touchend') - expect(v['_iosTouching']).toBe(false) - expect(v['_iosJustTouchEnded']).toBe(true) - expect(v['_iosTouchEndTimerId']).not.toBeNull() + const touch = v['_iosTouch'] + expect(touch?.phase).toBe('tail') // Fire the timer manually (simulating 150ms elapsing). - const fn = timers.get(v['_iosTouchEndTimerId']!)! + const fn = timers.get((touch as { timerId: number }).timerId)! fn() - expect(v['_iosJustTouchEnded']).toBe(false) - expect(v['_iosTouchEndTimerId']).toBeNull() + expect(v['_iosTouch']).toBeNull() }) }) @@ -2809,7 +2895,8 @@ test('iOS Phase 1: resize in post-touchend grace window defers; flushes when tim const { v, el } = makeIOSVirtualizerWithRealEl(scrollToFn, mockWindow) dispatchTouchEvent(el, 'touchstart') dispatchTouchEvent(el, 'touchend') - expect(v['_iosJustTouchEnded']).toBe(true) + const touch = v['_iosTouch'] + expect(touch?.phase).toBe('tail') scrollToFn.mockClear() // Items measure during the grace window — must defer @@ -2819,9 +2906,9 @@ test('iOS Phase 1: resize in post-touchend grace window defers; flushes when tim expect(v['_iosDeferredAdjustment']).toBe(50 + 15) // Expire the grace timer; the timer callback flushes the accumulated delta. - const fn = timers.get(v['_iosTouchEndTimerId']!)! + const fn = timers.get((touch as { timerId: number }).timerId)! fn() - expect(v['_iosJustTouchEnded']).toBe(false) + expect(v['_iosTouch']).toBeNull() expect(scrollToFn).toHaveBeenCalledTimes(1) expect(v['_iosDeferredAdjustment']).toBe(0) }) @@ -2865,8 +2952,8 @@ test('iOS Phase 1: scroll-event after touchend timer cleanup also flushes', () = expect(scrollToFn).not.toHaveBeenCalled() expect(v['_iosDeferredAdjustment']).toBe(50) - // Scroll ends. Touch never started here, so the flush gate's - // !isScrolling && !_iosTouching && !_iosJustTouchEnded all hold. + // Scroll ends. Touch never started here, so nothing is in motion and + // the flush gate opens. scrollCallback!(100, false) expect(scrollToFn).toHaveBeenCalledTimes(1) expect(v['_iosDeferredAdjustment']).toBe(0) @@ -2889,15 +2976,14 @@ test('iOS Phase 1: new touchstart during grace window cancels pending flush time const { v, el } = makeIOSVirtualizerWithRealEl(scrollToFn, mockWindow) dispatchTouchEvent(el, 'touchstart') dispatchTouchEvent(el, 'touchend') - const firstTimerId = v['_iosTouchEndTimerId']! + const firstTimerId = (v['_iosTouch'] 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['_iosTouchEndTimerId']).toBeNull() - expect(v['_iosTouching']).toBe(true) + expect(v['_iosTouch']).toEqual({ phase: 'down' }) }) }) @@ -2976,7 +3062,7 @@ test('iOS Phase 1: scroll-element swap does not replay a stale deferred adjustme }) }) -test('iOS Phase 1: scroll-element swap mid-touch does not strand _iosTouching', () => { +test('iOS Phase 1: scroll-element swap mid-touch does not strand the `down` phase', () => { withFakeIOSUserAgent(() => { const mockWindow = { setTimeout: globalThis.setTimeout.bind(globalThis), @@ -2987,7 +3073,7 @@ test('iOS Phase 1: scroll-element swap mid-touch does not strand _iosTouching', mockWindow, ) dispatchTouchEvent(holder.el, 'touchstart') - expect(v['_iosTouching']).toBe(true) + expect(v['_iosTouch']).toEqual({ phase: 'down' }) // The in-flight touch keeps targeting the old element (implicit touch // capture), so the new element will never deliver its touchend. Without @@ -2995,11 +3081,11 @@ test('iOS Phase 1: scroll-element swap mid-touch does not strand _iosTouching', // the flush blocked until the user's next full touch cycle. holder.el = makeEl() v._willUpdate() - expect(v['_iosTouching']).toBe(false) + expect(v['_iosTouch']).toBeNull() }) }) -test('iOS Phase 1: scroll-element swap during grace window does not strand _iosJustTouchEnded', () => { +test('iOS Phase 1: scroll-element swap during grace window does not strand the tail', () => { withFakeIOSUserAgent(() => { let timerId = 0 const timers = new Map void>() @@ -3017,14 +3103,14 @@ test('iOS Phase 1: scroll-element swap during grace window does not strand _iosJ ) dispatchTouchEvent(holder.el, 'touchstart') dispatchTouchEvent(holder.el, 'touchend') - expect(v['_iosJustTouchEnded']).toBe(true) + expect(v['_iosTouch']?.phase).toBe('tail') // Swapping inside the grace window removes the listeners and clears the - // grace timer — which was the only pending reset of the flag. Without - // the cleanup reset the flag would stay true indefinitely. + // 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['_iosJustTouchEnded']).toBe(false) + expect(v['_iosTouch']).toBeNull() expect(timers.size).toBe(0) }) }) @@ -3057,7 +3143,7 @@ test('Phase 2a: browser-rounded scrollTop after self-write is reconciled to inte // Simulate a self-write to 123.5 (subpixel target). v.scrollToOffset(123.5, { behavior: 'auto' }) - expect(v['_intendedScrollOffset']).toBe(123.5) + expect(v['pendingWrite']?.target ?? null).toBe(123.5) // Browser fires a scroll event reporting 123 (integer-rounded). scrollCallback!(123, false) @@ -3065,7 +3151,7 @@ test('Phase 2a: browser-rounded scrollTop after self-write is reconciled to inte // 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['_intendedScrollOffset']).toBeNull() + expect(v['pendingWrite']?.target ?? null).toBeNull() }) test('Phase 2a: user-initiated scroll (large delta) is NOT reconciled to intended value', () => { @@ -3092,13 +3178,13 @@ test('Phase 2a: user-initiated scroll (large delta) is NOT reconciled to intende }) v._willUpdate() v.scrollToOffset(100, { behavior: 'auto' }) - expect(v['_intendedScrollOffset']).toBe(100) + 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['_intendedScrollOffset']).toBeNull() + expect(v['pendingWrite']?.target ?? null).toBeNull() }) // ─── Phase 2b: scrollTopMax elastic-overscroll clamp ───────────────────────── @@ -3270,7 +3356,7 @@ test('Phase 2a: a second self-write replaces the intended target', () => { v._willUpdate() v.scrollToOffset(100, { behavior: 'auto' }) v.scrollToOffset(200.7, { behavior: 'auto' }) - expect(v['_intendedScrollOffset']).toBe(200.7) + 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) @@ -3289,11 +3375,10 @@ test('iOS Phase 1: non-iOS still does NOT install touch state machine', () => { const { v, el } = makeIOSVirtualizerWithRealEl(scrollToFn, mockWindow) dispatchTouchEvent(el, 'touchstart') - expect(v['_iosTouching']).toBe(true) // touchstart still flips the flag (cheap) + expect(v['_iosTouch']).toEqual({ phase: 'down' }) // touchstart still records (cheap) dispatchTouchEvent(el, 'touchend') - // Non-iOS path returns before setting justTouchEnded / arming timer - expect(v['_iosJustTouchEnded']).toBe(false) - expect(v['_iosTouchEndTimerId']).toBeNull() + // Non-iOS path returns before arming a tail + expect(v['_iosTouch']).toBeNull() }) test('non-iOS: adjustment is applied immediately during scroll (no regression)', () => { @@ -3482,7 +3567,7 @@ test('scroll-up jank: idle (scrollDirection=null) still applies adjustment', () // `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 `_intendedScrollOffset` sub-pixel reconciliation). That equality +// 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 @@ -3572,7 +3657,7 @@ test('self-write read-back rounded by the browser still does not latch a directi v['applyScrollAdjustment'](40.5) expect(v.scrollOffset).toBe(640.5) - // The browser rounds the write to an integer; the `_intendedScrollOffset` + // The browser rounds the write to an integer; the `pendingWrite` // reconciliation snaps the read-back to the held value. emitScroll(640, true) @@ -5736,3 +5821,39 @@ test.fails( expect(el.scrollTop).toBe(3900) }, ) + +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) +}) diff --git a/packages/virtual-core/tests/scroll-trace.test.ts b/packages/virtual-core/tests/scroll-trace.test.ts new file mode 100644 index 000000000..6b1ea2cf8 --- /dev/null +++ b/packages/virtual-core/tests/scroll-trace.test.ts @@ -0,0 +1,569 @@ +import { describe, expect, test } from 'vitest' +import { Virtualizer, _resetIOSDetectionForTests } from '../src/index' +import type { ScrollToOptions, VirtualizerOptions } from '../src/index' + +type ScrollAlignment = NonNullable + +// Behaviour snapshot for refactors of the scroll machinery. +// +// Seeded random event sequences (scroll commands, read-backs, smooth +// animation steps, content resizes delivered by ResizeObserver, prepends, +// user drags, touches, renders, frames, waits that fire due timers, the +// reconcile safety valve) +// run against a simulated browser, on iOS and non-iOS, across a set of layout +// options. Each sequence records only what a consumer can observe: every +// write to the element, where it rests after each step, and what the adapter +// renders. The traces are hashed per seed and compared with the committed +// snapshot. +// +// A refactor with no behaviour change leaves the snapshot untouched. A fix +// that changes behaviour updates it (`vitest -u`), and the snapshot diff shows +// which configurations and seeds moved. To read one trace in full: +// +// SCROLL_TRACE=lanes/ios/17 pnpm vitest run tests/scroll-trace.test.ts +// +// Private state is deliberately not recorded: it is what refactors change. + +type Behavior = 'auto' | 'smooth' + +type Step = + | { t: 'index'; i: number; align: ScrollAlignment; behavior: Behavior } + | { t: 'offset'; to: number; behavior: Behavior } + | { t: 'by'; d: number; behavior: Behavior } + | { t: 'end'; behavior: Behavior } + | { t: 'cancel' } + // Content changes size. `pick` chooses among the rendered rows; the + // ResizeObserver delivers all of a step's rows in one callback. + | { t: 'resize'; picks: Array; sizes: Array } + | { t: 'prepend'; n: number } + | { t: 'append'; n: number } + | { t: 'render' } // the adapter re-renders after onChange + | { t: 'event' } // a scroll event reporting the element, still scrolling + | { t: 'idle' } // a scroll event reporting the element at rest + | { t: 'anim' } // one frame of a native smooth scroll animation + | { t: 'drag'; d: number } // native scroll by the user + | { t: 'touchstart' } + | { t: 'touchend' } + | { t: 'touchcancel' } + | { t: 'frame' } + | { t: 'wait'; ms: number } // time passes: timeouts due by then fire + | { t: 'valve' } // jump past the reconcile safety valve, then a frame + +type Options = Partial> + +type Config = { + name: string + options?: Options + setup?: (v: Virtualizer) => void + // Share of scroll commands issued with `behavior: 'smooth'`. + smooth?: number +} + +const IOS_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)' +const VIEWPORT = 200 +const SEEDS = 60 +const STEPS = 50 + +const configs: Array = [ + { name: 'base' }, + { + name: 'variable', + options: { estimateSize: (i) => 30 + (i % 5) * 15 }, + }, + { name: 'lanes', options: { lanes: 2 } }, + { + name: 'padding', + options: { + paddingStart: 30, + paddingEnd: 40, + scrollPaddingStart: 20, + scrollPaddingEnd: 10, + gap: 5, + }, + }, + { name: 'margin', options: { scrollMargin: 100 } }, + { name: 'anchorEnd', options: { anchorTo: 'end' } }, + { + name: 'predicate', + setup: (v) => { + v.shouldAdjustScrollPositionOnItemSizeChange = (_item, delta) => delta > 0 + }, + }, + { name: 'horizontal', options: { horizontal: true } }, + { name: 'smooth', smooth: 0.8 }, +] + +// mulberry32 +function rng(seed: number) { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) >>> 0 + let t = a + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +// FNV-1a, 32 bit +function hash(s: string) { + let h = 0x811c9dc5 + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i) + h = Math.imul(h, 0x01000193) + } + return (h >>> 0).toString(16).padStart(8, '0') +} + +// A row's size before any content change: fixed per key, unknown to the +// virtualizer until it is measured. +const naturalSize = (key: number) => + 25 + (Math.abs(Math.imul(key + 7, 0x9e3779b1)) % 90) + +const ALIGNS: Array = ['start', 'center', 'end', 'auto'] + +function makeSteps(seed: number, smooth: number): Array { + const r = rng(seed) + const int = (n: number) => Math.floor(r() * n) + const behavior = (): Behavior => (r() < smooth ? 'smooth' : 'auto') + const resize = (n: number): Step => ({ + t: 'resize', + picks: Array.from({ length: n }, () => r()), + sizes: Array.from({ length: n }, () => 20 + int(100)), + }) + // [weight, make] + const table: Array<[number, () => Step]> = [ + [ + 6, + () => ({ + t: 'index', + i: int(100), + align: ALIGNS[int(4)]!, + behavior: behavior(), + }), + ], + [2, () => ({ t: 'offset', to: int(5200), behavior: behavior() })], + [3, () => ({ t: 'by', d: int(1200) - 600, behavior: behavior() })], + [2, () => ({ t: 'end', behavior: behavior() })], + [1, () => ({ t: 'cancel' })], + [10, () => resize(1)], + [4, () => resize(2 + int(3))], + [1, () => ({ t: 'prepend', n: 1 + int(5) })], + [1, () => ({ t: 'append', n: 1 + int(5) })], + [10, () => ({ t: 'render' })], + [10, () => ({ t: 'event' })], + [4, () => ({ t: 'idle' })], + [6, () => ({ t: 'anim' })], + [6, () => ({ t: 'drag', d: int(800) - 400 })], + [2, () => ({ t: 'touchstart' })], + [3, () => ({ t: 'touchend' })], + [1, () => ({ t: 'touchcancel' })], + [12, () => ({ t: 'frame' })], + // Either side of the 150ms post-touch tail. + [2, () => ({ t: 'wait', ms: [40, 100, 160, 400][int(4)]! })], + [1, () => ({ t: 'valve' })], + ] + const total = table.reduce((sum, [w]) => sum + w, 0) + const steps: Array = [] + for (let n = 0; n < STEPS; n++) { + let pick = r() * total + for (const [w, make] of table) { + pick -= w + if (pick < 0) { + steps.push(make()) + break + } + } + } + return steps +} + +function runTrace(config: Config, ios: boolean, seed: number): Array { + if (ios) { + Object.defineProperty(navigator, 'userAgent', { + value: IOS_UA, + configurable: true, + }) + } + _resetIOSDetectionForTests() + try { + return simulate(config, ios, makeSteps(seed, config.smooth ?? 0.3)) + } finally { + delete (navigator as any).userAgent + _resetIOSDetectionForTests() + } +} + +type Row = { + index: number + isConnected: boolean + getAttribute: (name: string) => string | null +} + +function simulate( + config: Config, + ios: boolean, + steps: Array, +): Array { + const options = config.options ?? {} + const rafs: Array = [] + const timers = new Map void; due: number }>() + let timerId = 0 + let clock = 0 + // The ResizeObserver callback the virtualizer registers. + const ro: { deliver: ((rows: Array) => void) | null } = { + deliver: null, + } + const win = { + requestAnimationFrame: (cb: FrameRequestCallback) => rafs.push(cb), + cancelAnimationFrame: () => {}, + setTimeout: (fn: () => void, ms = 0) => { + timers.set(++timerId, { fn, due: clock + ms }) + return timerId + }, + clearTimeout: (id: number) => timers.delete(id), + performance: { now: () => clock }, + ResizeObserver: class { + constructor(cb: (entries: Array<{ target: Row }>) => void) { + ro.deliver = (rows) => cb(rows.map((target) => ({ target }))) + } + observe() {} + unobserve() {} + disconnect() {} + }, + } + const listeners: Record void>> = {} + // Both axes mirror each other, so `horizontal` runs the same scenario. + const el = { + pos: 0, + size: 0, + get scrollTop() { + return this.pos + }, + set scrollTop(v: number) { + this.pos = v + }, + get scrollLeft() { + return this.pos + }, + set scrollLeft(v: number) { + this.pos = v + }, + get scrollHeight() { + return this.size + }, + get scrollWidth() { + return this.size + }, + clientHeight: VIEWPORT, + clientWidth: VIEWPORT, + offsetHeight: VIEWPORT, + offsetWidth: VIEWPORT, + ownerDocument: { defaultView: win }, + addEventListener: (type: string, fn: () => void) => { + ;(listeners[type] ??= []).push(fn) + }, + removeEventListener: (type: string, fn: () => void) => { + listeners[type] = (listeners[type] ?? []).filter((f) => f !== fn) + }, + } + const max = () => Math.max(0, el.size - VIEWPORT) + // The browser keeps whole pixels, so a read-back can differ from a + // fractional write. + const clamp = (x: number) => Math.round(Math.max(0, Math.min(x, max()))) + let ids = Array.from({ length: 100 }, (_, i) => i) + let nextId = 100 + const content = new Map() + const sizeOf = (index: number) => { + const key = ids[index]! + return content.get(key) ?? naturalSize(key) + } + const rows = new Map() + let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = + null + let touching = false + // A native smooth scroll in flight, and a frame of it computed before an + // instant write replaced it, which WebKit can still apply and report. + let smoothTarget: number | null = null + let lateFrame: number | null = null + let writes: Array = [] + let rendered: Array = [] + + const v = new Virtualizer({ + count: ids.length, + estimateSize: () => 50, + getItemKey: (i) => ids[i]!, + getScrollElement: () => el as any, + measureElement: (node) => sizeOf((node as unknown as Row).index), + scrollToFn: (offset, { adjustments = 0, behavior }) => { + const to = offset + adjustments + writes.push(`${behavior === 'smooth' ? 's' : 'w'}${to}`) + // iOS ignores a programmatic write while a finger is down. + if (ios && touching) return + if (behavior === 'smooth') { + smoothTarget = clamp(to) + lateFrame = null + } else { + if (smoothTarget !== null) { + lateFrame = Math.round((el.pos + smoothTarget) / 2) + } + smoothTarget = null + el.pos = clamp(to) + } + }, + observeElementRect: (_inst, cb) => { + cb({ width: VIEWPORT, height: VIEWPORT }) + return () => {} + }, + observeElementOffset: (_inst, cb) => { + scrollCallback = cb + cb(el.pos, false) + return () => {} + }, + ...options, + }) + config.setup?.(v) + const unmount = v._didMount() + + // What a framework adapter does on render: read the items, mount and + // measure their rows, run the update hook, and size the container. A + // container that shrinks under the position clamps it; the scroll event + // for that comes with the next event step. + const render = () => { + const items = v.getVirtualItems() + rendered = items.map((item) => item.index) + const mounted = new Set() + for (const item of items) { + const key = ids[item.index]! + mounted.add(key) + let row = rows.get(key) + if (!row) { + const node: Row = { + index: item.index, + isConnected: true, + getAttribute: (name) => + name === 'data-index' ? String(node.index) : null, + } + row = node + rows.set(key, row) + } + row.index = item.index + row.isConnected = true + v.measureElement(row as any) + } + let unmounted = false + for (const [key, row] of rows) { + if (!mounted.has(key)) { + row.isConnected = false + rows.delete(key) + unmounted = true + } + } + if (unmounted) v.measureElement(null) + v._willUpdate() + el.size = v.getTotalSize() + (v.options.scrollMargin ?? 0) + el.pos = clamp(el.pos) + return `r${rendered[0] ?? '-'}-${rendered[rendered.length - 1] ?? '-'}/${el.size}` + } + const scroll = (isScrolling: boolean) => scrollCallback!(el.pos, isScrolling) + const frame = () => rafs.splice(0).forEach((cb) => cb(clock)) + // Move the clock forward, firing each timeout at its due time in order. + const advance = (ms: number) => { + const until = clock + ms + for (;;) { + let next: [number, { fn: () => void; due: number }] | undefined + for (const entry of timers) { + if (entry[1].due <= until && (!next || entry[1].due < next[1].due)) { + next = entry + } + } + if (!next) break + timers.delete(next[0]) + clock = Math.max(clock, next[1].due) + next[1].fn() + } + clock = until + } + const fire = (type: string) => listeners[type]?.forEach((fn) => fn()) + + const trace: Array = [] + const record = (label: string, extra = '') => { + trace.push( + `${label} ${writes.join(',')} @${el.pos}${extra ? ' ' + extra : ''}`, + ) + writes = [] + } + + // An adapter renders before mount (nothing to show yet) and again after. + render() + record('init', render()) + + for (const step of steps) { + let extra = '' + switch (step.t) { + case 'index': + v.scrollToIndex(step.i % v.options.count, { + align: step.align, + behavior: step.behavior, + }) + break + case 'offset': + v.scrollToOffset(step.to, { behavior: step.behavior }) + break + case 'by': + v.scrollBy(step.d, { behavior: step.behavior }) + break + case 'end': + v.scrollToEnd({ behavior: step.behavior }) + break + case 'cancel': + v.cancelScroll() + break + case 'resize': { + const changed: Array = [] + step.picks.forEach((pick, k) => { + const index = rendered[Math.floor(pick * rendered.length)] + if (index === undefined) return + content.set(ids[index]!, step.sizes[k]!) + const row = rows.get(ids[index]!) + if (row && !changed.includes(row)) changed.push(row) + }) + if (changed.length > 0) ro.deliver?.(changed) + extra = changed.map((row) => `#${row.index}`).join('') + break + } + case 'prepend': + ids = [...Array.from({ length: step.n }, () => -++nextId), ...ids] + v.setOptions({ ...v.options, count: ids.length }) + extra = render() + break + case 'append': + ids = [...ids, ...Array.from({ length: step.n }, () => ++nextId)] + v.setOptions({ ...v.options, count: ids.length }) + extra = render() + break + case 'render': + extra = render() + break + case 'event': + scroll(true) + break + case 'idle': + scroll(false) + break + case 'anim': + if (lateFrame !== null) { + el.pos = clamp(lateFrame) + lateFrame = null + scroll(true) + } else if (smoothTarget !== null) { + const remaining = smoothTarget - el.pos + el.pos = + Math.abs(remaining) <= 40 + ? smoothTarget + : Math.round(el.pos + remaining / 2) + if (el.pos === smoothTarget) smoothTarget = null + scroll(true) + } + break + case 'drag': + // A user scroll stops a native smooth animation. + smoothTarget = null + lateFrame = null + el.pos = clamp(el.pos + step.d) + scroll(true) + break + case 'touchstart': + touching = true + smoothTarget = null + lateFrame = null + fire('touchstart') + break + case 'touchend': + touching = false + fire('touchend') + break + case 'touchcancel': + touching = false + fire('touchcancel') + break + case 'frame': + frame() + break + case 'wait': + advance(step.ms) + break + case 'valve': + advance(6000) + frame() + break + } + advance(16) + record(step.t, extra) + } + + // Settle: the finger lifts, any animation finishes, reconcile runs while + // the element reports motion, then rest, timers, and reconcile at rest. + if (touching) { + touching = false + fire('touchend') + } + lateFrame = null + if (smoothTarget !== null) { + el.pos = smoothTarget + smoothTarget = null + scroll(true) + } + for (let i = 0; i < 20 && rafs.length > 0; i++) { + frame() + render() + scroll(true) + } + scroll(false) + advance(1000) + for (let i = 0; i < 20 && rafs.length > 0; i++) { + frame() + render() + scroll(false) + } + record('settled', render()) + unmount() + return trace +} + +const key = (config: Config, ios: boolean, seed: number) => + `${config.name}/${ios ? 'ios' : 'web'}/${seed}` + +const only = process.env.SCROLL_TRACE + +describe.skipIf(!!only)('scroll behaviour snapshot', () => { + for (const config of configs) { + for (const ios of [false, true]) { + test(`${config.name} ${ios ? 'ios' : 'web'}`, () => { + const hashes: Array = [] + for (let seed = 0; seed < SEEDS; seed++) { + hashes.push(hash(runTrace(config, ios, seed).join('\n'))) + } + // Ten per row so a diff points at the seeds that moved. + const rows: Array = [] + for (let k = 0; k < hashes.length; k += 10) { + rows.push(`${k}: ${hashes.slice(k, k + 10).join(' ')}`) + } + expect(rows).toMatchSnapshot() + }) + } + } +}) + +describe.runIf(!!only)('scroll trace', () => { + test(`${only}`, () => { + const [name, platform, seed] = only!.split('/') + const config = configs.find((c) => c.name === name) + if (!config) throw new Error(`unknown config ${name}`) + const trace = runTrace(config, platform === 'ios', Number(seed)) + // Vitest captures console output; write straight to stderr. + process.stderr.write( + `${key(config, platform === 'ios', Number(seed))}\n${trace.join('\n')}\n`, + ) + }) +}) From 676a098bfbe168a9bcb55ce3cca984823a85cea7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:03:46 +0200 Subject: [PATCH 12/46] ci: Version Packages (#1317) --- .changeset/pending-write-state.md | 5 - benchmarks/CHANGELOG.md | 7 + benchmarks/package.json | 2 +- examples/angular/chat/package.json | 2 +- examples/angular/dynamic/package.json | 2 +- examples/angular/fixed/package.json | 2 +- examples/angular/infinite-scroll/package.json | 2 +- examples/angular/padding/package.json | 2 +- examples/angular/smooth-scroll/package.json | 2 +- examples/angular/sticky/package.json | 2 +- examples/angular/table/package.json | 2 +- examples/angular/variable/package.json | 2 +- examples/angular/window/package.json | 2 +- examples/lit/dynamic/package.json | 4 +- examples/lit/fixed/package.json | 4 +- examples/marko/chat-pretext/package.json | 2 +- examples/marko/chat/package.json | 2 +- examples/marko/dynamic/package.json | 2 +- examples/marko/fixed/package.json | 2 +- examples/marko/grid/package.json | 2 +- examples/marko/infinite-scroll/package.json | 2 +- examples/marko/padding/package.json | 2 +- examples/marko/pretext/package.json | 2 +- examples/marko/scroll-padding/package.json | 2 +- examples/marko/smooth-scroll/package.json | 2 +- examples/marko/ssr-fetch/package.json | 2 +- examples/marko/ssr-restore/package.json | 2 +- examples/marko/ssr-slice/package.json | 2 +- examples/marko/ssr/package.json | 2 +- examples/marko/sticky/package.json | 2 +- examples/marko/table/package.json | 2 +- examples/marko/variable/package.json | 2 +- examples/marko/window-ssr-slice/package.json | 2 +- examples/marko/window/package.json | 2 +- examples/react/chat/package.json | 2 +- examples/react/dynamic/package.json | 2 +- examples/react/fixed/package.json | 2 +- examples/react/infinite-scroll/package.json | 2 +- examples/react/padding/package.json | 2 +- examples/react/pretext/package.json | 2 +- examples/react/scroll-padding/package.json | 2 +- examples/react/smooth-scroll/package.json | 2 +- examples/react/sticky/package.json | 2 +- examples/react/table/package.json | 2 +- examples/react/variable/package.json | 2 +- examples/react/window/package.json | 2 +- examples/svelte/dynamic/package.json | 2 +- examples/svelte/fixed/package.json | 2 +- examples/svelte/infinite-scroll/package.json | 2 +- examples/svelte/smooth-scroll/package.json | 2 +- examples/svelte/sticky/package.json | 2 +- examples/svelte/table/package.json | 2 +- examples/vue/dynamic/package.json | 2 +- examples/vue/fixed/package.json | 2 +- examples/vue/infinite-scroll/package.json | 2 +- examples/vue/padding/package.json | 2 +- examples/vue/scroll-padding/package.json | 2 +- examples/vue/smooth-scroll/package.json | 2 +- examples/vue/sticky/package.json | 2 +- examples/vue/table/package.json | 2 +- examples/vue/variable/package.json | 2 +- packages/angular-virtual/CHANGELOG.md | 7 + packages/angular-virtual/package.json | 2 +- packages/lit-virtual/CHANGELOG.md | 7 + packages/lit-virtual/package.json | 2 +- packages/marko-virtual/CHANGELOG.md | 7 + packages/marko-virtual/package.json | 2 +- packages/react-virtual/CHANGELOG.md | 7 + packages/react-virtual/package.json | 2 +- packages/solid-virtual/CHANGELOG.md | 7 + packages/solid-virtual/package.json | 2 +- packages/svelte-virtual/CHANGELOG.md | 7 + packages/svelte-virtual/package.json | 2 +- packages/virtual-core/CHANGELOG.md | 6 + packages/virtual-core/package.json | 2 +- packages/vue-virtual/CHANGELOG.md | 7 + packages/vue-virtual/package.json | 2 +- pnpm-lock.yaml | 120 +++++++++--------- 78 files changed, 191 insertions(+), 134 deletions(-) delete mode 100644 .changeset/pending-write-state.md diff --git a/.changeset/pending-write-state.md b/.changeset/pending-write-state.md deleted file mode 100644 index f909931a6..000000000 --- a/.changeset/pending-write-state.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/virtual-core': patch ---- - -refactor(virtual-core): simplify the scroll-correction state: a scroll write is tracked until its read-back as one record, the iOS touch gesture as one record, the pending prepend anchor as a tagged record, and every scroll command starts through one entry point (no behaviour change, with two exceptions: a second `touchend`/`touchcancel` inside the post-touch grace window now restarts the window instead of leaving the first timer to end it early, and after the scroll element is swapped or the virtualizer disabled, a write to the old element is no longer matched to the new element's first scroll report). `scrollAdjustments` becomes a read-only getter with the same value (assigning to it is no longer supported). diff --git a/benchmarks/CHANGELOG.md b/benchmarks/CHANGELOG.md index 1cd51a53e..be3200a14 100644 --- a/benchmarks/CHANGELOG.md +++ b/benchmarks/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/virtual-benchmarks +## 0.0.21 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/react-virtual@3.14.18 + ## 0.0.20 ### Patch Changes diff --git a/benchmarks/package.json b/benchmarks/package.json index fcb0b7514..7256415ec 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -1,7 +1,7 @@ { "name": "@tanstack/virtual-benchmarks", "private": true, - "version": "0.0.20", + "version": "0.0.21", "type": "module", "scripts": { "dev": "vite", diff --git a/examples/angular/chat/package.json b/examples/angular/chat/package.json index 2e68cb346..12fbf1fc9 100644 --- a/examples/angular/chat/package.json +++ b/examples/angular/chat/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.3", + "@tanstack/angular-virtual": "^6.1.4", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/dynamic/package.json b/examples/angular/dynamic/package.json index c12b3c5ef..4feda9db2 100644 --- a/examples/angular/dynamic/package.json +++ b/examples/angular/dynamic/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", "@faker-js/faker": "^8.4.1", - "@tanstack/angular-virtual": "^6.1.3", + "@tanstack/angular-virtual": "^6.1.4", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/fixed/package.json b/examples/angular/fixed/package.json index 21e5ad3d9..cd378a4c5 100644 --- a/examples/angular/fixed/package.json +++ b/examples/angular/fixed/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.3", + "@tanstack/angular-virtual": "^6.1.4", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/infinite-scroll/package.json b/examples/angular/infinite-scroll/package.json index 12d13f822..a66916665 100644 --- a/examples/angular/infinite-scroll/package.json +++ b/examples/angular/infinite-scroll/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", "@tanstack/angular-query-experimental": "5.80.7", - "@tanstack/angular-virtual": "^6.1.3", + "@tanstack/angular-virtual": "^6.1.4", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/padding/package.json b/examples/angular/padding/package.json index d720bce97..e15482fca 100644 --- a/examples/angular/padding/package.json +++ b/examples/angular/padding/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.3", + "@tanstack/angular-virtual": "^6.1.4", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/smooth-scroll/package.json b/examples/angular/smooth-scroll/package.json index ffbe1bc0c..d41915ba9 100644 --- a/examples/angular/smooth-scroll/package.json +++ b/examples/angular/smooth-scroll/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.3", + "@tanstack/angular-virtual": "^6.1.4", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/sticky/package.json b/examples/angular/sticky/package.json index a982e329e..df3815d7b 100644 --- a/examples/angular/sticky/package.json +++ b/examples/angular/sticky/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", "@faker-js/faker": "^8.4.1", - "@tanstack/angular-virtual": "^6.1.3", + "@tanstack/angular-virtual": "^6.1.4", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/table/package.json b/examples/angular/table/package.json index c8d3cde31..fe0f49c04 100644 --- a/examples/angular/table/package.json +++ b/examples/angular/table/package.json @@ -19,7 +19,7 @@ "@angular/router": "^20.2.0", "@faker-js/faker": "^8.4.1", "@tanstack/angular-table": "8.21.3", - "@tanstack/angular-virtual": "^6.1.3", + "@tanstack/angular-virtual": "^6.1.4", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/variable/package.json b/examples/angular/variable/package.json index 6bd3c36c1..fed1c861c 100644 --- a/examples/angular/variable/package.json +++ b/examples/angular/variable/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.3", + "@tanstack/angular-virtual": "^6.1.4", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/window/package.json b/examples/angular/window/package.json index 8de02ba8a..b85770e7b 100644 --- a/examples/angular/window/package.json +++ b/examples/angular/window/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.3", + "@tanstack/angular-virtual": "^6.1.4", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/lit/dynamic/package.json b/examples/lit/dynamic/package.json index c514284c3..027a588f6 100644 --- a/examples/lit/dynamic/package.json +++ b/examples/lit/dynamic/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/lit-virtual": "^3.14.6", - "@tanstack/virtual-core": "^3.19.2", + "@tanstack/lit-virtual": "^3.14.7", + "@tanstack/virtual-core": "^3.19.3", "lit": "^3.3.0" }, "devDependencies": { diff --git a/examples/lit/fixed/package.json b/examples/lit/fixed/package.json index 436df85a8..e9fee9f99 100644 --- a/examples/lit/fixed/package.json +++ b/examples/lit/fixed/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/lit-virtual": "^3.14.6", - "@tanstack/virtual-core": "^3.19.2", + "@tanstack/lit-virtual": "^3.14.7", + "@tanstack/virtual-core": "^3.19.3", "lit": "^3.3.0" }, "devDependencies": { diff --git a/examples/marko/chat-pretext/package.json b/examples/marko/chat-pretext/package.json index e7caaab9d..794db71f6 100644 --- a/examples/marko/chat-pretext/package.json +++ b/examples/marko/chat-pretext/package.json @@ -11,7 +11,7 @@ "dependencies": { "@chenglou/pretext": "^0.0.7", "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/chat/package.json b/examples/marko/chat/package.json index aaa8894e5..e34518b74 100644 --- a/examples/marko/chat/package.json +++ b/examples/marko/chat/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/dynamic/package.json b/examples/marko/dynamic/package.json index 1c767b52c..7e5b82676 100644 --- a/examples/marko/dynamic/package.json +++ b/examples/marko/dynamic/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/fixed/package.json b/examples/marko/fixed/package.json index 7affe0dfc..0a4796132 100644 --- a/examples/marko/fixed/package.json +++ b/examples/marko/fixed/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/grid/package.json b/examples/marko/grid/package.json index f2bd17488..4adfeaf3a 100644 --- a/examples/marko/grid/package.json +++ b/examples/marko/grid/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/infinite-scroll/package.json b/examples/marko/infinite-scroll/package.json index 596218c17..b349089ba 100644 --- a/examples/marko/infinite-scroll/package.json +++ b/examples/marko/infinite-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/padding/package.json b/examples/marko/padding/package.json index 07c7722c6..66de53bbb 100644 --- a/examples/marko/padding/package.json +++ b/examples/marko/padding/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/pretext/package.json b/examples/marko/pretext/package.json index 5dadfb1ec..f62fd8aa0 100644 --- a/examples/marko/pretext/package.json +++ b/examples/marko/pretext/package.json @@ -11,7 +11,7 @@ "dependencies": { "@chenglou/pretext": "^0.0.7", "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/scroll-padding/package.json b/examples/marko/scroll-padding/package.json index a5bdf47f1..07545cabd 100644 --- a/examples/marko/scroll-padding/package.json +++ b/examples/marko/scroll-padding/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/smooth-scroll/package.json b/examples/marko/smooth-scroll/package.json index 447f25423..795afdd62 100644 --- a/examples/marko/smooth-scroll/package.json +++ b/examples/marko/smooth-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr-fetch/package.json b/examples/marko/ssr-fetch/package.json index 664223d0c..bbdaacea8 100644 --- a/examples/marko/ssr-fetch/package.json +++ b/examples/marko/ssr-fetch/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr-restore/package.json b/examples/marko/ssr-restore/package.json index 0bfaba423..a08133593 100644 --- a/examples/marko/ssr-restore/package.json +++ b/examples/marko/ssr-restore/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr-slice/package.json b/examples/marko/ssr-slice/package.json index 7edbef812..02e573183 100644 --- a/examples/marko/ssr-slice/package.json +++ b/examples/marko/ssr-slice/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr/package.json b/examples/marko/ssr/package.json index 45bc3886f..867a7f3e3 100644 --- a/examples/marko/ssr/package.json +++ b/examples/marko/ssr/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/sticky/package.json b/examples/marko/sticky/package.json index f681f51eb..f37a3a9bf 100644 --- a/examples/marko/sticky/package.json +++ b/examples/marko/sticky/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/table/package.json b/examples/marko/table/package.json index e21385598..1170ed9e0 100644 --- a/examples/marko/table/package.json +++ b/examples/marko/table/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/variable/package.json b/examples/marko/variable/package.json index 5173d26bd..5aba1d811 100644 --- a/examples/marko/variable/package.json +++ b/examples/marko/variable/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/window-ssr-slice/package.json b/examples/marko/window-ssr-slice/package.json index af7a6a41b..9e76ead81 100644 --- a/examples/marko/window-ssr-slice/package.json +++ b/examples/marko/window-ssr-slice/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/window/package.json b/examples/marko/window/package.json index af2059bc8..121b01451 100644 --- a/examples/marko/window/package.json +++ b/examples/marko/window/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.3", + "@tanstack/marko-virtual": "^3.16.4", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/react/chat/package.json b/examples/react/chat/package.json index 7e53bf135..1aa4b06f0 100644 --- a/examples/react/chat/package.json +++ b/examples/react/chat/package.json @@ -8,7 +8,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.17", + "@tanstack/react-virtual": "^3.14.18", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/dynamic/package.json b/examples/react/dynamic/package.json index 6d157cbfc..9c21af65c 100644 --- a/examples/react/dynamic/package.json +++ b/examples/react/dynamic/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/react-virtual": "^3.14.17", + "@tanstack/react-virtual": "^3.14.18", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/fixed/package.json b/examples/react/fixed/package.json index f35e42ddf..1b77b5c8e 100644 --- a/examples/react/fixed/package.json +++ b/examples/react/fixed/package.json @@ -8,7 +8,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.17", + "@tanstack/react-virtual": "^3.14.18", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/infinite-scroll/package.json b/examples/react/infinite-scroll/package.json index 80a16c001..8d144e2b9 100644 --- a/examples/react/infinite-scroll/package.json +++ b/examples/react/infinite-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.80.7", - "@tanstack/react-virtual": "^3.14.17", + "@tanstack/react-virtual": "^3.14.18", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/padding/package.json b/examples/react/padding/package.json index f66c55b07..211676de6 100644 --- a/examples/react/padding/package.json +++ b/examples/react/padding/package.json @@ -9,7 +9,7 @@ "start": "vite" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.17", + "@tanstack/react-virtual": "^3.14.18", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/pretext/package.json b/examples/react/pretext/package.json index d49b3467e..f7afa9010 100644 --- a/examples/react/pretext/package.json +++ b/examples/react/pretext/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@chenglou/pretext": "^0.0.7", - "@tanstack/react-virtual": "^3.14.17", + "@tanstack/react-virtual": "^3.14.18", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/scroll-padding/package.json b/examples/react/scroll-padding/package.json index 750461085..ac9c5791b 100644 --- a/examples/react/scroll-padding/package.json +++ b/examples/react/scroll-padding/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@react-hookz/web": "^25.1.1", - "@tanstack/react-virtual": "^3.14.17", + "@tanstack/react-virtual": "^3.14.18", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/smooth-scroll/package.json b/examples/react/smooth-scroll/package.json index 3e49051b7..ff5c39f0e 100644 --- a/examples/react/smooth-scroll/package.json +++ b/examples/react/smooth-scroll/package.json @@ -9,7 +9,7 @@ "start": "vite" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.17", + "@tanstack/react-virtual": "^3.14.18", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/sticky/package.json b/examples/react/sticky/package.json index ba92a154c..26c825319 100644 --- a/examples/react/sticky/package.json +++ b/examples/react/sticky/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/react-virtual": "^3.14.17", + "@tanstack/react-virtual": "^3.14.18", "lodash": "^4.17.21", "react": "^19.2.7", "react-dom": "^19.2.7" diff --git a/examples/react/table/package.json b/examples/react/table/package.json index 6a77ae747..2f6957118 100644 --- a/examples/react/table/package.json +++ b/examples/react/table/package.json @@ -11,7 +11,7 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.14.17", + "@tanstack/react-virtual": "^3.14.18", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/variable/package.json b/examples/react/variable/package.json index dfef8c9a0..3938a9d77 100644 --- a/examples/react/variable/package.json +++ b/examples/react/variable/package.json @@ -9,7 +9,7 @@ "start": "vite" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.17", + "@tanstack/react-virtual": "^3.14.18", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/window/package.json b/examples/react/window/package.json index e971f0d62..f3ef6b110 100644 --- a/examples/react/window/package.json +++ b/examples/react/window/package.json @@ -8,7 +8,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.17", + "@tanstack/react-virtual": "^3.14.18", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/svelte/dynamic/package.json b/examples/svelte/dynamic/package.json index a05078641..bc13505c0 100644 --- a/examples/svelte/dynamic/package.json +++ b/examples/svelte/dynamic/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/svelte-virtual": "^3.13.43" + "@tanstack/svelte-virtual": "^3.13.44" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/fixed/package.json b/examples/svelte/fixed/package.json index cbe3ec261..d9bb3c372 100644 --- a/examples/svelte/fixed/package.json +++ b/examples/svelte/fixed/package.json @@ -9,7 +9,7 @@ "check": "svelte-check --tsconfig ./tsconfig.json" }, "dependencies": { - "@tanstack/svelte-virtual": "^3.13.43" + "@tanstack/svelte-virtual": "^3.13.44" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/infinite-scroll/package.json b/examples/svelte/infinite-scroll/package.json index 877239938..8c443f286 100644 --- a/examples/svelte/infinite-scroll/package.json +++ b/examples/svelte/infinite-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@tanstack/svelte-query": "^5.80.7", - "@tanstack/svelte-virtual": "^3.13.43" + "@tanstack/svelte-virtual": "^3.13.44" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/smooth-scroll/package.json b/examples/svelte/smooth-scroll/package.json index dd33fa081..c895a0908 100644 --- a/examples/svelte/smooth-scroll/package.json +++ b/examples/svelte/smooth-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/svelte-virtual": "^3.13.43" + "@tanstack/svelte-virtual": "^3.13.44" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/sticky/package.json b/examples/svelte/sticky/package.json index 064ddf61f..b762d7338 100644 --- a/examples/svelte/sticky/package.json +++ b/examples/svelte/sticky/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/svelte-virtual": "^3.13.43", + "@tanstack/svelte-virtual": "^3.13.44", "lodash": "^4.17.21" }, "devDependencies": { diff --git a/examples/svelte/table/package.json b/examples/svelte/table/package.json index 86ebdc02d..2eecf9b30 100644 --- a/examples/svelte/table/package.json +++ b/examples/svelte/table/package.json @@ -11,7 +11,7 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@tanstack/svelte-table": "^8.21.3", - "@tanstack/svelte-virtual": "^3.13.43" + "@tanstack/svelte-virtual": "^3.13.44" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/vue/dynamic/package.json b/examples/vue/dynamic/package.json index 436edabc9..1b64b35dc 100644 --- a/examples/vue/dynamic/package.json +++ b/examples/vue/dynamic/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/vue-virtual": "^3.13.43", + "@tanstack/vue-virtual": "^3.13.44", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/fixed/package.json b/examples/vue/fixed/package.json index 9a725780b..269b1bd62 100644 --- a/examples/vue/fixed/package.json +++ b/examples/vue/fixed/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.43", + "@tanstack/vue-virtual": "^3.13.44", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/infinite-scroll/package.json b/examples/vue/infinite-scroll/package.json index 0936671a0..7323502e4 100644 --- a/examples/vue/infinite-scroll/package.json +++ b/examples/vue/infinite-scroll/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@tanstack/vue-query": "^5.80.7", - "@tanstack/vue-virtual": "^3.13.43", + "@tanstack/vue-virtual": "^3.13.44", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/padding/package.json b/examples/vue/padding/package.json index f16708156..111088356 100644 --- a/examples/vue/padding/package.json +++ b/examples/vue/padding/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.43", + "@tanstack/vue-virtual": "^3.13.44", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/scroll-padding/package.json b/examples/vue/scroll-padding/package.json index 20f347d2e..240f87501 100644 --- a/examples/vue/scroll-padding/package.json +++ b/examples/vue/scroll-padding/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.43", + "@tanstack/vue-virtual": "^3.13.44", "@vueuse/core": "^12.8.2", "vue": "^3.5.16" }, diff --git a/examples/vue/smooth-scroll/package.json b/examples/vue/smooth-scroll/package.json index b7c364ec9..66791047d 100644 --- a/examples/vue/smooth-scroll/package.json +++ b/examples/vue/smooth-scroll/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.43", + "@tanstack/vue-virtual": "^3.13.44", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/sticky/package.json b/examples/vue/sticky/package.json index f8584ba31..d055ba41e 100644 --- a/examples/vue/sticky/package.json +++ b/examples/vue/sticky/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/vue-virtual": "^3.13.43", + "@tanstack/vue-virtual": "^3.13.44", "lodash": "^4.17.21", "vue": "^3.5.16" }, diff --git a/examples/vue/table/package.json b/examples/vue/table/package.json index 853d8a0b4..f1a63a907 100644 --- a/examples/vue/table/package.json +++ b/examples/vue/table/package.json @@ -10,7 +10,7 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@tanstack/vue-table": "^8.21.3", - "@tanstack/vue-virtual": "^3.13.43", + "@tanstack/vue-virtual": "^3.13.44", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/variable/package.json b/examples/vue/variable/package.json index 28dad187d..fffaaf50b 100644 --- a/examples/vue/variable/package.json +++ b/examples/vue/variable/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.43", + "@tanstack/vue-virtual": "^3.13.44", "vue": "^3.5.16" }, "devDependencies": { diff --git a/packages/angular-virtual/CHANGELOG.md b/packages/angular-virtual/CHANGELOG.md index b65edcb29..c5697dd31 100644 --- a/packages/angular-virtual/CHANGELOG.md +++ b/packages/angular-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/angular-virtual +## 6.1.4 + +### Patch Changes + +- Updated dependencies [[`f211d3a`](https://github.com/TanStack/virtual/commit/f211d3a64de6cbdd85469c9b97959db65df32eb6)]: + - @tanstack/virtual-core@3.19.3 + ## 6.1.3 ### Patch Changes diff --git a/packages/angular-virtual/package.json b/packages/angular-virtual/package.json index 29180e3ac..dcef1856c 100644 --- a/packages/angular-virtual/package.json +++ b/packages/angular-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/angular-virtual", - "version": "6.1.3", + "version": "6.1.4", "description": "Headless UI for virtualizing scrollable elements in Angular", "author": "Garrett Darnell", "license": "MIT", diff --git a/packages/lit-virtual/CHANGELOG.md b/packages/lit-virtual/CHANGELOG.md index 4dd0aaec4..1ee191cc4 100644 --- a/packages/lit-virtual/CHANGELOG.md +++ b/packages/lit-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/lit-virtual +## 3.14.7 + +### Patch Changes + +- Updated dependencies [[`f211d3a`](https://github.com/TanStack/virtual/commit/f211d3a64de6cbdd85469c9b97959db65df32eb6)]: + - @tanstack/virtual-core@3.19.3 + ## 3.14.6 ### Patch Changes diff --git a/packages/lit-virtual/package.json b/packages/lit-virtual/package.json index 113fefa5c..a66f63eb1 100644 --- a/packages/lit-virtual/package.json +++ b/packages/lit-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/lit-virtual", - "version": "3.14.6", + "version": "3.14.7", "description": "Headless UI for virtualizing scrollable elements in Lit", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/marko-virtual/CHANGELOG.md b/packages/marko-virtual/CHANGELOG.md index 354235b9c..898aef870 100644 --- a/packages/marko-virtual/CHANGELOG.md +++ b/packages/marko-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/marko-virtual +## 3.16.4 + +### Patch Changes + +- Updated dependencies [[`f211d3a`](https://github.com/TanStack/virtual/commit/f211d3a64de6cbdd85469c9b97959db65df32eb6)]: + - @tanstack/virtual-core@3.19.3 + ## 3.16.3 ### Patch Changes diff --git a/packages/marko-virtual/package.json b/packages/marko-virtual/package.json index b666c28b3..4002731e8 100644 --- a/packages/marko-virtual/package.json +++ b/packages/marko-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/marko-virtual", - "version": "3.16.3", + "version": "3.16.4", "description": "Headless UI for virtualizing scrollable elements in Marko 6", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/react-virtual/CHANGELOG.md b/packages/react-virtual/CHANGELOG.md index a9ca6cd6a..2df615bc9 100644 --- a/packages/react-virtual/CHANGELOG.md +++ b/packages/react-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/react-virtual +## 3.14.18 + +### Patch Changes + +- Updated dependencies [[`f211d3a`](https://github.com/TanStack/virtual/commit/f211d3a64de6cbdd85469c9b97959db65df32eb6)]: + - @tanstack/virtual-core@3.19.3 + ## 3.14.17 ### Patch Changes diff --git a/packages/react-virtual/package.json b/packages/react-virtual/package.json index 4449672d2..8dd6fddd9 100644 --- a/packages/react-virtual/package.json +++ b/packages/react-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/react-virtual", - "version": "3.14.17", + "version": "3.14.18", "description": "Headless UI for virtualizing scrollable elements in React", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/solid-virtual/CHANGELOG.md b/packages/solid-virtual/CHANGELOG.md index b497f876d..8aab2202b 100644 --- a/packages/solid-virtual/CHANGELOG.md +++ b/packages/solid-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/solid-virtual +## 3.13.45 + +### Patch Changes + +- Updated dependencies [[`f211d3a`](https://github.com/TanStack/virtual/commit/f211d3a64de6cbdd85469c9b97959db65df32eb6)]: + - @tanstack/virtual-core@3.19.3 + ## 3.13.44 ### Patch Changes diff --git a/packages/solid-virtual/package.json b/packages/solid-virtual/package.json index ec1e09c74..5691d1419 100644 --- a/packages/solid-virtual/package.json +++ b/packages/solid-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/solid-virtual", - "version": "3.13.44", + "version": "3.13.45", "description": "Headless UI for virtualizing scrollable elements in Solid", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/svelte-virtual/CHANGELOG.md b/packages/svelte-virtual/CHANGELOG.md index 84d569db3..603a000b5 100644 --- a/packages/svelte-virtual/CHANGELOG.md +++ b/packages/svelte-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/svelte-virtual +## 3.13.44 + +### Patch Changes + +- Updated dependencies [[`f211d3a`](https://github.com/TanStack/virtual/commit/f211d3a64de6cbdd85469c9b97959db65df32eb6)]: + - @tanstack/virtual-core@3.19.3 + ## 3.13.43 ### Patch Changes diff --git a/packages/svelte-virtual/package.json b/packages/svelte-virtual/package.json index 5eb982101..f03ff0dd3 100644 --- a/packages/svelte-virtual/package.json +++ b/packages/svelte-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/svelte-virtual", - "version": "3.13.43", + "version": "3.13.44", "description": "Headless UI for virtualizing scrollable elements in Svelte", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/virtual-core/CHANGELOG.md b/packages/virtual-core/CHANGELOG.md index c393af0b4..4e1dde222 100644 --- a/packages/virtual-core/CHANGELOG.md +++ b/packages/virtual-core/CHANGELOG.md @@ -1,5 +1,11 @@ # @tanstack/virtual-core +## 3.19.3 + +### Patch Changes + +- [#1316](https://github.com/TanStack/virtual/pull/1316) [`f211d3a`](https://github.com/TanStack/virtual/commit/f211d3a64de6cbdd85469c9b97959db65df32eb6) - refactor(virtual-core): simplify the scroll-correction state: a scroll write is tracked until its read-back as one record, the iOS touch gesture as one record, the pending prepend anchor as a tagged record, and every scroll command starts through one entry point (no behaviour change, with two exceptions: a second `touchend`/`touchcancel` inside the post-touch grace window now restarts the window instead of leaving the first timer to end it early, and after the scroll element is swapped or the virtualizer disabled, a write to the old element is no longer matched to the new element's first scroll report). `scrollAdjustments` becomes a read-only getter with the same value (assigning to it is no longer supported). + ## 3.19.2 ### Patch Changes diff --git a/packages/virtual-core/package.json b/packages/virtual-core/package.json index 77010e67e..dca531cab 100644 --- a/packages/virtual-core/package.json +++ b/packages/virtual-core/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/virtual-core", - "version": "3.19.2", + "version": "3.19.3", "description": "Headless UI for virtualizing scrollable elements in TS/JS + Frameworks", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/vue-virtual/CHANGELOG.md b/packages/vue-virtual/CHANGELOG.md index 59948a7f7..79215edb2 100644 --- a/packages/vue-virtual/CHANGELOG.md +++ b/packages/vue-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/vue-virtual +## 3.13.44 + +### Patch Changes + +- Updated dependencies [[`f211d3a`](https://github.com/TanStack/virtual/commit/f211d3a64de6cbdd85469c9b97959db65df32eb6)]: + - @tanstack/virtual-core@3.19.3 + ## 3.13.43 ### Patch Changes diff --git a/packages/vue-virtual/package.json b/packages/vue-virtual/package.json index 7ad377c0e..3a5275b20 100644 --- a/packages/vue-virtual/package.json +++ b/packages/vue-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/vue-virtual", - "version": "3.13.43", + "version": "3.13.44", "description": "Headless UI for virtualizing scrollable elements in Vue", "author": "Tanner Linsley", "license": "MIT", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b78d8b9a8..ae5da843e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,7 +145,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.3 + specifier: ^6.1.4 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -200,7 +200,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/angular-virtual': - specifier: ^6.1.3 + specifier: ^6.1.4 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -252,7 +252,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.3 + specifier: ^6.1.4 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -307,7 +307,7 @@ importers: specifier: 5.80.7 version: 5.80.7(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)) '@tanstack/angular-virtual': - specifier: ^6.1.3 + specifier: ^6.1.4 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -359,7 +359,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.3 + specifier: ^6.1.4 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -411,7 +411,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.3 + specifier: ^6.1.4 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -466,7 +466,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/angular-virtual': - specifier: ^6.1.3 + specifier: ^6.1.4 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -524,7 +524,7 @@ importers: specifier: 8.21.3 version: 8.21.3(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)) '@tanstack/angular-virtual': - specifier: ^6.1.3 + specifier: ^6.1.4 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -576,7 +576,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.3 + specifier: ^6.1.4 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -628,7 +628,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.3 + specifier: ^6.1.4 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -659,10 +659,10 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/lit-virtual': - specifier: ^3.14.6 + specifier: ^3.14.7 version: link:../../../packages/lit-virtual '@tanstack/virtual-core': - specifier: ^3.19.2 + specifier: ^3.19.3 version: link:../../../packages/virtual-core lit: specifier: ^3.3.0 @@ -684,10 +684,10 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/lit-virtual': - specifier: ^3.14.6 + specifier: ^3.14.7 version: link:../../../packages/lit-virtual '@tanstack/virtual-core': - specifier: ^3.19.2 + specifier: ^3.19.3 version: link:../../../packages/virtual-core lit: specifier: ^3.3.0 @@ -709,7 +709,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -737,7 +737,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -762,7 +762,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -787,7 +787,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -812,7 +812,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -837,7 +837,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -862,7 +862,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -890,7 +890,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -915,7 +915,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -940,7 +940,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -965,7 +965,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -990,7 +990,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1015,7 +1015,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1040,7 +1040,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1065,7 +1065,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1090,7 +1090,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1115,7 +1115,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1140,7 +1140,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1165,7 +1165,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.3 + specifier: ^3.16.4 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1187,7 +1187,7 @@ importers: examples/react/chat: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.17 + specifier: ^3.14.18 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1218,7 +1218,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/react-virtual': - specifier: ^3.14.17 + specifier: ^3.14.18 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1249,7 +1249,7 @@ importers: examples/react/fixed: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.17 + specifier: ^3.14.18 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1283,7 +1283,7 @@ importers: specifier: ^5.80.7 version: 5.90.5(react@19.2.7) '@tanstack/react-virtual': - specifier: ^3.14.17 + specifier: ^3.14.18 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1308,7 +1308,7 @@ importers: examples/react/padding: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.17 + specifier: ^3.14.18 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1336,7 +1336,7 @@ importers: specifier: ^0.0.7 version: 0.0.7 '@tanstack/react-virtual': - specifier: ^3.14.17 + specifier: ^3.14.18 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1370,7 +1370,7 @@ importers: specifier: ^25.1.1 version: 25.1.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-virtual': - specifier: ^3.14.17 + specifier: ^3.14.18 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1395,7 +1395,7 @@ importers: examples/react/smooth-scroll: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.17 + specifier: ^3.14.18 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1423,7 +1423,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/react-virtual': - specifier: ^3.14.17 + specifier: ^3.14.18 version: link:../../../packages/react-virtual lodash: specifier: ^4.17.21 @@ -1460,7 +1460,7 @@ importers: specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-virtual': - specifier: ^3.14.17 + specifier: ^3.14.18 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1485,7 +1485,7 @@ importers: examples/react/variable: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.17 + specifier: ^3.14.18 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1510,7 +1510,7 @@ importers: examples/react/window: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.17 + specifier: ^3.14.18 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1544,7 +1544,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/svelte-virtual': - specifier: ^3.13.43 + specifier: ^3.13.44 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1572,7 +1572,7 @@ importers: examples/svelte/fixed: dependencies: '@tanstack/svelte-virtual': - specifier: ^3.13.43 + specifier: ^3.13.44 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1603,7 +1603,7 @@ importers: specifier: ^5.80.7 version: 5.90.2(svelte@4.2.20) '@tanstack/svelte-virtual': - specifier: ^3.13.43 + specifier: ^3.13.44 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1634,7 +1634,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/svelte-virtual': - specifier: ^3.13.43 + specifier: ^3.13.44 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1665,7 +1665,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/svelte-virtual': - specifier: ^3.13.43 + specifier: ^3.13.44 version: link:../../../packages/svelte-virtual lodash: specifier: ^4.17.21 @@ -1702,7 +1702,7 @@ importers: specifier: ^8.21.3 version: 8.21.3(svelte@4.2.20) '@tanstack/svelte-virtual': - specifier: ^3.13.43 + specifier: ^3.13.44 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1733,7 +1733,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/vue-virtual': - specifier: ^3.13.43 + specifier: ^3.13.44 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1758,7 +1758,7 @@ importers: examples/vue/fixed: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.43 + specifier: ^3.13.44 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1786,7 +1786,7 @@ importers: specifier: ^5.80.7 version: 5.90.5(vue@3.5.22(typescript@5.9.3)) '@tanstack/vue-virtual': - specifier: ^3.13.43 + specifier: ^3.13.44 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1811,7 +1811,7 @@ importers: examples/vue/padding: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.43 + specifier: ^3.13.44 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1836,7 +1836,7 @@ importers: examples/vue/scroll-padding: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.43 + specifier: ^3.13.44 version: link:../../../packages/vue-virtual '@vueuse/core': specifier: ^12.8.2 @@ -1864,7 +1864,7 @@ importers: examples/vue/smooth-scroll: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.43 + specifier: ^3.13.44 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1892,7 +1892,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/vue-virtual': - specifier: ^3.13.43 + specifier: ^3.13.44 version: link:../../../packages/vue-virtual lodash: specifier: ^4.17.21 @@ -1929,7 +1929,7 @@ importers: specifier: ^8.21.3 version: 8.21.3(vue@3.5.22(typescript@5.9.3)) '@tanstack/vue-virtual': - specifier: ^3.13.43 + specifier: ^3.13.44 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1954,7 +1954,7 @@ importers: examples/vue/variable: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.43 + specifier: ^3.13.44 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 From 269e8ed12db0b9ce55543da5b0bb1082a6296aac Mon Sep 17 00:00:00 2001 From: Yoojun Zhou Date: Tue, 13 Oct 2026 23:29:50 +0800 Subject: [PATCH 13/46] fix(virtual-core): request deferred ResizeObserver frames from the scroll element's window (#1320) --- .changeset/raf-scroll-element-window.md | 5 + packages/virtual-core/src/index.ts | 34 ++++- packages/virtual-core/tests/index.test.ts | 146 ++++++++++++++++++++++ 3 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 .changeset/raf-scroll-element-window.md diff --git a/.changeset/raf-scroll-element-window.md b/.changeset/raf-scroll-element-window.md new file mode 100644 index 000000000..c2278cb4d --- /dev/null +++ b/.changeset/raf-scroll-element-window.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): with `useAnimationFrameWithResizeObserver`, request frames from the scroll element's window instead of the global one, and cancel frames still queued on cleanup, so a list rendered into another window does not stall while the opener is minimized or occluded diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index cb5011392..6d7d8ce91 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -86,6 +86,25 @@ const getRect = (element: HTMLElement): Rect => { return { width: offsetWidth, height: offsetHeight } } +// Animation frames for `useAnimationFrameWithResizeObserver`, requested from +// the scroll element's own window rather than the global one. +const createFrameQueue = (targetWindow: Window) => { + const ids = new Set() + return { + schedule: (fn: () => void) => { + const id = targetWindow.requestAnimationFrame(() => { + ids.delete(id) + fn() + }) + ids.add(id) + }, + cancel: () => { + ids.forEach((id) => targetWindow.cancelAnimationFrame(id)) + ids.clear() + }, + } +} + export const defaultKeyExtractor = (index: number) => index export const defaultRangeExtractor = (range: Range) => { @@ -129,6 +148,7 @@ export const observeElementRect = ( return () => {} } + const frames = createFrameQueue(targetWindow) const observer = new targetWindow.ResizeObserver((entries) => { const run = () => { const entry = entries[0] @@ -143,7 +163,7 @@ export const observeElementRect = ( } instance.options.useAnimationFrameWithResizeObserver - ? requestAnimationFrame(run) + ? frames.schedule(run) : run() }) @@ -151,6 +171,7 @@ export const observeElementRect = ( return () => { observer.unobserve(element) + frames.cancel() } } @@ -566,17 +587,20 @@ export class Virtualizer< private now = () => this.targetWindow?.performance?.now?.() ?? Date.now() private observer = (() => { let _ro: ResizeObserver | null = null + let _frames: ReturnType | null = null const get = () => { if (_ro) { return _ro } - if (!this.targetWindow || !this.targetWindow.ResizeObserver) { + const targetWindow = this.targetWindow + if (!targetWindow || !targetWindow.ResizeObserver) { return null } - return (_ro = new this.targetWindow.ResizeObserver((entries) => { + const frames = (_frames = createFrameQueue(targetWindow)) + return (_ro = new targetWindow.ResizeObserver((entries) => { const run = () => { // Measure every entry before notifying. A notify that adjusted // scroll re-renders synchronously, and that render can unmount @@ -596,7 +620,7 @@ export class Virtualizer< } } this.options.useAnimationFrameWithResizeObserver - ? requestAnimationFrame(run) + ? frames.schedule(run) : run() })) } @@ -634,7 +658,9 @@ export class Virtualizer< return { disconnect: () => { get()?.disconnect() + _frames?.cancel() _ro = null + _frames = null }, observe: (target: Element) => get()?.observe(target, { box: 'border-box' }), diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 290d810a3..a50f6e709 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -5,6 +5,7 @@ import { defaultRangeExtractor, elementScroll, observeElementOffset, + observeElementRect, observeWindowOffset, windowScroll, } from '../src/index' @@ -5857,3 +5858,148 @@ test('a write to a scroll element that went away is not adopted by the next one' 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() + }) +}) From 4f9636e36e43fa8fcb67c14e2277b5b7a6461f37 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:18:09 +0200 Subject: [PATCH 14/46] ci: Version Packages (#1322) --- .changeset/raf-scroll-element-window.md | 5 - benchmarks/CHANGELOG.md | 7 + benchmarks/package.json | 2 +- examples/angular/chat/package.json | 2 +- examples/angular/dynamic/package.json | 2 +- examples/angular/fixed/package.json | 2 +- examples/angular/infinite-scroll/package.json | 2 +- examples/angular/padding/package.json | 2 +- examples/angular/smooth-scroll/package.json | 2 +- examples/angular/sticky/package.json | 2 +- examples/angular/table/package.json | 2 +- examples/angular/variable/package.json | 2 +- examples/angular/window/package.json | 2 +- examples/lit/dynamic/package.json | 4 +- examples/lit/fixed/package.json | 4 +- examples/marko/chat-pretext/package.json | 2 +- examples/marko/chat/package.json | 2 +- examples/marko/dynamic/package.json | 2 +- examples/marko/fixed/package.json | 2 +- examples/marko/grid/package.json | 2 +- examples/marko/infinite-scroll/package.json | 2 +- examples/marko/padding/package.json | 2 +- examples/marko/pretext/package.json | 2 +- examples/marko/scroll-padding/package.json | 2 +- examples/marko/smooth-scroll/package.json | 2 +- examples/marko/ssr-fetch/package.json | 2 +- examples/marko/ssr-restore/package.json | 2 +- examples/marko/ssr-slice/package.json | 2 +- examples/marko/ssr/package.json | 2 +- examples/marko/sticky/package.json | 2 +- examples/marko/table/package.json | 2 +- examples/marko/variable/package.json | 2 +- examples/marko/window-ssr-slice/package.json | 2 +- examples/marko/window/package.json | 2 +- examples/react/chat/package.json | 2 +- examples/react/dynamic/package.json | 2 +- examples/react/fixed/package.json | 2 +- examples/react/infinite-scroll/package.json | 2 +- examples/react/padding/package.json | 2 +- examples/react/pretext/package.json | 2 +- examples/react/scroll-padding/package.json | 2 +- examples/react/smooth-scroll/package.json | 2 +- examples/react/sticky/package.json | 2 +- examples/react/table/package.json | 2 +- examples/react/variable/package.json | 2 +- examples/react/window/package.json | 2 +- examples/svelte/dynamic/package.json | 2 +- examples/svelte/fixed/package.json | 2 +- examples/svelte/infinite-scroll/package.json | 2 +- examples/svelte/smooth-scroll/package.json | 2 +- examples/svelte/sticky/package.json | 2 +- examples/svelte/table/package.json | 2 +- examples/vue/dynamic/package.json | 2 +- examples/vue/fixed/package.json | 2 +- examples/vue/infinite-scroll/package.json | 2 +- examples/vue/padding/package.json | 2 +- examples/vue/scroll-padding/package.json | 2 +- examples/vue/smooth-scroll/package.json | 2 +- examples/vue/sticky/package.json | 2 +- examples/vue/table/package.json | 2 +- examples/vue/variable/package.json | 2 +- packages/angular-virtual/CHANGELOG.md | 7 + packages/angular-virtual/package.json | 2 +- packages/lit-virtual/CHANGELOG.md | 7 + packages/lit-virtual/package.json | 2 +- packages/marko-virtual/CHANGELOG.md | 7 + packages/marko-virtual/package.json | 2 +- packages/react-virtual/CHANGELOG.md | 7 + packages/react-virtual/package.json | 2 +- packages/solid-virtual/CHANGELOG.md | 7 + packages/solid-virtual/package.json | 2 +- packages/svelte-virtual/CHANGELOG.md | 7 + packages/svelte-virtual/package.json | 2 +- packages/virtual-core/CHANGELOG.md | 6 + packages/virtual-core/package.json | 2 +- packages/vue-virtual/CHANGELOG.md | 7 + packages/vue-virtual/package.json | 2 +- pnpm-lock.yaml | 120 +++++++++--------- 78 files changed, 191 insertions(+), 134 deletions(-) delete mode 100644 .changeset/raf-scroll-element-window.md diff --git a/.changeset/raf-scroll-element-window.md b/.changeset/raf-scroll-element-window.md deleted file mode 100644 index c2278cb4d..000000000 --- a/.changeset/raf-scroll-element-window.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/virtual-core': patch ---- - -fix(virtual-core): with `useAnimationFrameWithResizeObserver`, request frames from the scroll element's window instead of the global one, and cancel frames still queued on cleanup, so a list rendered into another window does not stall while the opener is minimized or occluded diff --git a/benchmarks/CHANGELOG.md b/benchmarks/CHANGELOG.md index be3200a14..ec841699e 100644 --- a/benchmarks/CHANGELOG.md +++ b/benchmarks/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/virtual-benchmarks +## 0.0.22 + +### Patch Changes + +- Updated dependencies []: + - @tanstack/react-virtual@3.14.19 + ## 0.0.21 ### Patch Changes diff --git a/benchmarks/package.json b/benchmarks/package.json index 7256415ec..4eefdb6e2 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -1,7 +1,7 @@ { "name": "@tanstack/virtual-benchmarks", "private": true, - "version": "0.0.21", + "version": "0.0.22", "type": "module", "scripts": { "dev": "vite", diff --git a/examples/angular/chat/package.json b/examples/angular/chat/package.json index 12fbf1fc9..d46de3d46 100644 --- a/examples/angular/chat/package.json +++ b/examples/angular/chat/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.4", + "@tanstack/angular-virtual": "^6.1.5", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/dynamic/package.json b/examples/angular/dynamic/package.json index 4feda9db2..d958361d4 100644 --- a/examples/angular/dynamic/package.json +++ b/examples/angular/dynamic/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", "@faker-js/faker": "^8.4.1", - "@tanstack/angular-virtual": "^6.1.4", + "@tanstack/angular-virtual": "^6.1.5", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/fixed/package.json b/examples/angular/fixed/package.json index cd378a4c5..7a539cab9 100644 --- a/examples/angular/fixed/package.json +++ b/examples/angular/fixed/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.4", + "@tanstack/angular-virtual": "^6.1.5", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/infinite-scroll/package.json b/examples/angular/infinite-scroll/package.json index a66916665..a769185cb 100644 --- a/examples/angular/infinite-scroll/package.json +++ b/examples/angular/infinite-scroll/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", "@tanstack/angular-query-experimental": "5.80.7", - "@tanstack/angular-virtual": "^6.1.4", + "@tanstack/angular-virtual": "^6.1.5", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/padding/package.json b/examples/angular/padding/package.json index e15482fca..e508efb44 100644 --- a/examples/angular/padding/package.json +++ b/examples/angular/padding/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.4", + "@tanstack/angular-virtual": "^6.1.5", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/smooth-scroll/package.json b/examples/angular/smooth-scroll/package.json index d41915ba9..b778dea75 100644 --- a/examples/angular/smooth-scroll/package.json +++ b/examples/angular/smooth-scroll/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.4", + "@tanstack/angular-virtual": "^6.1.5", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/sticky/package.json b/examples/angular/sticky/package.json index df3815d7b..22461be81 100644 --- a/examples/angular/sticky/package.json +++ b/examples/angular/sticky/package.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", "@faker-js/faker": "^8.4.1", - "@tanstack/angular-virtual": "^6.1.4", + "@tanstack/angular-virtual": "^6.1.5", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/table/package.json b/examples/angular/table/package.json index fe0f49c04..56be3f423 100644 --- a/examples/angular/table/package.json +++ b/examples/angular/table/package.json @@ -19,7 +19,7 @@ "@angular/router": "^20.2.0", "@faker-js/faker": "^8.4.1", "@tanstack/angular-table": "8.21.3", - "@tanstack/angular-virtual": "^6.1.4", + "@tanstack/angular-virtual": "^6.1.5", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/variable/package.json b/examples/angular/variable/package.json index fed1c861c..3b12638b1 100644 --- a/examples/angular/variable/package.json +++ b/examples/angular/variable/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.4", + "@tanstack/angular-virtual": "^6.1.5", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/angular/window/package.json b/examples/angular/window/package.json index b85770e7b..fbec7ffa1 100644 --- a/examples/angular/window/package.json +++ b/examples/angular/window/package.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^20.2.0", "@angular/platform-browser-dynamic": "^20.2.0", "@angular/router": "^20.2.0", - "@tanstack/angular-virtual": "^6.1.4", + "@tanstack/angular-virtual": "^6.1.5", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "0.15.1" diff --git a/examples/lit/dynamic/package.json b/examples/lit/dynamic/package.json index 027a588f6..2a7f9a4ee 100644 --- a/examples/lit/dynamic/package.json +++ b/examples/lit/dynamic/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/lit-virtual": "^3.14.7", - "@tanstack/virtual-core": "^3.19.3", + "@tanstack/lit-virtual": "^3.14.8", + "@tanstack/virtual-core": "^3.19.4", "lit": "^3.3.0" }, "devDependencies": { diff --git a/examples/lit/fixed/package.json b/examples/lit/fixed/package.json index e9fee9f99..bcef5124d 100644 --- a/examples/lit/fixed/package.json +++ b/examples/lit/fixed/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/lit-virtual": "^3.14.7", - "@tanstack/virtual-core": "^3.19.3", + "@tanstack/lit-virtual": "^3.14.8", + "@tanstack/virtual-core": "^3.19.4", "lit": "^3.3.0" }, "devDependencies": { diff --git a/examples/marko/chat-pretext/package.json b/examples/marko/chat-pretext/package.json index 794db71f6..a953ef168 100644 --- a/examples/marko/chat-pretext/package.json +++ b/examples/marko/chat-pretext/package.json @@ -11,7 +11,7 @@ "dependencies": { "@chenglou/pretext": "^0.0.7", "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/chat/package.json b/examples/marko/chat/package.json index e34518b74..3edc398c0 100644 --- a/examples/marko/chat/package.json +++ b/examples/marko/chat/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/dynamic/package.json b/examples/marko/dynamic/package.json index 7e5b82676..4c5173fa4 100644 --- a/examples/marko/dynamic/package.json +++ b/examples/marko/dynamic/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/fixed/package.json b/examples/marko/fixed/package.json index 0a4796132..e687930fa 100644 --- a/examples/marko/fixed/package.json +++ b/examples/marko/fixed/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/grid/package.json b/examples/marko/grid/package.json index 4adfeaf3a..7616cbd10 100644 --- a/examples/marko/grid/package.json +++ b/examples/marko/grid/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/infinite-scroll/package.json b/examples/marko/infinite-scroll/package.json index b349089ba..269ab81f8 100644 --- a/examples/marko/infinite-scroll/package.json +++ b/examples/marko/infinite-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/padding/package.json b/examples/marko/padding/package.json index 66de53bbb..a36a28fb5 100644 --- a/examples/marko/padding/package.json +++ b/examples/marko/padding/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/pretext/package.json b/examples/marko/pretext/package.json index f62fd8aa0..408061510 100644 --- a/examples/marko/pretext/package.json +++ b/examples/marko/pretext/package.json @@ -11,7 +11,7 @@ "dependencies": { "@chenglou/pretext": "^0.0.7", "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/scroll-padding/package.json b/examples/marko/scroll-padding/package.json index 07545cabd..0a808baae 100644 --- a/examples/marko/scroll-padding/package.json +++ b/examples/marko/scroll-padding/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/smooth-scroll/package.json b/examples/marko/smooth-scroll/package.json index 795afdd62..fa96d4d28 100644 --- a/examples/marko/smooth-scroll/package.json +++ b/examples/marko/smooth-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr-fetch/package.json b/examples/marko/ssr-fetch/package.json index bbdaacea8..de466910b 100644 --- a/examples/marko/ssr-fetch/package.json +++ b/examples/marko/ssr-fetch/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr-restore/package.json b/examples/marko/ssr-restore/package.json index a08133593..a86a6d5be 100644 --- a/examples/marko/ssr-restore/package.json +++ b/examples/marko/ssr-restore/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr-slice/package.json b/examples/marko/ssr-slice/package.json index 02e573183..9e02cd9b7 100644 --- a/examples/marko/ssr-slice/package.json +++ b/examples/marko/ssr-slice/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/ssr/package.json b/examples/marko/ssr/package.json index 867a7f3e3..50e376ca5 100644 --- a/examples/marko/ssr/package.json +++ b/examples/marko/ssr/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/sticky/package.json b/examples/marko/sticky/package.json index f37a3a9bf..b8f0399fc 100644 --- a/examples/marko/sticky/package.json +++ b/examples/marko/sticky/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/table/package.json b/examples/marko/table/package.json index 1170ed9e0..fcd1307bf 100644 --- a/examples/marko/table/package.json +++ b/examples/marko/table/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/variable/package.json b/examples/marko/variable/package.json index 5aba1d811..38d26d10c 100644 --- a/examples/marko/variable/package.json +++ b/examples/marko/variable/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/window-ssr-slice/package.json b/examples/marko/window-ssr-slice/package.json index 9e76ead81..f62284b3b 100644 --- a/examples/marko/window-ssr-slice/package.json +++ b/examples/marko/window-ssr-slice/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/marko/window/package.json b/examples/marko/window/package.json index 121b01451..ff1266e84 100644 --- a/examples/marko/window/package.json +++ b/examples/marko/window/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@marko/run": "^0.10.0", - "@tanstack/marko-virtual": "^3.16.4", + "@tanstack/marko-virtual": "^3.16.5", "marko": "^6.2.2" }, "devDependencies": { diff --git a/examples/react/chat/package.json b/examples/react/chat/package.json index 1aa4b06f0..10a281769 100644 --- a/examples/react/chat/package.json +++ b/examples/react/chat/package.json @@ -8,7 +8,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.18", + "@tanstack/react-virtual": "^3.14.19", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/dynamic/package.json b/examples/react/dynamic/package.json index 9c21af65c..815876b35 100644 --- a/examples/react/dynamic/package.json +++ b/examples/react/dynamic/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/react-virtual": "^3.14.18", + "@tanstack/react-virtual": "^3.14.19", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/fixed/package.json b/examples/react/fixed/package.json index 1b77b5c8e..0750df9e7 100644 --- a/examples/react/fixed/package.json +++ b/examples/react/fixed/package.json @@ -8,7 +8,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.18", + "@tanstack/react-virtual": "^3.14.19", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/infinite-scroll/package.json b/examples/react/infinite-scroll/package.json index 8d144e2b9..d31cbf47e 100644 --- a/examples/react/infinite-scroll/package.json +++ b/examples/react/infinite-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.80.7", - "@tanstack/react-virtual": "^3.14.18", + "@tanstack/react-virtual": "^3.14.19", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/padding/package.json b/examples/react/padding/package.json index 211676de6..f26cbb9ce 100644 --- a/examples/react/padding/package.json +++ b/examples/react/padding/package.json @@ -9,7 +9,7 @@ "start": "vite" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.18", + "@tanstack/react-virtual": "^3.14.19", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/pretext/package.json b/examples/react/pretext/package.json index f7afa9010..ffa1784b8 100644 --- a/examples/react/pretext/package.json +++ b/examples/react/pretext/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@chenglou/pretext": "^0.0.7", - "@tanstack/react-virtual": "^3.14.18", + "@tanstack/react-virtual": "^3.14.19", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/scroll-padding/package.json b/examples/react/scroll-padding/package.json index ac9c5791b..ce29337d7 100644 --- a/examples/react/scroll-padding/package.json +++ b/examples/react/scroll-padding/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@react-hookz/web": "^25.1.1", - "@tanstack/react-virtual": "^3.14.18", + "@tanstack/react-virtual": "^3.14.19", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/smooth-scroll/package.json b/examples/react/smooth-scroll/package.json index ff5c39f0e..6ff15aa9f 100644 --- a/examples/react/smooth-scroll/package.json +++ b/examples/react/smooth-scroll/package.json @@ -9,7 +9,7 @@ "start": "vite" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.18", + "@tanstack/react-virtual": "^3.14.19", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/sticky/package.json b/examples/react/sticky/package.json index 26c825319..a1084e831 100644 --- a/examples/react/sticky/package.json +++ b/examples/react/sticky/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/react-virtual": "^3.14.18", + "@tanstack/react-virtual": "^3.14.19", "lodash": "^4.17.21", "react": "^19.2.7", "react-dom": "^19.2.7" diff --git a/examples/react/table/package.json b/examples/react/table/package.json index 2f6957118..5f4a4c607 100644 --- a/examples/react/table/package.json +++ b/examples/react/table/package.json @@ -11,7 +11,7 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.14.18", + "@tanstack/react-virtual": "^3.14.19", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/variable/package.json b/examples/react/variable/package.json index 3938a9d77..e213e1c4e 100644 --- a/examples/react/variable/package.json +++ b/examples/react/variable/package.json @@ -9,7 +9,7 @@ "start": "vite" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.18", + "@tanstack/react-virtual": "^3.14.19", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/react/window/package.json b/examples/react/window/package.json index f3ef6b110..560ce09cf 100644 --- a/examples/react/window/package.json +++ b/examples/react/window/package.json @@ -8,7 +8,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.18", + "@tanstack/react-virtual": "^3.14.19", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/examples/svelte/dynamic/package.json b/examples/svelte/dynamic/package.json index bc13505c0..dfb6b95e6 100644 --- a/examples/svelte/dynamic/package.json +++ b/examples/svelte/dynamic/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/svelte-virtual": "^3.13.44" + "@tanstack/svelte-virtual": "^3.13.45" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/fixed/package.json b/examples/svelte/fixed/package.json index d9bb3c372..616c67a6b 100644 --- a/examples/svelte/fixed/package.json +++ b/examples/svelte/fixed/package.json @@ -9,7 +9,7 @@ "check": "svelte-check --tsconfig ./tsconfig.json" }, "dependencies": { - "@tanstack/svelte-virtual": "^3.13.44" + "@tanstack/svelte-virtual": "^3.13.45" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/infinite-scroll/package.json b/examples/svelte/infinite-scroll/package.json index 8c443f286..c5d8a28fd 100644 --- a/examples/svelte/infinite-scroll/package.json +++ b/examples/svelte/infinite-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@tanstack/svelte-query": "^5.80.7", - "@tanstack/svelte-virtual": "^3.13.44" + "@tanstack/svelte-virtual": "^3.13.45" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/smooth-scroll/package.json b/examples/svelte/smooth-scroll/package.json index c895a0908..653ef6b29 100644 --- a/examples/svelte/smooth-scroll/package.json +++ b/examples/svelte/smooth-scroll/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/svelte-virtual": "^3.13.44" + "@tanstack/svelte-virtual": "^3.13.45" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/svelte/sticky/package.json b/examples/svelte/sticky/package.json index b762d7338..5fe990588 100644 --- a/examples/svelte/sticky/package.json +++ b/examples/svelte/sticky/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/svelte-virtual": "^3.13.44", + "@tanstack/svelte-virtual": "^3.13.45", "lodash": "^4.17.21" }, "devDependencies": { diff --git a/examples/svelte/table/package.json b/examples/svelte/table/package.json index 2eecf9b30..aae2acf3a 100644 --- a/examples/svelte/table/package.json +++ b/examples/svelte/table/package.json @@ -11,7 +11,7 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@tanstack/svelte-table": "^8.21.3", - "@tanstack/svelte-virtual": "^3.13.44" + "@tanstack/svelte-virtual": "^3.13.45" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", diff --git a/examples/vue/dynamic/package.json b/examples/vue/dynamic/package.json index 1b64b35dc..030ce326f 100644 --- a/examples/vue/dynamic/package.json +++ b/examples/vue/dynamic/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/vue-virtual": "^3.13.44", + "@tanstack/vue-virtual": "^3.13.45", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/fixed/package.json b/examples/vue/fixed/package.json index 269b1bd62..92f4044f6 100644 --- a/examples/vue/fixed/package.json +++ b/examples/vue/fixed/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.44", + "@tanstack/vue-virtual": "^3.13.45", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/infinite-scroll/package.json b/examples/vue/infinite-scroll/package.json index 7323502e4..a64f273a4 100644 --- a/examples/vue/infinite-scroll/package.json +++ b/examples/vue/infinite-scroll/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@tanstack/vue-query": "^5.80.7", - "@tanstack/vue-virtual": "^3.13.44", + "@tanstack/vue-virtual": "^3.13.45", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/padding/package.json b/examples/vue/padding/package.json index 111088356..294895d7b 100644 --- a/examples/vue/padding/package.json +++ b/examples/vue/padding/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.44", + "@tanstack/vue-virtual": "^3.13.45", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/scroll-padding/package.json b/examples/vue/scroll-padding/package.json index 240f87501..270e18239 100644 --- a/examples/vue/scroll-padding/package.json +++ b/examples/vue/scroll-padding/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.44", + "@tanstack/vue-virtual": "^3.13.45", "@vueuse/core": "^12.8.2", "vue": "^3.5.16" }, diff --git a/examples/vue/smooth-scroll/package.json b/examples/vue/smooth-scroll/package.json index 66791047d..472eeb9b4 100644 --- a/examples/vue/smooth-scroll/package.json +++ b/examples/vue/smooth-scroll/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.44", + "@tanstack/vue-virtual": "^3.13.45", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/sticky/package.json b/examples/vue/sticky/package.json index d055ba41e..d4d685bd8 100644 --- a/examples/vue/sticky/package.json +++ b/examples/vue/sticky/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@faker-js/faker": "^8.4.1", - "@tanstack/vue-virtual": "^3.13.44", + "@tanstack/vue-virtual": "^3.13.45", "lodash": "^4.17.21", "vue": "^3.5.16" }, diff --git a/examples/vue/table/package.json b/examples/vue/table/package.json index f1a63a907..6e31afc68 100644 --- a/examples/vue/table/package.json +++ b/examples/vue/table/package.json @@ -10,7 +10,7 @@ "dependencies": { "@faker-js/faker": "^8.4.1", "@tanstack/vue-table": "^8.21.3", - "@tanstack/vue-virtual": "^3.13.44", + "@tanstack/vue-virtual": "^3.13.45", "vue": "^3.5.16" }, "devDependencies": { diff --git a/examples/vue/variable/package.json b/examples/vue/variable/package.json index fffaaf50b..98da4ebff 100644 --- a/examples/vue/variable/package.json +++ b/examples/vue/variable/package.json @@ -8,7 +8,7 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/vue-virtual": "^3.13.44", + "@tanstack/vue-virtual": "^3.13.45", "vue": "^3.5.16" }, "devDependencies": { diff --git a/packages/angular-virtual/CHANGELOG.md b/packages/angular-virtual/CHANGELOG.md index c5697dd31..c3affd7df 100644 --- a/packages/angular-virtual/CHANGELOG.md +++ b/packages/angular-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/angular-virtual +## 6.1.5 + +### Patch Changes + +- Updated dependencies [[`269e8ed`](https://github.com/TanStack/virtual/commit/269e8ed12db0b9ce55543da5b0bb1082a6296aac)]: + - @tanstack/virtual-core@3.19.4 + ## 6.1.4 ### Patch Changes diff --git a/packages/angular-virtual/package.json b/packages/angular-virtual/package.json index dcef1856c..313b96cfa 100644 --- a/packages/angular-virtual/package.json +++ b/packages/angular-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/angular-virtual", - "version": "6.1.4", + "version": "6.1.5", "description": "Headless UI for virtualizing scrollable elements in Angular", "author": "Garrett Darnell", "license": "MIT", diff --git a/packages/lit-virtual/CHANGELOG.md b/packages/lit-virtual/CHANGELOG.md index 1ee191cc4..3de041107 100644 --- a/packages/lit-virtual/CHANGELOG.md +++ b/packages/lit-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/lit-virtual +## 3.14.8 + +### Patch Changes + +- Updated dependencies [[`269e8ed`](https://github.com/TanStack/virtual/commit/269e8ed12db0b9ce55543da5b0bb1082a6296aac)]: + - @tanstack/virtual-core@3.19.4 + ## 3.14.7 ### Patch Changes diff --git a/packages/lit-virtual/package.json b/packages/lit-virtual/package.json index a66f63eb1..8637687f0 100644 --- a/packages/lit-virtual/package.json +++ b/packages/lit-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/lit-virtual", - "version": "3.14.7", + "version": "3.14.8", "description": "Headless UI for virtualizing scrollable elements in Lit", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/marko-virtual/CHANGELOG.md b/packages/marko-virtual/CHANGELOG.md index 898aef870..6bc257852 100644 --- a/packages/marko-virtual/CHANGELOG.md +++ b/packages/marko-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/marko-virtual +## 3.16.5 + +### Patch Changes + +- Updated dependencies [[`269e8ed`](https://github.com/TanStack/virtual/commit/269e8ed12db0b9ce55543da5b0bb1082a6296aac)]: + - @tanstack/virtual-core@3.19.4 + ## 3.16.4 ### Patch Changes diff --git a/packages/marko-virtual/package.json b/packages/marko-virtual/package.json index 4002731e8..aa07c0dfa 100644 --- a/packages/marko-virtual/package.json +++ b/packages/marko-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/marko-virtual", - "version": "3.16.4", + "version": "3.16.5", "description": "Headless UI for virtualizing scrollable elements in Marko 6", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/react-virtual/CHANGELOG.md b/packages/react-virtual/CHANGELOG.md index 2df615bc9..5d67a737e 100644 --- a/packages/react-virtual/CHANGELOG.md +++ b/packages/react-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/react-virtual +## 3.14.19 + +### Patch Changes + +- Updated dependencies [[`269e8ed`](https://github.com/TanStack/virtual/commit/269e8ed12db0b9ce55543da5b0bb1082a6296aac)]: + - @tanstack/virtual-core@3.19.4 + ## 3.14.18 ### Patch Changes diff --git a/packages/react-virtual/package.json b/packages/react-virtual/package.json index 8dd6fddd9..b59f1e9c7 100644 --- a/packages/react-virtual/package.json +++ b/packages/react-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/react-virtual", - "version": "3.14.18", + "version": "3.14.19", "description": "Headless UI for virtualizing scrollable elements in React", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/solid-virtual/CHANGELOG.md b/packages/solid-virtual/CHANGELOG.md index 8aab2202b..3f731e654 100644 --- a/packages/solid-virtual/CHANGELOG.md +++ b/packages/solid-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/solid-virtual +## 3.13.46 + +### Patch Changes + +- Updated dependencies [[`269e8ed`](https://github.com/TanStack/virtual/commit/269e8ed12db0b9ce55543da5b0bb1082a6296aac)]: + - @tanstack/virtual-core@3.19.4 + ## 3.13.45 ### Patch Changes diff --git a/packages/solid-virtual/package.json b/packages/solid-virtual/package.json index 5691d1419..af7088535 100644 --- a/packages/solid-virtual/package.json +++ b/packages/solid-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/solid-virtual", - "version": "3.13.45", + "version": "3.13.46", "description": "Headless UI for virtualizing scrollable elements in Solid", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/svelte-virtual/CHANGELOG.md b/packages/svelte-virtual/CHANGELOG.md index 603a000b5..0bc74b1b5 100644 --- a/packages/svelte-virtual/CHANGELOG.md +++ b/packages/svelte-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/svelte-virtual +## 3.13.45 + +### Patch Changes + +- Updated dependencies [[`269e8ed`](https://github.com/TanStack/virtual/commit/269e8ed12db0b9ce55543da5b0bb1082a6296aac)]: + - @tanstack/virtual-core@3.19.4 + ## 3.13.44 ### Patch Changes diff --git a/packages/svelte-virtual/package.json b/packages/svelte-virtual/package.json index f03ff0dd3..f818c9d8f 100644 --- a/packages/svelte-virtual/package.json +++ b/packages/svelte-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/svelte-virtual", - "version": "3.13.44", + "version": "3.13.45", "description": "Headless UI for virtualizing scrollable elements in Svelte", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/virtual-core/CHANGELOG.md b/packages/virtual-core/CHANGELOG.md index 4e1dde222..2e24807db 100644 --- a/packages/virtual-core/CHANGELOG.md +++ b/packages/virtual-core/CHANGELOG.md @@ -1,5 +1,11 @@ # @tanstack/virtual-core +## 3.19.4 + +### Patch Changes + +- [#1320](https://github.com/TanStack/virtual/pull/1320) [`269e8ed`](https://github.com/TanStack/virtual/commit/269e8ed12db0b9ce55543da5b0bb1082a6296aac) - fix(virtual-core): with `useAnimationFrameWithResizeObserver`, request frames from the scroll element's window instead of the global one, and cancel frames still queued on cleanup, so a list rendered into another window does not stall while the opener is minimized or occluded + ## 3.19.3 ### Patch Changes diff --git a/packages/virtual-core/package.json b/packages/virtual-core/package.json index dca531cab..ced4c4f85 100644 --- a/packages/virtual-core/package.json +++ b/packages/virtual-core/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/virtual-core", - "version": "3.19.3", + "version": "3.19.4", "description": "Headless UI for virtualizing scrollable elements in TS/JS + Frameworks", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/vue-virtual/CHANGELOG.md b/packages/vue-virtual/CHANGELOG.md index 79215edb2..857b5224a 100644 --- a/packages/vue-virtual/CHANGELOG.md +++ b/packages/vue-virtual/CHANGELOG.md @@ -1,5 +1,12 @@ # @tanstack/vue-virtual +## 3.13.45 + +### Patch Changes + +- Updated dependencies [[`269e8ed`](https://github.com/TanStack/virtual/commit/269e8ed12db0b9ce55543da5b0bb1082a6296aac)]: + - @tanstack/virtual-core@3.19.4 + ## 3.13.44 ### Patch Changes diff --git a/packages/vue-virtual/package.json b/packages/vue-virtual/package.json index 3a5275b20..3362c6340 100644 --- a/packages/vue-virtual/package.json +++ b/packages/vue-virtual/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/vue-virtual", - "version": "3.13.44", + "version": "3.13.45", "description": "Headless UI for virtualizing scrollable elements in Vue", "author": "Tanner Linsley", "license": "MIT", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae5da843e..31dd61160 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,7 +145,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.4 + specifier: ^6.1.5 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -200,7 +200,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/angular-virtual': - specifier: ^6.1.4 + specifier: ^6.1.5 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -252,7 +252,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.4 + specifier: ^6.1.5 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -307,7 +307,7 @@ importers: specifier: 5.80.7 version: 5.80.7(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)) '@tanstack/angular-virtual': - specifier: ^6.1.4 + specifier: ^6.1.5 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -359,7 +359,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.4 + specifier: ^6.1.5 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -411,7 +411,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.4 + specifier: ^6.1.5 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -466,7 +466,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/angular-virtual': - specifier: ^6.1.4 + specifier: ^6.1.5 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -524,7 +524,7 @@ importers: specifier: 8.21.3 version: 8.21.3(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)) '@tanstack/angular-virtual': - specifier: ^6.1.4 + specifier: ^6.1.5 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -576,7 +576,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.4 + specifier: ^6.1.5 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -628,7 +628,7 @@ importers: specifier: ^20.2.0 version: 20.3.26(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.26(@angular/animations@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.26(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.26(@angular/compiler@20.3.26)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@tanstack/angular-virtual': - specifier: ^6.1.4 + specifier: ^6.1.5 version: link:../../../packages/angular-virtual rxjs: specifier: ^7.8.2 @@ -659,10 +659,10 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/lit-virtual': - specifier: ^3.14.7 + specifier: ^3.14.8 version: link:../../../packages/lit-virtual '@tanstack/virtual-core': - specifier: ^3.19.3 + specifier: ^3.19.4 version: link:../../../packages/virtual-core lit: specifier: ^3.3.0 @@ -684,10 +684,10 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/lit-virtual': - specifier: ^3.14.7 + specifier: ^3.14.8 version: link:../../../packages/lit-virtual '@tanstack/virtual-core': - specifier: ^3.19.3 + specifier: ^3.19.4 version: link:../../../packages/virtual-core lit: specifier: ^3.3.0 @@ -709,7 +709,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -737,7 +737,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -762,7 +762,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -787,7 +787,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -812,7 +812,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -837,7 +837,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -862,7 +862,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -890,7 +890,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -915,7 +915,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -940,7 +940,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -965,7 +965,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -990,7 +990,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1015,7 +1015,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1040,7 +1040,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1065,7 +1065,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1090,7 +1090,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1115,7 +1115,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1140,7 +1140,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1165,7 +1165,7 @@ importers: specifier: ^0.10.0 version: 0.10.0(@marko/compiler@5.40.0)(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(marko@6.3.15)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) '@tanstack/marko-virtual': - specifier: ^3.16.4 + specifier: ^3.16.5 version: link:../../../packages/marko-virtual marko: specifier: ^6.2.2 @@ -1187,7 +1187,7 @@ importers: examples/react/chat: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.18 + specifier: ^3.14.19 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1218,7 +1218,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/react-virtual': - specifier: ^3.14.18 + specifier: ^3.14.19 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1249,7 +1249,7 @@ importers: examples/react/fixed: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.18 + specifier: ^3.14.19 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1283,7 +1283,7 @@ importers: specifier: ^5.80.7 version: 5.90.5(react@19.2.7) '@tanstack/react-virtual': - specifier: ^3.14.18 + specifier: ^3.14.19 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1308,7 +1308,7 @@ importers: examples/react/padding: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.18 + specifier: ^3.14.19 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1336,7 +1336,7 @@ importers: specifier: ^0.0.7 version: 0.0.7 '@tanstack/react-virtual': - specifier: ^3.14.18 + specifier: ^3.14.19 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1370,7 +1370,7 @@ importers: specifier: ^25.1.1 version: 25.1.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-virtual': - specifier: ^3.14.18 + specifier: ^3.14.19 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1395,7 +1395,7 @@ importers: examples/react/smooth-scroll: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.18 + specifier: ^3.14.19 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1423,7 +1423,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/react-virtual': - specifier: ^3.14.18 + specifier: ^3.14.19 version: link:../../../packages/react-virtual lodash: specifier: ^4.17.21 @@ -1460,7 +1460,7 @@ importers: specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-virtual': - specifier: ^3.14.18 + specifier: ^3.14.19 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1485,7 +1485,7 @@ importers: examples/react/variable: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.18 + specifier: ^3.14.19 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1510,7 +1510,7 @@ importers: examples/react/window: dependencies: '@tanstack/react-virtual': - specifier: ^3.14.18 + specifier: ^3.14.19 version: link:../../../packages/react-virtual react: specifier: ^19.2.7 @@ -1544,7 +1544,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/svelte-virtual': - specifier: ^3.13.44 + specifier: ^3.13.45 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1572,7 +1572,7 @@ importers: examples/svelte/fixed: dependencies: '@tanstack/svelte-virtual': - specifier: ^3.13.44 + specifier: ^3.13.45 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1603,7 +1603,7 @@ importers: specifier: ^5.80.7 version: 5.90.2(svelte@4.2.20) '@tanstack/svelte-virtual': - specifier: ^3.13.44 + specifier: ^3.13.45 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1634,7 +1634,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/svelte-virtual': - specifier: ^3.13.44 + specifier: ^3.13.45 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1665,7 +1665,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/svelte-virtual': - specifier: ^3.13.44 + specifier: ^3.13.45 version: link:../../../packages/svelte-virtual lodash: specifier: ^4.17.21 @@ -1702,7 +1702,7 @@ importers: specifier: ^8.21.3 version: 8.21.3(svelte@4.2.20) '@tanstack/svelte-virtual': - specifier: ^3.13.44 + specifier: ^3.13.45 version: link:../../../packages/svelte-virtual devDependencies: '@sveltejs/vite-plugin-svelte': @@ -1733,7 +1733,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/vue-virtual': - specifier: ^3.13.44 + specifier: ^3.13.45 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1758,7 +1758,7 @@ importers: examples/vue/fixed: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.44 + specifier: ^3.13.45 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1786,7 +1786,7 @@ importers: specifier: ^5.80.7 version: 5.90.5(vue@3.5.22(typescript@5.9.3)) '@tanstack/vue-virtual': - specifier: ^3.13.44 + specifier: ^3.13.45 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1811,7 +1811,7 @@ importers: examples/vue/padding: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.44 + specifier: ^3.13.45 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1836,7 +1836,7 @@ importers: examples/vue/scroll-padding: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.44 + specifier: ^3.13.45 version: link:../../../packages/vue-virtual '@vueuse/core': specifier: ^12.8.2 @@ -1864,7 +1864,7 @@ importers: examples/vue/smooth-scroll: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.44 + specifier: ^3.13.45 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1892,7 +1892,7 @@ importers: specifier: ^8.4.1 version: 8.4.1 '@tanstack/vue-virtual': - specifier: ^3.13.44 + specifier: ^3.13.45 version: link:../../../packages/vue-virtual lodash: specifier: ^4.17.21 @@ -1929,7 +1929,7 @@ importers: specifier: ^8.21.3 version: 8.21.3(vue@3.5.22(typescript@5.9.3)) '@tanstack/vue-virtual': - specifier: ^3.13.44 + specifier: ^3.13.45 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 @@ -1954,7 +1954,7 @@ importers: examples/vue/variable: dependencies: '@tanstack/vue-virtual': - specifier: ^3.13.44 + specifier: ^3.13.45 version: link:../../../packages/vue-virtual vue: specifier: ^3.5.16 From 4a1a4fdae1fa86cdda6603194876399f6651630d Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Tue, 13 Oct 2026 20:34:08 +0200 Subject: [PATCH 15/46] fix(virtual-core): defer iOS scroll corrections only for touch-driven scrolls (#1318) --- .changeset/ios-touch-only-deferral.md | 5 + .../react-virtual/e2e/app/scroll/main.tsx | 6 + .../app/test/ios-programmatic-landing.spec.ts | 56 + packages/virtual-core/src/index.ts | 168 ++- .../__snapshots__/scroll-trace.test.ts.snap | 108 +- packages/virtual-core/tests/index.test.ts | 986 ++++++++++-------- .../tests/ios-differential.test.ts | 23 +- .../virtual-core/tests/scroll-trace.test.ts | 9 +- 8 files changed, 814 insertions(+), 547 deletions(-) create mode 100644 .changeset/ios-touch-only-deferral.md create mode 100644 packages/react-virtual/e2e/app/test/ios-programmatic-landing.spec.ts diff --git a/.changeset/ios-touch-only-deferral.md b/.changeset/ios-touch-only-deferral.md new file mode 100644 index 000000000..bd799ebdb --- /dev/null +++ b/.changeset/ios-touch-only-deferral.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): on iOS, defer scroll corrections only for touch-driven scrolls (a finger down, or the momentum after it), so a programmatic landing like `scrollToOffset` compensates before paint instead of snapping later diff --git a/packages/react-virtual/e2e/app/scroll/main.tsx b/packages/react-virtual/e2e/app/scroll/main.tsx index 1a2f9eacb..b7823891a 100644 --- a/packages/react-virtual/e2e/app/scroll/main.tsx +++ b/packages/react-virtual/e2e/app/scroll/main.tsx @@ -49,6 +49,12 @@ const App = () => { +
{ + test.skip(browserName !== 'chromium', 'touch emulation runs in Chromium') + await page.goto('/scroll/') + + // Click, then sample scrollTop every frame for 700ms. + const samples = await page.evaluate(async (sel) => { + const el = document.querySelector('#scroll-container')! + const out: Array<[number, number]> = [] + const start = performance.now() + ;(document.querySelector(sel) as HTMLButtonElement).click() + await new Promise((resolve) => { + const tick = () => { + const t = performance.now() - start + out.push([t, el.scrollTop]) + if (t < 700) requestAnimationFrame(tick) + else resolve() + } + requestAnimationFrame(tick) + }) + return out + }, button) + + // The landing and its corrections settle within the first frames; a + // deferred correction would move the list again ~150ms after the last + // scroll event. + const final = samples[samples.length - 1]![1] + const late = samples.filter(([t]) => t >= 100).map(([, top]) => top) + expect(final).toBeGreaterThan(0) + expect( + late.every((top) => top === final), + JSON.stringify(samples), + ).toBe(true) + }) +} diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 6d7d8ce91..5135de4e9 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -179,6 +179,14 @@ const addEventListenerOptions = { passive: true, } +// Touch listeners on the scroll element run in the capture phase, so a row +// that stops a touch event's propagation cannot hide the gesture from the +// iOS deferral. +const touchListenerOptions = { + capture: true, + passive: true, +} + export const observeWindowRect = ( instance: Virtualizer, cb: (rect: Rect) => void, @@ -526,22 +534,23 @@ export class Virtualizer< get scrollAdjustments() { return this.pendingWrite?.adjustments ?? 0 } - // Sum of size-change deltas above-viewport that were skipped during - // iOS momentum scroll (writing scrollTop mid-momentum cancels it). - // Flushed in a single scrollTo when iOS is fully settled. + // Sum of size-change deltas above-viewport that were skipped during a + // touch-driven iOS scroll (writing scrollTop mid-momentum cancels it, + // #884). Flushed in a single scrollTo once the gesture has settled. private _iosDeferredAdjustment = 0 - // The touch gesture in progress. iOS WebKit cancels momentum when - // scrollTop is written, so adjustments are deferred not only during - // `isScrolling` but through the whole gesture: `down` from touchstart to - // touchend/touchcancel (active drag), then `tail` for a short grace window - // after the finger lifts (early momentum — iOS fires touch events only - // once at the start of momentum, so the tail is a timer rather than - // another event). Null between gestures, and after touchend on non-iOS, - // which arms no tail. The tail record owns its timer: `_endIosTouch` is - // the only way to drop either, so a cleared record never leaves a timer - // behind, and a live timer always has its record. + // The touch gesture in progress. iOS WebKit ignores a scrollTop write + // while a finger drags the content (one resting on it does not stop the + // write) and cancels momentum on one after it, so adjustments are deferred + // through the whole gesture: `down` from touchstart to touchend/ + // touchcancel, with `dragged` set by the first scroll event that is not our + // own write's read-back, then `tail` while momentum runs. iOS fires no + // touch events during momentum, so every scroll event re-arms the tail and + // it closes ~150ms after the last one. Null between gestures, and after + // touchend on non-iOS, which arms no tail. The tail record owns its timer: + // `_endIosTouch` is the only way to drop either, so a cleared record never + // leaves a timer behind, and a live timer always has its record. private _iosTouch: - | { phase: 'down' } + | { phase: 'down'; dragged: boolean } | { phase: 'tail'; timerId: number } | null = null // Our last write to the element, from `_scrollToOffset` until the next @@ -850,11 +859,12 @@ export class Virtualizer< } if (isIOSWebKit() && this._iosInMotion()) { - // An index scroll retargets from fresh measurements in reconcileScroll; - // replaying this delta after it lands would count it twice. - if (this.scrollState?.index == null) { - this._iosDeferredAdjustment += delta - } + // Only a touch defers, so an index scroll meets this only with a finger + // down. If the finger scrolls, its first scroll event ends the index + // scroll and the flush applies what was deferred; if it lifts without + // moving, reconcile's next retarget already contains the correction + // and clears it (see reconcileScroll). + this._iosDeferredAdjustment += delta return false } else { const target = this.getScrollOffset() + this.scrollAdjustments + delta @@ -1013,7 +1023,9 @@ export class Virtualizer< // The read-back of our own write: adopt the exact target over the // browser's rounded value (see `pendingWrite`). const pending = this.pendingWrite - if (pending !== null && Math.abs(offset - pending.target) < 1.5) { + const isReadBack = + pending !== null && Math.abs(offset - pending.target) < 1.5 + if (isReadBack) { offset = pending.target } else if ( this.scrollState?.index != null && @@ -1069,9 +1081,26 @@ export class Virtualizer< this.scrollOffset = offset this.isScrolling = isScrolling - // Flush deferred iOS adjustments if we're now fully settled. - // "Fully settled" means: not actively scrolling, no finger on - // screen, and the post-touchend grace window has expired. + // Momentum after touchend fires a scroll event per frame but no + // touch events, so each one re-arms the tail to span the fling. + // Only an open tail is extended: a scroll event with no touch before + // it (our own write's read-back) never opens one. + if (isScrolling && this._iosTouch?.phase === 'tail') { + this._openIosTail() + } + // A finger that moved the content is dragging. Our own write landing + // under a resting finger is not, and neither is a report at rest + // that leaves the position where it was. + if ( + !isReadBack && + isScrolling && + offset !== prevOffset && + this._iosTouch?.phase === 'down' + ) { + this._iosTouch.dragged = true + } + + // Flush deferred iOS adjustments once the gesture has settled. this._flushIosDeferredIfReady() if (this.scrollState) { @@ -1090,47 +1119,41 @@ export class Virtualizer< const onTouchStart = () => { // A finger back down inside the grace window cancels its tail. this._endIosTouch() - this._iosTouch = { phase: 'down' } + this._iosTouch = { phase: 'down', dragged: false } } const onTouchEnd = () => { this._endIosTouch() - if (!isIOSWebKit() || this.targetWindow == null) { - // Non-iOS: nothing more to track. - return - } - // After ~150 ms with no scroll/touch events, momentum is done. - const tail = { - phase: 'tail' as const, - timerId: this.targetWindow.setTimeout(() => { - if (this._iosTouch === tail) this._iosTouch = null - // After the grace window, attempt to flush. The scroll event - // for momentum decay may have already fired before our timer. - this._flushIosDeferredIfReady() - }, 150), - } - this._iosTouch = tail + this._openIosTail() } scrollEl.addEventListener( 'touchstart', onTouchStart, - addEventListenerOptions, - ) - scrollEl.addEventListener( - 'touchend', - onTouchEnd, - addEventListenerOptions, + touchListenerOptions, ) + scrollEl.addEventListener('touchend', onTouchEnd, touchListenerOptions) // Same handler as touchend: iOS can end a touch with touchcancel // (system gesture, interrupted scroll) and never fire touchend. scrollEl.addEventListener( 'touchcancel', onTouchEnd, - addEventListenerOptions, + touchListenerOptions, ) this.unsubs.push(() => { - scrollEl.removeEventListener('touchstart', onTouchStart) - scrollEl.removeEventListener('touchend', onTouchEnd) - scrollEl.removeEventListener('touchcancel', onTouchEnd) + scrollEl.removeEventListener( + 'touchstart', + onTouchStart, + touchListenerOptions, + ) + scrollEl.removeEventListener( + 'touchend', + onTouchEnd, + touchListenerOptions, + ) + scrollEl.removeEventListener( + 'touchcancel', + onTouchEnd, + touchListenerOptions, + ) this._endIosTouch() }) } @@ -1216,10 +1239,33 @@ export class Virtualizer< } } - // The element is moving or about to: a scroll in progress, or a touch - // gesture that may still turn into one (see `_iosTouch`). On iOS a write - // now would cancel the momentum, so adjustments wait for the flush below. - private _iosInMotion = () => this.isScrolling || this._iosTouch !== null + // A touch gesture owns the scroll (see `_iosTouch`): on iOS a write now + // would be ignored or would cancel the momentum, so adjustments wait for + // the flush below. `isScrolling` is not part of it: any scroll event sets + // it, including the read-back of our own write, and a programmatic scroll + // has no momentum to protect (#1250). + private _iosInMotion = () => this._iosTouch !== null + + // A finger is dragging the content, so iOS ignores our writes and no + // read-back will come. A resting finger does not: the write lands. + private _iosDragging = () => + this._iosTouch?.phase === 'down' && this._iosTouch.dragged + + // (Re)open the tail after the finger lifts, closing ~150ms after the last + // touchend or momentum frame. On non-iOS there is nothing to protect. + private _openIosTail = () => { + if (!isIOSWebKit() || this.targetWindow == null) return + this._endIosTouch() + const tail = { + phase: 'tail' as const, + timerId: this.targetWindow.setTimeout(() => { + if (this._iosTouch === tail) this._iosTouch = null + // The last momentum frame may have come before the timer. + this._flushIosDeferredIfReady() + }, 150), + } + this._iosTouch = tail + } private _endIosTouch = () => { if (this._iosTouch?.phase === 'tail' && this.targetWindow != null) { @@ -1373,6 +1419,10 @@ export class Virtualizer< adjustments: undefined, behavior: keepSmooth ? 'smooth' : 'auto', }) + // The new target is computed from current sizes, so this write + // already contains any correction deferred on iOS. Under a dragging + // finger iOS ignores the write, so the deferral stays for the flush. + if (!this._iosDragging()) this._iosDeferredAdjustment = 0 } } @@ -1928,9 +1978,12 @@ export class Virtualizer< // since compensating against the stale offset would overwrite the // command. Compensation writes and the prepend anchor sync update // `scrollOffset` eagerly, and the iOS flush writes exactly - // `scrollOffset + scrollAdjustments`, so none of them count. + // `scrollOffset + scrollAdjustments`, so none of them count. Under a + // dragging finger iOS ignores the write, so no read-back will come and + // the touch deferral below takes the correction instead. const writtenOffset = this.getCommandWrittenOffset() const awaitingCommandReadBack = + !(isIOSWebKit() && this._iosDragging()) && writtenOffset !== null && !approxEqual( writtenOffset, @@ -2208,6 +2261,13 @@ export class Virtualizer< ) { if (kind === 'absolute') { this._iosDeferredAdjustment = 0 + // The command takes over the scroll, so close the tail: the landing's + // corrections then apply before paint, even for a command issued from + // a tap handler inside the tail (#1250). On iOS 26 and earlier the + // write stops the momentum; on 27 momentum can continue past it, but + // a write there does not stop momentum either (#1307, step 3). With a + // finger down the user owns the scroll: leave it. + if (this._iosTouch?.phase === 'tail') this._endIosTouch() } this.scrollState = { ...target, diff --git a/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap b/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap index 3c0509e86..353862da0 100644 --- a/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap +++ b/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap @@ -2,12 +2,12 @@ exports[`scroll behaviour snapshot > anchorEnd ios 1`] = ` [ - "0: 4f298fd0 aad8d5a3 891c472b 6b3fd29c 55b588c8 22dcf00c 215b6b0d 7cce72d9 d0a82d58 bc037d54", - "10: d44a9bb5 586dd775 b430581a 6e281984 aa7c5657 153cee4a cfe99984 e2ca2c19 1367462c 5b539e6c", - "20: 1513df98 cfcd9542 62c8c74b f31baa86 64cc3f52 e4eea90c 0fe112b7 dc03acfc 9c5c547a 2f69dca1", - "30: 4b65af20 57a8311c 2fcb9406 dd2e21be 8a13a7ef 514b807f 68555950 bd7052a9 4e3b9609 b2cd4a3e", - "40: a37c95f7 af0b2c51 3cc0e1c8 8a237208 1e043fba 80baef90 493457fe f2410084 5fadb00a 30b3276f", - "50: 36d91b30 cfce66db b89abd4c deacf849 36079ddd 52742173 8499d80c 89bf911c 83ea3d20 49fdd4f9", + "0: 282cf6e1 e8d72086 32d84175 41f39894 e6545a49 bdfeac4e e157de78 2eeb4a65 a7e60471 91ea3dfe", + "10: a6af11d6 d39cf9a3 675d7128 fdfa8085 f7c95667 153cee4a 76a1e959 e2ca2c19 ca3469b5 fcd5167f", + "20: 78469a2b 61474ab4 ac511a18 0121b5a8 9151c75d e4eea90c 670a738f 797533c3 7e2251a6 668a6a76", + "30: d3ddb03a 92c65137 6e9d6453 dd2e21be 8a13a7ef 0065547f fb8aa82b 8141e566 4e3b9609 d9883551", + "40: 41e98d8e ffdc4099 b9048a44 b6fe40dd 05de3a84 a2be79ab 3dc0fa80 ac2dd208 bb53d67d dbe98727", + "50: 49d7572b 1ae7af8c 6d833ccb 470fa38b 36079ddd 118919eb 68db84a8 93867ea2 83ea3d20 1d26883e", ] `; @@ -24,12 +24,12 @@ exports[`scroll behaviour snapshot > anchorEnd web 1`] = ` exports[`scroll behaviour snapshot > base ios 1`] = ` [ - "0: 5887e219 abe3cd0e 891c472b 61a2f9b9 2cbfb4c4 7fb055d7 e8385a04 2d1bfe19 8f7607c0 9970e654", - "10: 7f6afed0 4dcb6d27 e91a7175 0f66079c dbb9cc42 f5d2f4d7 cb3749ad e2ca2c19 e24d273b c5b1dcc1", - "20: 6f1d9099 0d96c3bd 61e8fc8b 76d42dca 5dcca7a8 b105dbdd e1f9c925 c1f8e4ab 3d92177b 63757970", - "30: 4b65af20 eb152cd3 2c2b6a76 e2f91462 7e888af6 36d83add 68555950 bd7052a9 10edfac1 b2cd4a3e", - "40: a37c95f7 4bcf0d6d 533bd349 2c711ebe a5233478 acc8c54b 31c8b4eb 2c10d2fd eebc1cb1 488db66c", - "50: 36d91b30 6ce0ab7d a969c360 2c499c27 58dd9721 288a970e 37c83cd0 6d942e32 83ea3d20 32b19c85", + "0: b9137b9c 394fee8b 32d84175 a64b15f0 eecef157 81f99e26 3770b8af 6a913d03 e792721d a72cd3ec", + "10: b0d48519 57d1d43b 043cf059 eafa6bc0 35c18da0 f5d2f4d7 87494a18 e2ca2c19 30031c34 a218de69", + "20: d69a02f6 65844fed ac511a18 ef97077c b4e88922 b105dbdd 670a738f 86c19915 17976cbd 6e2f429c", + "30: d3ddb03a 966d3422 5d07e323 e2f91462 7e888af6 7b794ad1 71950a2c 8141e566 10edfac1 d9883551", + "40: 41e98d8e fd7d6df4 044dcfac 715efd76 da040dcb 2cbec68e 35c640e7 be6d2097 14042112 ca941eb9", + "50: 49d7572b 6ce0ab7d 6d833ccb fe140621 58dd9721 17c1f1d0 bad2e9d3 4c0075f5 83ea3d20 d1ae57b0", ] `; @@ -46,12 +46,12 @@ exports[`scroll behaviour snapshot > base web 1`] = ` exports[`scroll behaviour snapshot > horizontal ios 1`] = ` [ - "0: 5887e219 abe3cd0e 891c472b 61a2f9b9 2cbfb4c4 7fb055d7 e8385a04 2d1bfe19 8f7607c0 9970e654", - "10: 7f6afed0 4dcb6d27 e91a7175 0f66079c dbb9cc42 f5d2f4d7 cb3749ad e2ca2c19 e24d273b c5b1dcc1", - "20: 6f1d9099 0d96c3bd 61e8fc8b 76d42dca 5dcca7a8 b105dbdd e1f9c925 c1f8e4ab 3d92177b 63757970", - "30: 4b65af20 eb152cd3 2c2b6a76 e2f91462 7e888af6 36d83add 68555950 bd7052a9 10edfac1 b2cd4a3e", - "40: a37c95f7 4bcf0d6d 533bd349 2c711ebe a5233478 acc8c54b 31c8b4eb 2c10d2fd eebc1cb1 488db66c", - "50: 36d91b30 6ce0ab7d a969c360 2c499c27 58dd9721 288a970e 37c83cd0 6d942e32 83ea3d20 32b19c85", + "0: b9137b9c 394fee8b 32d84175 a64b15f0 eecef157 81f99e26 3770b8af 6a913d03 e792721d a72cd3ec", + "10: b0d48519 57d1d43b 043cf059 eafa6bc0 35c18da0 f5d2f4d7 87494a18 e2ca2c19 30031c34 a218de69", + "20: d69a02f6 65844fed ac511a18 ef97077c b4e88922 b105dbdd 670a738f 86c19915 17976cbd 6e2f429c", + "30: d3ddb03a 966d3422 5d07e323 e2f91462 7e888af6 7b794ad1 71950a2c 8141e566 10edfac1 d9883551", + "40: 41e98d8e fd7d6df4 044dcfac 715efd76 da040dcb 2cbec68e 35c640e7 be6d2097 14042112 ca941eb9", + "50: 49d7572b 6ce0ab7d 6d833ccb fe140621 58dd9721 17c1f1d0 bad2e9d3 4c0075f5 83ea3d20 d1ae57b0", ] `; @@ -68,12 +68,12 @@ exports[`scroll behaviour snapshot > horizontal web 1`] = ` exports[`scroll behaviour snapshot > lanes ios 1`] = ` [ - "0: c6dd08cb 147a2be3 13203d38 aa97ec29 544098f0 fb4d19c1 b895e017 2eb29692 6ef29cec 2b318c67", - "10: b8f614ec 2a990734 24e1cc10 ad1c0dc9 7b4b7b9d e9749cd5 a5f53b4e af772ff3 77b88ced 5b661f55", - "20: 16fa47be 01f0386a b1893d8e 1ed850a8 f23871ad ba3c88b6 d8984fc6 222168c3 86bac07d fcc3a928", - "30: ccf65223 b9edc885 b8a97e0b eab3a7a5 63dafad0 2d577a18 ff486842 6f222b0a 22a2c534 7b10519a", - "40: 49ebb7e4 953afd3a 16c879ff 827661aa f693ed31 55f47063 a741de85 f11984c1 19291542 799e7dd3", - "50: cb6a1e7d 84a561da 8cd6b03a 7a150d3e 9b4f0ed6 60f3b4d4 fbe950c5 873002f8 48b89640 0442e5e7", + "0: fcaf5c9d aa31c81a be5bc264 8da067a6 dfcce7b5 b2ab7fc2 5f919201 2eb29692 fc68d8b2 b460a830", + "10: f1721ff2 2c803d87 098712d0 8eacfdaa 4199ffbb e9749cd5 3e7ce136 af772ff3 35688b69 13731b17", + "20: 349946e9 a31a22c8 7d53dcfb 635cd06e 1262e458 ba3c88b6 7abc0006 3fff9c9d 1557d683 868f0885", + "30: f9b1e990 7ebf54e7 e82dec91 eab3a7a5 63dafad0 6c26f17c 55e138ed aa618abf b4eddb66 755dcabd", + "40: d5eca131 b28ff8fd 17499af6 436ccb21 4a599390 55f47063 e8117c25 c03f0587 4a3744c8 df44b055", + "50: 052daa55 84a561da 16da1777 fbe709a7 9b4f0ed6 e991a5c5 437a67cf 6955068d 48b89640 411167cb", ] `; @@ -90,12 +90,12 @@ exports[`scroll behaviour snapshot > lanes web 1`] = ` exports[`scroll behaviour snapshot > margin ios 1`] = ` [ - "0: cfe5f103 e904cb37 9903a3b4 1a33cf4f 65bed3da a29439f7 17a9e3c6 245d5721 57acbeb2 32de7235", - "10: 683ab7bb 4a34d79c 9378872f 756820d4 6f276b57 ecee60a0 076ed8f1 53754f4c 871c5fdc 89cc8718", - "20: ac608363 a4a4c7ea 541dd3aa 1f8f8648 4fd6db33 2f3bb231 00818908 67d5bf34 31cb282c 48d9b0b5", - "30: 70377111 9c05ddf5 ab70c6d3 443556ba 58ecae93 a0b9e446 3df28c65 a762f728 143e728c 3d255499", - "40: eb7d03a8 45148c24 812b02f1 18886587 ae2f38a6 5a2dacf8 8a5405d6 e9a13eb5 922f4483 94e2085f", - "50: cd85f836 b84f25a9 44c5f003 573e0d17 5ea402c9 f356d904 5857417f 93b00875 5ed0daa5 b801680f", + "0: 018f8bc1 3a7d502e e838ecd9 39fb33f0 08167c3f 68c0ffe1 2ad09a58 64ae8a6b e6cbbb22 0afc4c25", + "10: 6f7ebbb5 1ce7bc30 13f22c53 c148c8ec 14ab1ac1 ecee60a0 83262f2f 53754f4c 95ab6afb 216d3915", + "20: c72c4685 20db46ae 75e019ea 27e49ca2 22dc2b58 2f3bb231 7144e417 41821127 fae1bada a823f96b", + "30: d53f1b14 c264e66c fdd20473 443556ba 58ecae93 297593ed 0948c083 8319e805 143e728c 2c0c1154", + "40: 4f75cbae 10d1b2b9 d2ee5348 e7cf9b97 9ba76728 b34a15db db745b6d 1189858f 33312219 7fc66f6b", + "50: 3a707136 b84f25a9 c15682e0 20a25d74 5ea402c9 64d706c8 57fdb2d6 83602b38 5ed0daa5 8bc44cb4", ] `; @@ -112,12 +112,12 @@ exports[`scroll behaviour snapshot > margin web 1`] = ` exports[`scroll behaviour snapshot > padding ios 1`] = ` [ - "0: 7ce656c5 3f6396a2 ff1846c1 6a93b49f afd851fb 5d9a3036 19c9d0db 0b0cef6b 5baeb23d a31d5d4e", - "10: cd6de633 6dd688b7 4eedc487 5888b4aa d97787bc 11a5136d 629f4ec5 850708e0 c38dcf47 fda29dcb", - "20: fb42dc9d 7e1dbd8f d556b6de 24669a89 3e6c560c 87232ce3 713c008b 68b24170 ae553e4a de2d7260", - "30: 8a500602 21cbdddc fe42967c 090eb33f aa89e778 f0b9b29a fddaae98 c49dcc84 9e8a9ec6 7ffe530b", - "40: 6e49a752 c7ee6f01 a0cac638 0ea00ab4 48cc1cd6 11057618 7abf5573 9be1bed3 53635e4b fecdec2a", - "50: 2e0dd4b1 46031c46 3e3afa5e 39ba8c01 eafdfeea 49a44331 9f89e5ac 982b04fc ace495d9 5feb11bf", + "0: d3045c89 a54e0eda 8f2db94a d4e8f78e 03b80765 2a389364 83cebad2 0b0cef6b 2ae2094c 2a4d76eb", + "10: 5ae330a7 8d5be956 b1525893 76ca5911 d878c1db 11a5136d 760b05f7 850708e0 bf84b064 9ff484b0", + "20: a7bdbbbe 39c3a659 3488eba0 c7fb67e3 ada281e3 87232ce3 10ddc090 bcae7173 463a3308 d44c4b80", + "30: 838fc730 b3a64a42 0f4a2a52 090eb33f aa89e778 1ed26ae5 e6ff4491 86dc7002 9e8a9ec6 b7262b75", + "40: 7310c7a3 7ca8764d a0cac638 4be67f5c 37e91a94 df233e84 0f218eec 234019d7 147564f1 4839096f", + "50: c1d891be 46031c46 33613584 3e4aa2c1 eafdfeea 3c2af533 a63c6e71 74b5c434 ace495d9 6a5add71", ] `; @@ -134,12 +134,12 @@ exports[`scroll behaviour snapshot > padding web 1`] = ` exports[`scroll behaviour snapshot > predicate ios 1`] = ` [ - "0: 781ddc9b b30a23c2 93bad3b1 425fbffb 57ad1433 fc4b3cfc 81c8f4dd 80e2fe18 4b88cb8f 5b542fb3", - "10: d14bdad1 674c7db9 164b70b4 610f0a1d 24ee547c 3de8713e 7adb144b 57e0517c 650b9699 6e653156", - "20: 557ae3b2 89ac6afe 115a1a38 298ce6ec fd7eac41 3292ba3f 2fe1e7fa f66e796f 1061b08f f6652f37", - "30: 51076098 567ff44b 49cb7b84 f4883cc3 70ca1a9c 4cc6c949 af075e4f 7b2eaec8 ba70c93a 1d29977b", - "40: d4b4003c 16a048e8 2b24716c cc64a65e 023db0a6 68e5154e 806f5b9b 1cc9dae3 f715e0f3 17e93b0a", - "50: 34f20ab7 896571b0 79f3b432 dcd1b65b 5c8cff95 ff903d83 3359b798 6cd6c09b eb7ebc85 c7de5165", + "0: 55ced412 06ec2779 29a71490 52ae616e 7ff4d61a 7cc8db13 0651e36a 9c09701d 6a2172ec 79006d50", + "10: c2613462 48593dd3 8c0dfa4f e63fc8bd a4bc5e0b 561aec84 ce137e95 57e0517c cdd88f3d 0f2b0911", + "20: 58bc106a 73ac63fa b2cae6d0 2ec0de96 6f0049e9 11c825de 717e6d9c 6a4de7af 50199ebc 669bc632", + "30: 191877d8 5f78905e a870d34f f4883cc3 70ca1a9c e6adacc4 3dbb43d8 e171cccc 063455d7 168763c3", + "40: 8954f2f7 621fde8c cfc5bd0a 5f2b5151 3ec31fb0 20302f8c deefedde f458a3c2 48cf9410 0e878c91", + "50: 3eee4840 08d2c17c c9b5e84d bab65960 68df2e14 6923c1e4 ef80266e 6cd6c09b ee76ebae 180ce8ad", ] `; @@ -156,12 +156,12 @@ exports[`scroll behaviour snapshot > predicate web 1`] = ` exports[`scroll behaviour snapshot > smooth ios 1`] = ` [ - "0: f6b6d765 abe3cd0e a5810b75 449f0d02 8c4e0ffd 81c6109d 3baa79a2 4e69fa05 6da3b9f4 44548616", - "10: 566a5fdf 04ff1ed2 72330b80 9aa13f31 f4fdf51c 425c54ed af0a6f89 577590ad d9e2fc70 06c62243", - "20: 45a192f8 d2561f12 054dfac1 9bfef053 995d88d0 4863001d 3e67e00f aa102e37 4096c41f f9884ae7", - "30: 5105da32 69d46669 f8df5a7f c389598d ef52afb4 d277009e 70c0467b 25d3f80b 1130d5f2 4c1558fd", - "40: 517bd4ad 1d2b9887 534ef4db 1ccffcfd 115d3592 47a8fab6 d10aecd5 f3ade4e7 c3d0bf08 ab665d94", - "50: 7dba7d19 abc4d1a1 d806c049 7a29b27f 1bdbe3ee 22c69134 2d809341 fa65bc54 143f84f0 624c4099", + "0: 29560eee 394fee8b 577c0ab3 bf1ebb2f 8c4e0ffd c7477e32 e1862abe 4e69fa05 47a3f3b7 c8a09f5c", + "10: 0b70aa37 e9522bd0 78080d39 9e77b30e f4fdf51c 425c54ed c369b253 577590ad d9e2fc70 24d0175d", + "20: 45a192f8 d2561f12 054dfac1 9bfef053 ca3c6797 4863001d 5bdd0d27 bc5c94a8 b946b740 f9884ae7", + "30: ebe8f266 e35f5818 38472d8f c389598d ef52afb4 d277009e 54c9817c 9602c7e7 1130d5f2 ae9e39ef", + "40: 517bd4ad 1d2b9887 534ef4db a8d62bed 527091d5 bac62a70 d10aecd5 dd6f23ef 4f5c00c0 b3b02f05", + "50: 7583e0ce abc4d1a1 fff32c85 dc9a3a09 1bdbe3ee 22c69134 2d809341 fa65bc54 143f84f0 f9876d32", ] `; @@ -178,12 +178,12 @@ exports[`scroll behaviour snapshot > smooth web 1`] = ` exports[`scroll behaviour snapshot > variable ios 1`] = ` [ - "0: 3e794d55 ad98c0b2 927b911f 3b0cefe4 d885af90 ef69933d e4d0b78b 2ab62b32 05e9b62a 0cbf8311", - "10: 24f54188 282fb6ee d39ed06f 2c9e2f2b c40fc7dc a8ba89e9 334129e8 1411fce3 e51c5d1e 110c798a", - "20: 9738d290 270a631d 2a65846b cb11b412 1586781a c3e995e4 a39df847 0cd0ba75 ebf5fd1e bcde9cd4", - "30: 7acae8be 08a5362d b80fcb91 4cef1e03 9e86c65a 050a49b4 3a1286c7 1ae37890 717236aa d3d1a88c", - "40: 4a75a344 5a71ca13 5a3a092a 2a8e2e54 057eddc5 1ff70d54 db9c52ae e46be830 9776a720 0d85b7d8", - "50: 7a95c704 8844a6d3 64482217 c69b217c c2c92a5b 91b725d1 7a75f6ba 3b2552e7 10a5e16e 1fbb2f69", + "0: 8146f037 6155e2a6 5fb13b22 9c426db8 800a7c78 aafb8878 a4f3574c ca52ec1d f83130c6 90a838ad", + "10: d9a4342f 385b3b3b ff092a81 7c803d97 431d8223 a8ba89e9 a22b0008 1411fce3 f9d943d7 a71816ef", + "20: d2020c60 9baead9b 8030788b 756ad8d1 bfb0a8e7 c3e995e4 6bb19c0e bffedfc7 d1137ceb a563038a", + "30: 5b0bd5a4 f51e21ba 9899a730 4cef1e03 9e86c65a 7685028b d9eff3c9 64b1a1ec c56b35b2 780547a4", + "40: ef852267 c6c96d9f 5a3a092a 4ee85f57 22a1eda8 f3bb74ee 0b5ef252 7c4cd982 c07aadf7 52a1513b", + "50: 7b360174 8844a6d3 6888fe36 9c5e8846 c2c92a5b 060c0f41 7a75f6ba 45bc6bde 10a5e16e 85cffc65", ] `; diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index a50f6e709..23ac44bbf 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -2015,84 +2015,102 @@ function withFakeIOSUserAgent(fn: () => T): T { } } -test('iOS deferral: scroll-position write is deferred during isScrolling', () => { - withFakeIOSUserAgent(() => { - const scrollToFn = vi.fn() - let scrollCallback: - | ((offset: number, isScrolling: 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) => { - scrollCallback = cb - cb(100, true) // Start scrolling - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - scrollToFn.mockClear() +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, + scroll: (offset: number, isScrolling: boolean) => + scrollCallback!(offset, isScrolling), + touch: (type: 'touchstart' | 'touchend' | 'touchcancel') => + el._dispatch(type), + expireTouchTail: () => { + const id = + (v['_iosTouch'] as { timerId?: number } | null)?.timerId ?? null + expect(id, 'a touch tail timer is armed').not.toBeNull() + const fn = timers.get(id!)! + fn() + }, + } +} - // Resize an item above the current scroll position while isScrolling=true - // The default condition (item.start < scrollOffset + scrollAdjustments) - // would normally trigger an immediate scroll adjustment. +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 - - // On iOS during scroll, the adjustment should be DEFERRED — scrollToFn - // should NOT have been called for the adjustment. expect(scrollToFn).not.toHaveBeenCalled() expect(v['_iosDeferredAdjustment']).toBe(50) - // Now transition isScrolling → false - scrollCallback!(100, false) - - // The deferred adjustment should be flushed. - expect(scrollToFn).toHaveBeenCalled() + // The tail armed by the last momentum frame expires: gesture settled. + expireTouchTail() + expect(scrollToFn).toHaveBeenCalledTimes(1) expect(v['_iosDeferredAdjustment']).toBe(0) }) }) -test('iOS deferral: multiple resizes during scroll accumulate and flush as one', () => { +test('iOS deferral: multiple resizes during a fling accumulate and flush as one', () => { withFakeIOSUserAgent(() => { - const scrollToFn = vi.fn() - let scrollCallback: - | ((offset: number, isScrolling: 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) => { - scrollCallback = cb - cb(200, true) - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - scrollToFn.mockClear() + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer({ scrollTop: 200 }) + touch('touchstart') + touch('touchend') + scroll(200, true) - // Three resizes during scroll: 10 + 15 + 20 = 45 total + // Three resizes during the fling: 10 + 15 + 20 = 45 total v.resizeItem(0, 60) v.resizeItem(1, 65) v.resizeItem(2, 70) @@ -2100,7 +2118,7 @@ test('iOS deferral: multiple resizes during scroll accumulate and flush as one', expect(scrollToFn).not.toHaveBeenCalled() expect(v['_iosDeferredAdjustment']).toBe(45) - scrollCallback!(200, false) + expireTouchTail() // Single flush call expect(scrollToFn).toHaveBeenCalledTimes(1) expect(v['_iosDeferredAdjustment']).toBe(0) @@ -2113,40 +2131,17 @@ test('iOS deferral: scrollToIndex with no target still drops a pending deferred // point; otherwise the stale delta replays once the list refills (found by // @phcorp's differential run on #1316). withFakeIOSUserAgent(() => { - const scrollToFn = vi.fn() - let scrollCallback: - | ((offset: number, isScrolling: 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) => { - scrollCallback = cb - cb(200, true) - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - scrollToFn.mockClear() + const { v, scrollToFn, touch, expireTouchTail } = makeIOSTouchVirtualizer() - v.resizeItem(0, 90) // above the fold while scrolling: deferred + touch('touchstart') + v.resizeItem(0, 90) // above the fold under a finger: deferred v.setOptions({ ...v.options, count: 0 }) v['getMeasurements']() // the render for the empty list v.scrollToIndex(0) // no target, nothing to write v.setOptions({ ...v.options, count: 10 }) v['getMeasurements']() - scrollCallback!(200, false) // settles: the flush would replay the delta + touch('touchend') + expireTouchTail() // settles: the flush would replay the delta expect(scrollToFn).not.toHaveBeenCalled() }) @@ -2159,34 +2154,12 @@ test('iOS deferral: an absolute scroll command invalidates a pending deferred ad // just-established target by the accumulated delta. The absolute commands // must drop the deferral. withFakeIOSUserAgent(() => { - const scrollToFn = vi.fn() - let scrollCallback: - | ((offset: number, isScrolling: 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) => { - scrollCallback = cb - cb(200, true) - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - scrollToFn.mockClear() + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer({ scrollTop: 200 }) + touch('touchstart') + scroll(190, true) // the finger drags: the user owns the scroll - // Accumulate a deferred adjustment during scroll. + // Accumulate a deferred adjustment during the drag. v.resizeItem(0, 100) expect(v['_iosDeferredAdjustment']).toBe(50) @@ -2194,7 +2167,7 @@ test('iOS deferral: an absolute scroll command invalidates a pending deferred ad v.scrollToOffset(300) expect(v['_iosDeferredAdjustment']).toBe(0) - // scrollToIndex clears it too (still scrolling, so the resize defers). + // scrollToIndex clears it too (finger still down, so the resize defers). v.resizeItem(1, 100) expect(v['_iosDeferredAdjustment']).toBe(50) v.scrollToIndex(5) @@ -2202,8 +2175,11 @@ test('iOS deferral: an absolute scroll command invalidates a pending deferred ad scrollToFn.mockClear() // Settling must not replay any (now dropped) delta. - scrollCallback!(300, false) + touch('touchend') + expireTouchTail() + scroll(300, false) expect(v['_iosDeferredAdjustment']).toBe(0) + expect(scrollToFn).not.toHaveBeenCalled() }) }) @@ -2282,41 +2258,19 @@ test('iOS deferral: flushed delta is rolled into scrollAdjustments so back-to-ba // scrollAdjustments` would miss the flushed delta and the next correction // would compute from the stale offset. withFakeIOSUserAgent(() => { - const scrollToFn = vi.fn() - let scrollCallback: - | ((offset: number, isScrolling: 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) => { - scrollCallback = cb - cb(200, true) - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - scrollToFn.mockClear() + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer({ scrollTop: 200 }) + touch('touchstart') + touch('touchend') + scroll(200, true) - // Build up a deferred adjustment of 50 during scroll. + // Build up a deferred adjustment of 50 during the fling. v.resizeItem(0, 100) expect(v['_iosDeferredAdjustment']).toBe(50) expect(v['scrollAdjustments']).toBe(0) - // Settle: scroll event resets scrollAdjustments to 0, then the flush - // runs and must roll the deferred delta back into scrollAdjustments. - scrollCallback!(200, false) + // Settle: the flush must roll the deferred delta into scrollAdjustments. + expireTouchTail() expect(scrollToFn).toHaveBeenCalledTimes(1) const [, opts] = scrollToFn.mock.calls[0]! @@ -2334,40 +2288,18 @@ test('iOS deferral: a write before the flush is read back keeps the flushed adju // anchor sync) does not report anything, so the flushed delta stays until // the scroll event (found by @phcorp's mutation run on #1316). withFakeIOSUserAgent(() => { - const scrollToFn = vi.fn() - let scrollCallback: - | ((offset: number, isScrolling: 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) => { - scrollCallback = cb - cb(200, true) - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() + const { v, scroll, touch, expireTouchTail } = makeIOSTouchVirtualizer() - v.resizeItem(0, 100) // deferred while scrolling - scrollCallback!(200, false) // settles: the flush writes +50 + touch('touchstart') + v.resizeItem(0, 100) // deferred under the finger + touch('touchend') + expireTouchTail() // settles: the flush writes +50 expect(v.scrollAdjustments).toBe(50) v.scrollToOffset(150) // a write with no adjustments of its own expect(v.scrollAdjustments).toBe(50) - scrollCallback!(150, false) // its read-back + scroll(150, false) // its read-back expect(v.scrollAdjustments).toBe(0) }) }) @@ -2375,39 +2307,20 @@ test('iOS deferral: a write before the flush is read back keeps the flushed adju test('iOS deferral: a negative delta at the end clamp is dropped, not replayed', () => { // Regression (#1233 manifestation B): with anchorTo: 'end' and the reader // pinned at the bottom, a row above the viewport re-measuring *smaller* - // during isScrolling shrinks maxScrollOffset; the browser clamps scrollTop + // during a fling shrinks maxScrollOffset; the browser clamps scrollTop // onto the new bottom, which is already the correct end-anchored position. // The library also deferred a negative compensation for that same shrink — // replaying it on the settled, already-correct position lifts the view off // the bottom. The flush must drop the negative delta at the end clamp. withFakeIOSUserAgent(() => { - const scrollToFn = vi.fn() - let scrollCallback: - | ((offset: number, isScrolling: boolean) => void) - | null = null - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - anchorTo: 'end', - getScrollElement: () => - ({ - scrollTop: 300, // pinned at the bottom: scrollHeight - clientHeight - scrollLeft: 0, - scrollHeight: 500, - clientHeight: 200, - offsetHeight: 200, - }) as any, - scrollToFn, - observeElementRect: () => {}, - observeElementOffset: (_inst, cb) => { - scrollCallback = cb - cb(300, true) // at the bottom, scrolling - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - scrollToFn.mockClear() + 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 // A row above the viewport re-measures smaller while at the end. v.resizeItem(0, 30) // 50 → 30: total shrinks by 20 @@ -2417,7 +2330,7 @@ test('iOS deferral: a negative delta at the end clamp is dropped, not replayed', // Settle. The browser already clamped scrollTop onto the new bottom // (cur === max), so the deferred negative delta is stale and must not // replay. - scrollCallback!(300, false) + expireTouchTail() expect(v['_iosDeferredAdjustment']).toBe(0) expect(scrollToFn).not.toHaveBeenCalled() }) @@ -2429,33 +2342,11 @@ test('iOS deferral: a positive delta at the end clamp still replays (growth abov // DOM sizer may not have grown yet, so a positive deferred delta must still // flush — the end-clamp drop is negative-only. withFakeIOSUserAgent(() => { - const scrollToFn = vi.fn() - let scrollCallback: - | ((offset: number, isScrolling: boolean) => void) - | null = null - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - anchorTo: 'end', - getScrollElement: () => - ({ - scrollTop: 300, - scrollLeft: 0, - scrollHeight: 500, - clientHeight: 200, - offsetHeight: 200, - }) as any, - scrollToFn, - observeElementRect: () => {}, - observeElementOffset: (_inst, cb) => { - scrollCallback = cb - cb(300, true) - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - scrollToFn.mockClear() + 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 @@ -2463,7 +2354,7 @@ test('iOS deferral: a positive delta at the end clamp still replays (growth abov expect(v['_iosDeferredAdjustment']).toBe(20) // Settle. Growth doesn't clamp, so the positive delta must replay. - scrollCallback!(300, false) + expireTouchTail() expect(v['_iosDeferredAdjustment']).toBe(0) expect(scrollToFn).toHaveBeenCalledTimes(1) }) @@ -2482,21 +2373,28 @@ function makeDeferralHarness({ }: { ios?: 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 = { + 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) : fn()) @@ -2536,6 +2434,14 @@ function makeDeferralHarness({ 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, @@ -2555,19 +2461,19 @@ function landedIndexScrollWithResizeAbove(ios = true) { return h } -test.fails( - 'iOS deferral (#1307): a gesture before the index scroll retargets keeps the resize correction', - () => { - const h = landedIndexScrollWithResizeAbove() - h.run(() => { - h.scroll(true, 1010) - h.frame() - h.scroll(false) - // The user dragged 10px, so row 20 belongs 10px above the viewport top. - expect(h.rowTop(20)).toBe(-10) - }) - }, -) +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) @@ -2577,61 +2483,56 @@ test('non-iOS: a gesture before the index scroll retargets keeps the resize corr expect(h.rowTop(20)).toBe(-10) }) -test.fails( - '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): 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.fails( - '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): 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.fails( - '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) - }) - }, -) +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) + }) +}) test.fails( 'iOS deferral (#1307): a correction is not replayed once the viewport is above the resized row', () => { const h = makeDeferralHarness({ start: 2000 }) + h.touch('touchstart') h.run(() => { h.scroll(true, 2010) h.v.resizeItem(30, 100) - expect(h.v['_iosDeferredAdjustment']).toBe(50) - // The gesture carries the viewport above row 30, then settles. + }) + h.touch('touchend') + // The fling carries the viewport above row 30, then settles. + h.run(() => { h.scroll(true, 1000) h.scroll(false) - expect(h.rowTop(20)).toBe(0) }) + h.expireTail() + expect(h.rowTop(20)).toBe(0) }, ) @@ -2685,15 +2586,20 @@ test('scrollToIndex (#1307): a scroll away from a target that moved before landi 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.scroll(true, 3000) + }) + h.touch('touchend') + h.run(() => { + h.scroll(true, 3000) // momentum h.scroll(false) - // Applied where the momentum ended, not where the resize was seen. - expect(h.el.scrollTop).toBe(3050) - expect(h.rowTop(60)).toBe(0) }) + 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', () => { @@ -2701,6 +2607,7 @@ test('iOS deferral: a re-measure above the viewport is deferred scrolling forwar 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['_iosDeferredAdjustment']).toBe(0) @@ -2714,6 +2621,7 @@ 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['_iosDeferredAdjustment']).toBe(0) @@ -2722,15 +2630,18 @@ test('iOS deferral: a custom adjust predicate that opts out defers nothing', () 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['_iosDeferredAdjustment']).toBe(50) - h.scroll(false) - expect(h.rowTop(41)).toBe(40) }) + h.touch('touchend') + h.run(() => h.scroll(false)) + h.expireTail() + expect(h.rowTop(41)).toBe(40) }) // ─── Phase 1: touch event distinction ──────────────────────────────────────── @@ -2804,7 +2715,7 @@ test('iOS Phase 1: touchstart enters the `down` phase and ends a prior tail', () dispatchTouchEvent(el, 'touchend') // leaves a tail armed expect(v['_iosTouch']?.phase).toBe('tail') dispatchTouchEvent(el, 'touchstart') - expect(v['_iosTouch']).toEqual({ phase: 'down' }) + expect(v['_iosTouch']).toEqual({ phase: 'down', dragged: false }) }) }) @@ -2824,7 +2735,7 @@ test('iOS Phase 1: touchcancel clears touching the same as touchend, and cleanup expect(el._hasListener('touchcancel')).toBe(true) dispatchTouchEvent(el, 'touchstart') - expect(v['_iosTouch']).toEqual({ phase: 'down' }) + expect(v['_iosTouch']).toEqual({ phase: 'down', dragged: false }) dispatchTouchEvent(el, 'touchcancel') expect(v['_iosTouch']?.phase).toBe('tail') @@ -2915,47 +2826,40 @@ test('iOS Phase 1: resize in post-touchend grace window defers; flushes when tim }) }) -test('iOS Phase 1: scroll-event after touchend timer cleanup also flushes', () => { +test('iOS Phase 1: momentum scroll events re-arm the post-touchend tail until the fling ends', () => { withFakeIOSUserAgent(() => { - const scrollToFn = vi.fn() - let scrollCallback: ((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) => { - scrollCallback = cb - cb(100, true) // scrolling - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - scrollToFn.mockClear() + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer() + touch('touchstart') + touch('touchend') + const firstTimer = + (v['_iosTouch'] 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['_iosTouch']?.phase).toBe('tail') + expect( + (v['_iosTouch'] as { timerId?: number } | null)?.timerId ?? null, + ).not.toBe(firstTimer) - // Resize during scroll (no touch tracked here — pure scroll). v.resizeItem(0, 100) expect(scrollToFn).not.toHaveBeenCalled() expect(v['_iosDeferredAdjustment']).toBe(50) - // Scroll ends. Touch never started here, so nothing is in motion and - // the flush gate opens. - scrollCallback!(100, false) + scroll(140, true) // still flinging: re-armed again, still deferred + expect(v['_iosTouch']?.phase).toBe('tail') + expect(scrollToFn).not.toHaveBeenCalled() + + // 150 ms after the last frame the tail expires and the delta flushes. + expireTouchTail() + expect(v['_iosTouch']?.phase).not.toBe('tail') + expect( + (v['_iosTouch'] as { timerId?: number } | null)?.timerId ?? null, + ).toBeNull() expect(scrollToFn).toHaveBeenCalledTimes(1) expect(v['_iosDeferredAdjustment']).toBe(0) }) @@ -2984,7 +2888,7 @@ test('iOS Phase 1: new touchstart during grace window cancels pending flush time dispatchTouchEvent(el, 'touchstart') // The pending timer must have been canceled. expect(timers.has(firstTimerId)).toBe(false) - expect(v['_iosTouch']).toEqual({ phase: 'down' }) + expect(v['_iosTouch']).toEqual({ phase: 'down', dragged: false }) }) }) @@ -3038,13 +2942,12 @@ test('iOS Phase 1: scroll-element swap does not replay a stale deferred adjustme clearTimeout: globalThis.clearTimeout.bind(globalThis), } const { v, holder, makeEl, getScrollCallback } = - makeIOSVirtualizerWithSwappableEl(scrollToFn, mockWindow, { - startScrolling: true, - }) + makeIOSVirtualizerWithSwappableEl(scrollToFn, mockWindow) scrollToFn.mockClear() - // A resize above the viewport during the live scroll defers its + // 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['_iosDeferredAdjustment']).toBe(50) @@ -3074,7 +2977,7 @@ test('iOS Phase 1: scroll-element swap mid-touch does not strand the `down` phas mockWindow, ) dispatchTouchEvent(holder.el, 'touchstart') - expect(v['_iosTouch']).toEqual({ phase: 'down' }) + expect(v['_iosTouch']).toEqual({ phase: 'down', dragged: false }) // The in-flight touch keeps targeting the old element (implicit touch // capture), so the new element will never deliver its touchend. Without @@ -3192,53 +3095,27 @@ test('Phase 2a: user-initiated scroll (large delta) is NOT reconciled to intende test('Phase 2b: flush skipped when scrollTop is in elastic-overscroll zone (negative)', () => { 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() + const { v, el, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer() + touch('touchstart') - // Resize during scroll: defers + // Resize during the drag: defers v.resizeItem(0, 100) expect(v['_iosDeferredAdjustment']).toBe(50) - // User rubber-bands past the top: scrollTop becomes negative. - // Even though isScrolling=false now, the elastic-zone check blocks - // the flush so we don't snap-back to a clamped position. + // 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 - scrollCb!(-25, false) + scroll(-25, true) + touch('touchend') + expireTouchTail() expect(scrollToFn).not.toHaveBeenCalled() expect(v['_iosDeferredAdjustment']).toBe(50) // still deferred - // User releases, scroll snaps back in-bounds. Next scroll event - // should successfully flush. + // Bounce-back resolves in-bounds. The next scroll event flushes. el.scrollTop = 100 - scrollCb!(100, false) + scroll(100, false) expect(scrollToFn).toHaveBeenCalled() expect(v['_iosDeferredAdjustment']).toBe(0) }) @@ -3246,47 +3123,24 @@ test('Phase 2b: flush skipped when scrollTop is in elastic-overscroll zone (nega test('Phase 2b: flush skipped when scrollTop > scrollHeight-clientHeight (overscroll bottom)', () => { 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, // max valid scrollTop = 300 - 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() + const { v, el, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer() // clientHeight 200 → max valid scrollTop = 300 + touch('touchstart') v.resizeItem(0, 100) + expect(v['_iosDeferredAdjustment']).toBe(50) - // User pulls past the bottom: scrollTop becomes 350 (> max 300). + // 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 - scrollCb!(350, false) + scroll(350, true) + touch('touchend') + expireTouchTail() expect(scrollToFn).not.toHaveBeenCalled() // Bounce-back resolves el.scrollTop = 300 - scrollCb!(300, false) + scroll(300, false) expect(scrollToFn).toHaveBeenCalled() }) }) @@ -3376,7 +3230,7 @@ test('iOS Phase 1: non-iOS still does NOT install touch state machine', () => { const { v, el } = makeIOSVirtualizerWithRealEl(scrollToFn, mockWindow) dispatchTouchEvent(el, 'touchstart') - expect(v['_iosTouch']).toEqual({ phase: 'down' }) // touchstart still records (cheap) + expect(v['_iosTouch']).toEqual({ phase: 'down', dragged: false }) // touchstart still records (cheap) dispatchTouchEvent(el, 'touchend') // Non-iOS path returns before arming a tail expect(v['_iosTouch']).toBeNull() @@ -6003,3 +5857,299 @@ test('observeElementRect defers to a frame of the scroll element window and canc 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['_iosTouch']?.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['_iosDeferredAdjustment']).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['_iosTouch']?.phase).not.toBe('down') + expect(v['_iosTouch']?.phase).not.toBe('tail') + expect( + (v['_iosTouch'] 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['_iosDeferredAdjustment']).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['_iosTouch']?.phase).toBe('tail') + + // The command's own write cancels any momentum, so it closes the tail. + v.scrollToIndex(8) + expect(v['_iosTouch']?.phase).not.toBe('tail') + expect( + (v['_iosTouch'] as { timerId?: number } | null)?.timerId ?? null, + ).toBeNull() + scroll(250, true) // the echo must not re-open it + expect(v['_iosTouch']?.phase).not.toBe('tail') + scrollToFn.mockClear() + + v.resizeItem(0, 100) + expect(v['_iosDeferredAdjustment']).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['_iosTouch']?.phase).toBe('down') + scrollToFn.mockClear() + + v.resizeItem(0, 100) + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_iosDeferredAdjustment']).toBe(50) + }) +}) + +test('#1250: a scrollToIndex with no target leaves the fling running and drops a stale deferral', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer() + touch('touchstart') + touch('touchend') + scroll(120, true) // momentum + v.resizeItem(0, 100) + expect(v['_iosDeferredAdjustment']).toBe(50) + const timer = + (v['_iosTouch'] 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 deferral is dropped like on every absolute + // command, since it may describe rows that are gone (as in 3.19.2). + v.measurementsCache = [] + v.scrollToIndex(5) + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_iosDeferredAdjustment']).toBe(0) + expect(v['_iosTouch']?.phase).toBe('tail') + expect( + (v['_iosTouch'] as { timerId?: number } | null)?.timerId ?? null, + ).toBe(timer) + + // A resize during the remaining momentum still defers rather than + // writing scrollTop into the fling. + v.resizeItem(1, 80) + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_iosDeferredAdjustment']).toBe(30) + + expireTouchTail() + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(v['_iosDeferredAdjustment']).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['_iosTouch']?.phase).toBe('down') + touch('touchcancel') + expect(v['_iosTouch']?.phase).not.toBe('down') + expect(v['_iosTouch']?.phase).toBe('tail') + + expireTouchTail() + expect(v['_iosTouch']?.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['_iosTouch']?.phase).toBe('tail') + + // The command's own write stops any momentum, so it closes the tail. + command(v) + expect(v['_iosTouch']?.phase).not.toBe('tail') + scroll(el.scrollTop, true) // the read-back must not re-open it + scrollToFn.mockClear() + + v.resizeItem(0, 100) + expect(v['_iosDeferredAdjustment']).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['_iosDragging']()) 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['_iosTouch']).toEqual({ phase: 'down', dragged: false }) + row.dispatchEvent(new Event('touchend', { bubbles: true })) + expect(v['_iosTouch']?.phase).toBe('tail') + + unmount() + row.dispatchEvent(new Event('touchstart', { bubbles: true })) + expect(v['_iosTouch']).toBeNull() + scrollEl.remove() + }) + } finally { + window.ResizeObserver = prevRO + } +}) diff --git a/packages/virtual-core/tests/ios-differential.test.ts b/packages/virtual-core/tests/ios-differential.test.ts index b985c6d44..7cb37102a 100644 --- a/packages/virtual-core/tests/ios-differential.test.ts +++ b/packages/virtual-core/tests/ios-differential.test.ts @@ -465,25 +465,10 @@ const cases: Array = [ }, ] -// Cases where iOS still diverges, by id. All but P drop a correction deferred -// while an index scroll runs (#1305) instead of applying it the way non-iOS -// does; P misses the pending deferral when classifying the next resize. -// #1307 fixes them and must remove them from this list: the test asserts -// that each one still diverges, so a fix can't go unnoticed. -const KNOWN_DIVERGENT = new Set([ - 'B', - 'G', - 'H', - 'I', - 'K', - 'M', - 'M2', - 'N', - 'N2', - 'O3', - 'P', - 'Z', -]) +// Cases where iOS still diverges, by id. Programmatic scrolls no longer defer +// on iOS (only touch-driven ones do), so none of the scripted cases diverge; +// a case added here must still diverge, so a fix can't go unnoticed. +const KNOWN_DIVERGENT = new Set([]) describe('iOS deferral settles where non-iOS does', () => { for (const c of cases) { diff --git a/packages/virtual-core/tests/scroll-trace.test.ts b/packages/virtual-core/tests/scroll-trace.test.ts index 6b1ea2cf8..e45fc4502 100644 --- a/packages/virtual-core/tests/scroll-trace.test.ts +++ b/packages/virtual-core/tests/scroll-trace.test.ts @@ -282,6 +282,8 @@ function simulate( let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = null let touching = false + // The finger has moved the content since it touched down. + let dragged = false // A native smooth scroll in flight, and a frame of it computed before an // instant write replaced it, which WebKit can still apply and report. let smoothTarget: number | null = null @@ -298,8 +300,9 @@ function simulate( scrollToFn: (offset, { adjustments = 0, behavior }) => { const to = offset + adjustments writes.push(`${behavior === 'smooth' ? 's' : 'w'}${to}`) - // iOS ignores a programmatic write while a finger is down. - if (ios && touching) return + // iOS ignores a programmatic write while a finger drags; under a + // finger resting on the content the write lands. + if (ios && touching && dragged) return if (behavior === 'smooth') { smoothTarget = clamp(to) lateFrame = null @@ -470,11 +473,13 @@ function simulate( // A user scroll stops a native smooth animation. smoothTarget = null lateFrame = null + if (touching) dragged = true el.pos = clamp(el.pos + step.d) scroll(true) break case 'touchstart': touching = true + dragged = false smoothTarget = null lateFrame = null fire('touchstart') From 3c61b6e30723e6af24d375ea1c5221d12a2e894d Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Wed, 14 Oct 2026 12:46:19 +0200 Subject: [PATCH 16/46] fix(virtual-core): hold a scroll correction the element cannot take (#1319) --- .changeset/quiet-rows-hold.md | 7 + docs/api/virtualizer.md | 4 + examples/react/chat/src/main.tsx | 10 +- packages/virtual-core/src/index.ts | 287 +++++++---- .../__snapshots__/scroll-trace.test.ts.snap | 216 ++++---- packages/virtual-core/tests/index.test.ts | 283 +++++++---- .../virtual-core/tests/prepend-hold.test.ts | 465 ++++++++++++++++++ .../virtual-core/tests/scroll-trace.test.ts | 52 +- 8 files changed, 1004 insertions(+), 320 deletions(-) create mode 100644 .changeset/quiet-rows-hold.md create mode 100644 packages/virtual-core/tests/prepend-hold.test.ts diff --git a/.changeset/quiet-rows-hold.md b/.changeset/quiet-rows-hold.md new file mode 100644 index 000000000..d856953bc --- /dev/null +++ b/.changeset/quiet-rows-hold.md @@ -0,0 +1,7 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): hold a scroll correction the element cannot take instead of writing it (#1287) + +A prepend's anchor delta or an above-viewport size change that lands while a `scrollTop` write is impossible (iOS during a touch gesture or its momentum tail, any WebKit while the element is rubber-banding past an edge) is now held: `scrollOffset` carries the corrected position, the rows and the total size are exposed shifted back by the held amount so every adapter keeps the reader's row where it is, and one write moves the element once a write is possible. Before, the write was discarded during the bounce on desktop Safari (the bounce stalled with the new rows and a blank top on screen), and on iOS the new rows showed through the bounce and snapped back at the flush. diff --git a/docs/api/virtualizer.md b/docs/api/virtualizer.md index 4d8885363..2c5349185 100644 --- a/docs/api/virtualizer.md +++ b/docs/api/virtualizer.md @@ -623,3 +623,7 @@ scrollOffset: number ``` This option represents the current scroll position along the scrolling axis. It is measured in pixels from the starting point of the scrollable area. + +It is a position in the list's layout, and it stays consistent with `getOffsetForIndex`, the argument to `scrollToOffset`, and `initialOffset`: restoring a saved value returns to the same layout position. That is the same item only while the items and sizes before it are unchanged; to restore across changes, pair it with `takeSnapshot()` and `initialMeasurementsCache` as described above, or scroll to the item with `scrollToIndex`. + +While a scroll correction is held, because a write to the element is impossible at that moment (a touch gesture or its momentum on iOS, or the element rubber-banding past an edge), `getVirtualItems()` and `getTotalSize()` describe what is rendered, shifted by the held amount so the rows on screen do not move, while `scrollOffset` keeps the layout position. The hold ends with a single write once the element can take it. Prefer `getVirtualItems()` positions over `item.start - scrollOffset` for where a row sits in the viewport, since that difference is off by the held amount for the length of the gesture or bounce. diff --git a/examples/react/chat/src/main.tsx b/examples/react/chat/src/main.tsx index 7329f8f28..f37cc3d6f 100644 --- a/examples/react/chat/src/main.tsx +++ b/examples/react/chat/src/main.tsx @@ -34,6 +34,7 @@ function App() { const nextMessageIndexRef = React.useRef(initialMessages.length) const streamTimerRef = React.useRef(null) const loadingHistoryRef = React.useRef(false) + const lastScrollTopRef = React.useRef(0) const [messages, setMessages] = React.useState(initialMessages) const [loadingHistory, setLoadingHistory] = React.useState(false) const [didInitialScroll, setDidInitialScroll] = React.useState(false) @@ -178,7 +179,14 @@ function App() { className="Messages" onScroll={(event) => { if (!autoHistoryEnabled || virtualizer.isAtEnd(80)) return - if (event.currentTarget.scrollTop < 120) { + const { scrollTop } = event.currentTarget + const movingUp = scrollTop < lastScrollTopRef.current + lastScrollTopRef.current = scrollTop + // Load only while the reader scrolls up into the top. Safari's + // rubber band reports negative positions past the top and then + // settles back down to 0; neither is the reader asking for more + // history, and loading on the settle would load twice per bounce. + if (movingUp && scrollTop >= 0 && scrollTop < 120) { prependHistory() } }} diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 5135de4e9..a5da612e6 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -526,6 +526,13 @@ export class Virtualizer< private lanesSettling = false private pendingScrollAnchor: PendingScrollAnchor | null = null scrollRect: Rect | null = null + /** + * The scroll position in the list's layout, consistent with + * `getOffsetForIndex`, `scrollToOffset` and `initialOffset`. While a + * correction is held (`_holdOffset`), `getVirtualItems()` and + * `getTotalSize()` describe what is rendered, shifted by the held amount; + * this stays the layout position. + */ scrollOffset: number | null = null scrollDirection: ScrollDirection | null = null // Size compensation written to the element but not yet reflected in @@ -534,10 +541,31 @@ export class Virtualizer< get scrollAdjustments() { return this.pendingWrite?.adjustments ?? 0 } - // Sum of size-change deltas above-viewport that were skipped during a - // touch-driven iOS scroll (writing scrollTop mid-momentum cancels it, - // #884). Flushed in a single scrollTo once the gesture has settled. - private _iosDeferredAdjustment = 0 + // Scroll corrections (a prepend's anchor delta, size changes above the + // viewport) that could not be written to the element because a write was + // impossible at the time (see `_canWriteScroll`): the difference between + // `scrollOffset`, which always carries the corrected layout position, and + // where the element is. While it is non-zero the rows and the total size + // are exposed shifted by `-_holdOffset`, so every adapter renders the + // reader's row where it was, writes subtract it (`_scrollToOffset`) and + // scroll events add it back (the handler in `_willUpdate`). Released by + // `_flushHoldIfReady`: the rows re-render in layout coordinates and the + // element moves by the same amount in one paint. + private _holdOffset = 0 + // Whether the element's last scroll event reported a position past either + // edge (the rubber-band bounce). Judged at report time against the + // element's own max, with a 1px tolerance for a fractional scrollTop: a + // sizer that shrinks after the report (a trim) is not a bounce, and + // `scrollOffset` is no judge either, since an eager bump past the max while + // the sizer has not grown yet (#1258) is not one. + // + // Stored rather than read from the element when a write is decided: the + // two differ once a write has landed but not been reported back, and in + // that window the element reads in bounds while `scrollOffset` is still + // the pre-write value, so an anchor sync would write that stale offset + // over the command (the step 5 stall in #1307; 56 of 1080 snapshot seeds). + // Reading the element there is a behaviour change to take with that fix. + private _reportedOverscroll = false // The touch gesture in progress. iOS WebKit ignores a scrollTop write // while a finger drags the content (one resting on it does not stop the // write) and cancels momentum on one after it, so adjustments are deferred @@ -624,6 +652,7 @@ export class Virtualizer< this._resizeBatch = null if (batch.notify) { this.notify(batch.sync) + this._flushHoldIfReady(true) this._retryClampedAdjustment() } } @@ -836,7 +865,15 @@ export class Virtualizer< if (followOnAppend) { this.pendingScrollAnchor = { kind: 'follow', behavior: followOnAppend } } else if (anchorDelta !== null) { - this.pendingScrollAnchor = { kind: 'anchor', delta: anchorDelta } + if (this._canWriteScroll()) { + this.pendingScrollAnchor = { kind: 'anchor', delta: anchorDelta } + } else { + // The element cannot take the write (a touch owns it on iOS, or it is + // overscrolled). Hold the delta instead: this render already exposes + // the rows and the total shifted back by it, so the reader's row + // stays put, and the flush writes it once a write is possible. + this._holdOffset += anchorDelta + } } } @@ -858,27 +895,17 @@ export class Virtualizer< console.info('correction', delta) } - if (isIOSWebKit() && this._iosInMotion()) { - // Only a touch defers, so an index scroll meets this only with a finger - // down. If the finger scrolls, its first scroll event ends the index - // scroll and the flush applies what was deferred; if it lifts without - // moving, reconcile's next retarget already contains the correction - // and clears it (see reconcileScroll). - this._iosDeferredAdjustment += delta - return false + if (!this._canWriteScroll()) { + // Hold the correction: `scrollOffset` takes it as if written, the rows + // are exposed shifted back by it. The notify must be synchronous like + // a write's, so the shifted rows commit in the same paint as the + // content change that caused the correction. + this._holdOffset += delta + if (this.scrollOffset !== null) this.scrollOffset += delta + return true } else { const target = this.getScrollOffset() + this.scrollAdjustments + delta - // Guarded so a bare test double without `scrollHeight` / `document` - // does not crash in `getMaxScrollOffset`. - const el = this.scrollElement - const maxAtWrite = - el !== null && ('scrollHeight' in el || 'document' in el) - ? this.getMaxScrollOffset() - : null - this._clampedAdjustment = - maxAtWrite !== null && target > maxAtWrite + 0.5 - ? { target, maxAtWrite } - : null + this._clampedAdjustment = this._clampedBy(target) this._scrollToOffset(this.getScrollOffset(), { adjustments: this.scrollAdjustments + delta, behavior, @@ -897,7 +924,7 @@ export class Virtualizer< // Clamp only the lower bound: a negative offset is unreachable, and // on an unscrollable element (content fits the viewport) no scroll // event ever fires to correct it, permanently skewing - // getDistanceFromEnd() and wedging _flushIosDeferredIfReady (#1229). + // getDistanceFromEnd() and wedging the hold flush (#1229). // Upper-bound overflow stays untouched — it is transiently // legitimate mid-prepend while the consumer's sizer catches up. if (this.scrollOffset < 0) this.scrollOffset = 0 @@ -956,7 +983,8 @@ export class Virtualizer< // grace window would strand `_iosTouch` (the listeners that end it were // just removed), deferring every adjustment on the new element until // its next touch cycle. - this._iosDeferredAdjustment = 0 + this._holdOffset = 0 + this._reportedOverscroll = false this._endIosTouch() this._clampedAdjustment = null // A write to the old element will never be read back. @@ -1005,6 +1033,16 @@ export class Virtualizer< this.unsubs.push( this.options.observeElementOffset(this, (offset, isScrolling) => { + // Past the top is never a resting position. Past the bottom is + // judged only while scrolling: at rest the element has clamped, + // and a stale sizer makes no bounce. + const elementMax = isScrolling ? this._elementMaxScrollOffset() : null + this._reportedOverscroll = + offset < 0 || (elementMax !== null && offset > elementMax + 1) + // Everything below works in layout coordinates: the element is + // `_holdOffset` behind them while a correction is held. + offset += this._holdOffset + // A scroll event that reports movement but lands on the offset we // already hold — and isn't a self-write read-back — is a spurious // no-op re-emit that Safari/Firefox fire after a re-render's layout @@ -1100,8 +1138,8 @@ export class Virtualizer< this._iosTouch.dragged = true } - // Flush deferred iOS adjustments once the gesture has settled. - this._flushIosDeferredIfReady() + // A held correction is written once a write is possible. + this._flushHoldIfReady(true) if (this.scrollState) { this.scheduleScrollReconcile() @@ -1113,7 +1151,7 @@ export class Virtualizer< // Touch event listeners (iOS-aware deferral). We attach unconditionally // — the listeners are passive and cheap; on non-touch devices they // simply never fire. The gating by isIOSWebKit() lives in resizeItem - // and _flushIosDeferredIfReady so we only burn the path on iOS. + // and _canWriteScroll so we only burn the path on iOS. if ('addEventListener' in this.scrollElement) { const scrollEl = this.scrollElement as unknown as EventTarget const onTouchStart = () => { @@ -1173,12 +1211,13 @@ export class Virtualizer< // virtualizer already computed the correct range during render. // Now sync the browser's actual scroll position to match. // - // On iOS WebKit, writing scrollTop during touch/momentum cancels - // the in-flight scroll. Defer the DOM sync the same way - // applyScrollAdjustment does — accumulate the delta and let - // _flushIosDeferredIfReady handle it once the scroll settles. - if (isIOSWebKit() && this._iosInMotion()) { - this._iosDeferredAdjustment += anchor.delta + // `setOptions` found the element writable; a scroll event between + // it and this commit (a concurrent render) can have changed that. + // Hold the delta after all and have the rows re-rendered shifted; + // an update scheduled from a layout effect commits before paint. + if (!this._canWriteScroll()) { + this._holdOffset += anchor.delta + this.notify(false) } else if ( isSmoothScroll(this.scrollState) && !approxEqual( @@ -1205,6 +1244,11 @@ export class Virtualizer< } } + // A hold whose cause has passed without a scroll event (the sizer caught + // up with a position that read as overscrolled) is written here. An + // update scheduled from a layout effect commits before paint, so the + // notify need not be synchronous. + this._flushHoldIfReady(false) // The consumer has committed the new total size by now, so a clamped // compensation write may have room (#1258). this._retryClampedAdjustment() @@ -1261,7 +1305,7 @@ export class Virtualizer< timerId: this.targetWindow.setTimeout(() => { if (this._iosTouch === tail) this._iosTouch = null // The last momentum frame may have come before the timer. - this._flushIosDeferredIfReady() + this._flushHoldIfReady(true) }, 150), } this._iosTouch = tail @@ -1274,36 +1318,48 @@ export class Virtualizer< this._iosTouch = null } - // Apply any accumulated iOS-deferred scroll adjustment, but only when we're - // truly settled — not actively scrolling, not under an active touch, and - // past the post-touchend grace window. Called from the scroll callback - // and the touchend grace-timer. - private _flushIosDeferredIfReady = () => { - if (this._iosDeferredAdjustment === 0) return - if (this._iosInMotion()) return - // Phase 2b: Safari elastic-overscroll (rubber-band) lets scrollTop go - // negative or beyond scrollHeight - clientHeight. Writing scrollTop - // while in that zone snaps the page back to the clamped value at the - // end of the bounce, often discarding the user's intent. Skip the - // flush; the next in-bounds scroll event will retry. - const cur = this.getScrollOffset() - const max = this.getMaxScrollOffset() - if (cur < 0 || cur > max) return - // At the end clamp the browser already absorbed a shrink above the - // viewport (it clamped scrollTop onto the new bottom), so replaying our - // deferred negative delta would lift the view off the bottom — drop it. - // Positive deltas still replay: growth above doesn't clamp. (#1233) - if (this._iosDeferredAdjustment < 0 && cur >= max - 1) { - this._iosDeferredAdjustment = 0 - return + // Whether a scrollTop write made now would reach the element and stay: + // - not on iOS while a touch gesture owns the scroll (`_iosTouch`): a + // dragging finger ignores the write, momentum is cancelled by it on + // iOS 26 and earlier and overrides it on 27 (#884, #1307); + // - not while the element is overscrolled past either edge (the + // rubber-band bounce): WebKit discards the write and the bounce can + // stall where it was (#1287). + // A write that is unwanted for another reason (a travelling smooth scroll + // owns the position) is a separate check at the call sites. + private _canWriteScroll = () => + !(isIOSWebKit() && this._iosInMotion()) && !this._reportedOverscroll + + // Write a held correction once a write is possible, from the scroll + // handler, the touch tail timer, `_willUpdate` and a resize notify. The + // rows go back to layout coordinates and the total grows by the hold in a + // notify issued before the write, so the sizer can take the position and + // both commit in the paint the write lands in. `sync` is false only where + // the caller already runs inside the consumer's commit (`_willUpdate`), + // where an update it schedules commits before paint anyway. + private _flushHoldIfReady = (sync: boolean) => { + if (this._holdOffset === 0) return + if (!this.scrollElement || !this.options.enabled) return + if (!this._canWriteScroll()) return + // Let a travelling smooth scroll land first (see `_willUpdate`). + if (this.scrollState?.phase === 'smooth') return + this._holdOffset = 0 + // A held shrink can leave the layout position above the top; the + // element clamps that to 0 without an event to say so (#1229). + if (this.scrollOffset !== null && this.scrollOffset < 0) { + this.scrollOffset = 0 } - const delta = this._iosDeferredAdjustment - this._iosDeferredAdjustment = 0 - // Roll the deferred delta into the running accumulator so any resize - // landing between now and the resulting scroll event computes from the - // post-flush offset rather than the stale one. - this._scrollToOffset(cur, { - adjustments: this.scrollAdjustments + delta, + this.notify(sync) + // A command whose write has not been reported back yet owns the target: + // `scrollOffset` is still the pre-command position until its read-back, + // and a command that closes the touch tail makes a write possible with + // no scroll event in between, so this flush can run first. + const target = this.getCommandWrittenOffset() ?? this.getScrollOffset() + // A consumer that sizes the container after this write gets the retry + // (#1258). + this._clampedAdjustment = this._clampedBy(target) + this._scrollToOffset(target, { + adjustments: undefined, behavior: undefined, }) } @@ -1419,10 +1475,6 @@ export class Virtualizer< adjustments: undefined, behavior: keepSmooth ? 'smooth' : 'auto', }) - // The new target is computed from current sizes, so this write - // already contains any correction deferred on iOS. Under a dragging - // finger iOS ignores the write, so the deferral stays for the flush. - if (!this._iosDragging()) this._iosDeferredAdjustment = 0 } } @@ -1923,7 +1975,7 @@ export class Virtualizer< this.options.anchorTo === 'end' && !isSmoothScroll(this.scrollState) && this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold - const prevTotalSize = wasAtEnd ? this.getTotalSize() : 0 + const prevTotalSize = wasAtEnd ? this._getLayoutTotalSize() : 0 // Default anchoring predicate (used unless the consumer supplies a // custom shouldAdjustScrollPositionOnItemSizeChange). const scrollOffsetWithAdj = @@ -1994,7 +2046,7 @@ export class Virtualizer< if (!awaitingCommandReadBack) { if (wasAtEnd) { adjustedSync = this.applyScrollAdjustment( - this.getTotalSize() - prevTotalSize, + this._getLayoutTotalSize() - prevTotalSize, ) } else if (shouldAdjustScroll) { adjustedSync = this.applyScrollAdjustment(delta) @@ -2023,20 +2075,31 @@ export class Virtualizer< // (direct DOM updates) may never re-render when the range is // unchanged, so retry a clamped write here rather than only in // `_willUpdate` (#1266). + this._flushHoldIfReady(true) this._retryClampedAdjustment() } } getVirtualItems = memo( - () => [this.getVirtualIndexes(), this.getMeasurements()], - (indexes, measurements) => { + () => [this.getVirtualIndexes(), this.getMeasurements(), this._holdOffset], + (indexes, measurements, hold) => { const virtualItems: Array = [] for (let k = 0, len = indexes.length; k < len; k++) { const i = indexes[k]! const measurement = measurements[i]! - virtualItems.push(measurement) + // A held correction is exposed as a shift of every row (see + // `_holdOffset`); the layout itself is untouched. + virtualItems.push( + hold === 0 + ? measurement + : { + ...measurement, + start: measurement.start - hold, + end: measurement.end - hold, + }, + ) } return virtualItems @@ -2068,26 +2131,42 @@ export class Virtualizer< return notUndefined(measurements[idx]) } - private getMaxScrollOffset = () => { - if (!this.scrollElement) return 0 + // The element's scroll range in layout coordinates: while a correction is + // held the sizer is short by the hold, so the element's own max is too. + private getMaxScrollOffset = () => + (this._elementMaxScrollOffset() ?? 0) + this._holdOffset - if ('scrollHeight' in this.scrollElement) { - // Element + // The element's own scroll range, or null where it cannot be read (no + // element, or a bare test double without `scrollHeight` / `document`). + private _elementMaxScrollOffset = (): number | null => { + const el = this.scrollElement + if (el === null) return null + if ('scrollHeight' in el) { return this.options.horizontal - ? this.scrollElement.scrollWidth - this.scrollElement.clientWidth - : this.scrollElement.scrollHeight - this.scrollElement.clientHeight - } else { - // Window - const doc = this.scrollElement.document.documentElement + ? el.scrollWidth - el.clientWidth + : el.scrollHeight - el.clientHeight + } + if ('document' in el) { + const doc = el.document.documentElement return this.options.horizontal - ? doc.scrollWidth - this.scrollElement.innerWidth - : doc.scrollHeight - this.scrollElement.innerHeight + ? doc.scrollWidth - el.innerWidth + : doc.scrollHeight - el.innerHeight } + return null + } + + // A compensation write past the element's current max (the sizer has not + // grown yet) is clamped by the browser; record it for the retry (#1258). + private _clampedBy = (target: number) => { + const elementMax = this._elementMaxScrollOffset() + if (elementMax === null) return null + const maxAtWrite = elementMax + this._holdOffset + return target > maxAtWrite + 0.5 ? { target, maxAtWrite } : null } private getVirtualDistanceFromEnd = () => { return Math.max( - this.getTotalSize() - this.getSize() - this.getScrollOffset(), + this._getLayoutTotalSize() - this.getSize() - this.getScrollOffset(), 0, ) } @@ -2190,13 +2269,8 @@ export class Virtualizer< index = Math.max(0, Math.min(index, this.options.count - 1)) const offsetInfo = this.getOffsetForIndex(index, initialAlign) - if (!offsetInfo) { - // No target (an empty list), so nothing is written; still drop the - // deferral like every absolute command, or it replays once the list - // refills. - this._iosDeferredAdjustment = 0 - return - } + // No target (an empty list), so nothing is written. + if (!offsetInfo) return const [offset, align] = offsetInfo this._startScroll({ index, align }, offset, behavior, 'absolute') @@ -2243,12 +2317,10 @@ export class Virtualizer< // Every scroll command starts here: it becomes the in-flight command, // writes its first target and leaves reconcile to retarget and retire it. // - // An absolute command derives its target from current measurements, so - // any iOS-deferred compensation still pending is stale by definition — - // the command already accounts for the measurements the delta was - // compensating for. Drop it so _flushIosDeferredIfReady doesn't replay it - // onto the just-established position. A relative command (scrollBy) - // builds on the current offset, so it keeps the deferral. + // Targets are layout offsets. A held correction is not dropped by a + // command: the write lands the element `_holdOffset` short of the target, + // under the rows exposed shifted by the same amount, and the flush moves + // both together once a write is possible. // // An index or end target that is already at its offset starts a smooth // scroll as landed; an offset target never does, so its first scroll @@ -2260,7 +2332,6 @@ export class Virtualizer< kind: 'absolute' | 'relative', ) { if (kind === 'absolute') { - this._iosDeferredAdjustment = 0 // The command takes over the scroll, so close the tail: the landing's // corrections then apply before paint, even for a command issued from // a tap handler inside the tail (#1250). On iOS 26 and earlier the @@ -2295,7 +2366,13 @@ export class Virtualizer< this.scrollState = null } - getTotalSize = () => { + // The size the consumer gives the container: the layout's total, short by + // a held correction so the rows exposed shifted by it still fill it (see + // `_holdOffset`). + getTotalSize = () => + Math.max(this._getLayoutTotalSize() - this._holdOffset, 0) + + private _getLayoutTotalSize = () => { const measurements = this.getMeasurements() let end: number @@ -2384,7 +2461,13 @@ export class Virtualizer< smooth: behavior === 'smooth', adjustments: adjustments ?? this.scrollAdjustments, } - this.options.scrollToFn(offset, { behavior, adjustments }, this) + // The element sits `_holdOffset` behind the layout while a correction + // is held; `target` and the read-back stay in layout coordinates. + this.options.scrollToFn( + offset - this._holdOffset, + { behavior, adjustments }, + this, + ) } // Where the in-flight command's last instant write put the element, until diff --git a/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap b/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap index 353862da0..31300e65b 100644 --- a/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap +++ b/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap @@ -2,198 +2,198 @@ exports[`scroll behaviour snapshot > anchorEnd ios 1`] = ` [ - "0: 282cf6e1 e8d72086 32d84175 41f39894 e6545a49 bdfeac4e e157de78 2eeb4a65 a7e60471 91ea3dfe", - "10: a6af11d6 d39cf9a3 675d7128 fdfa8085 f7c95667 153cee4a 76a1e959 e2ca2c19 ca3469b5 fcd5167f", - "20: 78469a2b 61474ab4 ac511a18 0121b5a8 9151c75d e4eea90c 670a738f 797533c3 7e2251a6 668a6a76", - "30: d3ddb03a 92c65137 6e9d6453 dd2e21be 8a13a7ef 0065547f fb8aa82b 8141e566 4e3b9609 d9883551", - "40: 41e98d8e ffdc4099 b9048a44 b6fe40dd 05de3a84 a2be79ab 3dc0fa80 ac2dd208 bb53d67d dbe98727", - "50: 49d7572b 1ae7af8c 6d833ccb 470fa38b 36079ddd 118919eb 68db84a8 93867ea2 83ea3d20 1d26883e", + "0: ea2cac43 85b526da ed35961e 2f2e6cc4 71c65ae4 0eeb9610 33f6e53e 2a95e498 9d2aaa43 c8d7218b", + "10: 6e7306be 67a48d7b 56c4a6ad ddd93b12 3952ce1c 83082b83 8b6948e0 7081e813 65b7d405 7f3041e0", + "20: 8e4723f5 f780f908 13bd300f d53e7a2d 80f708b1 ec458f03 7533b661 6408933e 9f3ec27c 09a1874e", + "30: d0da31f6 e08a7697 beba95dc b0cf23fd 436c1d33 9b9b908c c8c87f50 d9749dba 552b4b5b 93ac03b2", + "40: 351e701d 2f446794 d8e6a0e3 4d60c709 0b280b4f ac75fa53 868d9e7b 7f5b2824 780cae35 8f0a8fe9", + "50: d2429922 8cc11ea8 810e9f05 bb003fd5 8fcc29b1 aee1412a bccee2e5 5d36e37b e9fb7a13 6dabad9d", ] `; exports[`scroll behaviour snapshot > anchorEnd web 1`] = ` [ - "0: 7e29eee4 689a8f9d 0be3bc81 0616f785 df872403 5dc6ddf6 efaed7ca 2eeb4a65 c98b85de 4d4f6d9c", - "10: 58d7e5b7 095832b0 675d7128 346f8f84 446602ce 153cee4a 8435976c bf6dff7a ca3469b5 fcd5167f", - "20: bd51101f 61474ab4 ae20db6d e6d6d477 1622bd05 0a125131 82351452 2db4f7e9 3b01d988 668a6a76", - "30: 187247d3 710fc3ad 314c91f8 dd2e21be a77f38e7 0065547f fefa6230 56e2fdaa 4e3b9609 720cd6cd", - "40: 930d4f71 5ca47fb5 8e942a09 b6fe40dd 2b9c8efc fcc62c79 3dc0fa80 9ec92707 3becd415 857463c5", - "50: 49d7572b 1ae7af8c 5370d3af 8103bbac 36079ddd f7788a1a c7c11306 93867ea2 02314a9e ddaed9da", + "0: ea2cac43 4d0cdcdd 2054f343 f5c1a3e7 35821d3b dc6157c1 4594e9e3 2a95e498 dbc275c5 22ff1998", + "10: a764fdc3 aef54094 56c4a6ad 8ece1eee 59bfcd8d 83082b83 05d160db 100c06e5 65b7d405 7f3041e0", + "20: 6616fe9d f780f908 13bd300f 30643489 8107ab97 ec458f03 63ed23da 68a18267 253fe61c 09a1874e", + "30: 5e6688c5 0d4221f3 5aa253d8 46527f28 2872565f 9b9b908c e14b74d0 8f93c51a 552b4b5b 660f3603", + "40: 93dd81a7 c9c39478 622e6d21 4d60c709 e721cb06 5b6d2738 868d9e7b f3ef7742 2aa59586 886aae03", + "50: d2429922 8cc11ea8 81d0357b 5c7e085f 8fcc29b1 3cea4ec5 27af96a0 5d36e37b 3c8d8225 2c598250", ] `; exports[`scroll behaviour snapshot > base ios 1`] = ` [ - "0: b9137b9c 394fee8b 32d84175 a64b15f0 eecef157 81f99e26 3770b8af 6a913d03 e792721d a72cd3ec", - "10: b0d48519 57d1d43b 043cf059 eafa6bc0 35c18da0 f5d2f4d7 87494a18 e2ca2c19 30031c34 a218de69", - "20: d69a02f6 65844fed ac511a18 ef97077c b4e88922 b105dbdd 670a738f 86c19915 17976cbd 6e2f429c", - "30: d3ddb03a 966d3422 5d07e323 e2f91462 7e888af6 7b794ad1 71950a2c 8141e566 10edfac1 d9883551", - "40: 41e98d8e fd7d6df4 044dcfac 715efd76 da040dcb 2cbec68e 35c640e7 be6d2097 14042112 ca941eb9", - "50: 49d7572b 6ce0ab7d 6d833ccb fe140621 58dd9721 17c1f1d0 bad2e9d3 4c0075f5 83ea3d20 d1ae57b0", + "0: a9fcb9d4 0d2cec68 ed35961e 0a69fa03 0be21da6 3ec8eb85 869fa2d1 27716a81 f885c716 44f33e92", + "10: 9dedc3e2 a924913e 455a1c1e f463e55c f807b968 83082b83 c0d001ca 7081e813 d1336903 7b73eb01", + "20: 65dd1acd ceac4ab2 2c8bfacf 98bf94c9 34191b80 a7b02f04 7533b661 f6030c4d 4ddf6da0 a5002541", + "30: d0da31f6 d8bbcf3a ea6dc035 aec3dd30 586ac9dd 46df8677 c4ac0a4d d9749dba 5b30b633 d4289992", + "40: 351e701d 3645f268 2bf7e8fd feacb1d8 9ce9ecf7 ccef0b14 f9ead96e 30236a00 28000854 3b4e9ef5", + "50: d2429922 07cac42c 029f0060 8861b4ff b30f44cd 28fd4f51 b68d2291 1841edc4 8632a12b f6d88486", ] `; exports[`scroll behaviour snapshot > base web 1`] = ` [ - "0: 487989ab 5b01c685 008a101f 987345a6 71d2dd97 0123605a 402cb4cd 6a913d03 d7a52f21 96c702b6", - "10: 30b67f8e 6665d259 043cf059 449c1d27 cb64d201 f5d2f4d7 8dd5e943 bf6dff7a 30031c34 a218de69", - "20: e256a75c 65844fed ae20db6d eef3b427 c0030496 d0eba513 82351452 10cc53a8 17976cbd 6e2f429c", - "30: a9fe18da a2431d61 3227e8b2 e2f91462 dff2fed9 7b794ad1 ea14f6e3 56e2fdaa 10edfac1 c2384cef", - "40: 4233947f 3aa25331 0fe80f3d 715efd76 cacc0bb6 dbc3e3ba 35c640e7 17678dfd 61acfd2f 4aeaefa7", - "50: 49d7572b 6ce0ab7d 14bb6efa be9301e2 58dd9721 d507a64a b2a3d459 4c0075f5 0d4a9e27 f210954f", + "0: a9fcb9d4 e79e71f6 993e0b9d 52eea5f6 f7591689 ef961068 b71055b4 27716a81 8b825bba 94652b32", + "10: 5c9cc689 e2e1c3ec 455a1c1e 18a26060 0accf4b5 83082b83 c0d001ca 100c06e5 d1336903 7b73eb01", + "20: 7886a736 ceac4ab2 2c8bfacf 6865f6c2 d47af093 a7b02f04 63ed23da 715e0039 4ddf6da0 a5002541", + "30: 5978f80e 0c5f12e2 718ad4b7 aec3dd30 1b99b475 46df8677 4e88df21 8f93c51a 5b30b633 66d94fc8", + "40: 8249f4ed 2170911f c938e8f5 feacb1d8 826217e9 8a2b7146 f9ead96e d1dc687f 36e554f8 03c3bcc1", + "50: d2429922 07cac42c 78c9d354 cfd20837 b30f44cd 5f27e381 5c11a132 1841edc4 36b0937d 9ddde49c", ] `; exports[`scroll behaviour snapshot > horizontal ios 1`] = ` [ - "0: b9137b9c 394fee8b 32d84175 a64b15f0 eecef157 81f99e26 3770b8af 6a913d03 e792721d a72cd3ec", - "10: b0d48519 57d1d43b 043cf059 eafa6bc0 35c18da0 f5d2f4d7 87494a18 e2ca2c19 30031c34 a218de69", - "20: d69a02f6 65844fed ac511a18 ef97077c b4e88922 b105dbdd 670a738f 86c19915 17976cbd 6e2f429c", - "30: d3ddb03a 966d3422 5d07e323 e2f91462 7e888af6 7b794ad1 71950a2c 8141e566 10edfac1 d9883551", - "40: 41e98d8e fd7d6df4 044dcfac 715efd76 da040dcb 2cbec68e 35c640e7 be6d2097 14042112 ca941eb9", - "50: 49d7572b 6ce0ab7d 6d833ccb fe140621 58dd9721 17c1f1d0 bad2e9d3 4c0075f5 83ea3d20 d1ae57b0", + "0: a9fcb9d4 0d2cec68 ed35961e 0a69fa03 0be21da6 3ec8eb85 869fa2d1 27716a81 f885c716 44f33e92", + "10: 9dedc3e2 a924913e 455a1c1e f463e55c f807b968 83082b83 c0d001ca 7081e813 d1336903 7b73eb01", + "20: 65dd1acd ceac4ab2 2c8bfacf 98bf94c9 34191b80 a7b02f04 7533b661 f6030c4d 4ddf6da0 a5002541", + "30: d0da31f6 d8bbcf3a ea6dc035 aec3dd30 586ac9dd 46df8677 c4ac0a4d d9749dba 5b30b633 d4289992", + "40: 351e701d 3645f268 2bf7e8fd feacb1d8 9ce9ecf7 ccef0b14 f9ead96e 30236a00 28000854 3b4e9ef5", + "50: d2429922 07cac42c 029f0060 8861b4ff b30f44cd 28fd4f51 b68d2291 1841edc4 8632a12b f6d88486", ] `; exports[`scroll behaviour snapshot > horizontal web 1`] = ` [ - "0: 487989ab 5b01c685 008a101f 987345a6 71d2dd97 0123605a 402cb4cd 6a913d03 d7a52f21 96c702b6", - "10: 30b67f8e 6665d259 043cf059 449c1d27 cb64d201 f5d2f4d7 8dd5e943 bf6dff7a 30031c34 a218de69", - "20: e256a75c 65844fed ae20db6d eef3b427 c0030496 d0eba513 82351452 10cc53a8 17976cbd 6e2f429c", - "30: a9fe18da a2431d61 3227e8b2 e2f91462 dff2fed9 7b794ad1 ea14f6e3 56e2fdaa 10edfac1 c2384cef", - "40: 4233947f 3aa25331 0fe80f3d 715efd76 cacc0bb6 dbc3e3ba 35c640e7 17678dfd 61acfd2f 4aeaefa7", - "50: 49d7572b 6ce0ab7d 14bb6efa be9301e2 58dd9721 d507a64a b2a3d459 4c0075f5 0d4a9e27 f210954f", + "0: a9fcb9d4 e79e71f6 993e0b9d 52eea5f6 f7591689 ef961068 b71055b4 27716a81 8b825bba 94652b32", + "10: 5c9cc689 e2e1c3ec 455a1c1e 18a26060 0accf4b5 83082b83 c0d001ca 100c06e5 d1336903 7b73eb01", + "20: 7886a736 ceac4ab2 2c8bfacf 6865f6c2 d47af093 a7b02f04 63ed23da 715e0039 4ddf6da0 a5002541", + "30: 5978f80e 0c5f12e2 718ad4b7 aec3dd30 1b99b475 46df8677 4e88df21 8f93c51a 5b30b633 66d94fc8", + "40: 8249f4ed 2170911f c938e8f5 feacb1d8 826217e9 8a2b7146 f9ead96e d1dc687f 36e554f8 03c3bcc1", + "50: d2429922 07cac42c 78c9d354 cfd20837 b30f44cd 5f27e381 5c11a132 1841edc4 36b0937d 9ddde49c", ] `; exports[`scroll behaviour snapshot > lanes ios 1`] = ` [ - "0: fcaf5c9d aa31c81a be5bc264 8da067a6 dfcce7b5 b2ab7fc2 5f919201 2eb29692 fc68d8b2 b460a830", - "10: f1721ff2 2c803d87 098712d0 8eacfdaa 4199ffbb e9749cd5 3e7ce136 af772ff3 35688b69 13731b17", - "20: 349946e9 a31a22c8 7d53dcfb 635cd06e 1262e458 ba3c88b6 7abc0006 3fff9c9d 1557d683 868f0885", - "30: f9b1e990 7ebf54e7 e82dec91 eab3a7a5 63dafad0 6c26f17c 55e138ed aa618abf b4eddb66 755dcabd", - "40: d5eca131 b28ff8fd 17499af6 436ccb21 4a599390 55f47063 e8117c25 c03f0587 4a3744c8 df44b055", - "50: 052daa55 84a561da 16da1777 fbe709a7 9b4f0ed6 e991a5c5 437a67cf 6955068d 48b89640 411167cb", + "0: 02580197 f314892c fd721b30 93e795a5 84959476 631e3681 7114dede de5f2a5e 576db818 2e50c67d", + "10: 514a82cf 25c644f3 7a8d2a09 2abee23f 5872f8d5 c103e623 c4d2fe64 554a1766 21de167e 487aeb4b", + "20: f2245f58 81bd9a1e 79b54054 6e12648a 38539662 4b10cf13 0bddc3e4 11317c75 bbfdc970 9e7a8ac3", + "30: 12468132 20c7aff9 67dcb6d5 03291755 c9ffab63 5f0a007d ec1d6907 f41ff60d f7397d3b 701d9fb6", + "40: d7be12c4 972dd8d5 cd9e5629 bc8f344f 60dc134e 96523d8a d0d37a83 08867236 7da74ce4 bbba1406", + "50: 89573ef3 4d90e248 f737f69e c3acfdb6 39c42ebf 55ca59be e01db9de cedb1bda cc70c64d 00e88da4", ] `; exports[`scroll behaviour snapshot > lanes web 1`] = ` [ - "0: 913fdc9a 44c91e8a de866186 dd261f52 9567771f 883fb01a ead92723 2eb29692 dc6ff20e 83acd62b", - "10: ec2808c8 1206b940 098712d0 0757677f edd71fea e9749cd5 61c90f24 af772ff3 35688b69 13731b17", - "20: 27c29bfd a31a22c8 7d53dcfb 6fc580cc faa00cb7 db732cad a5ee212e 0eebc6bd 30a86913 868f0885", - "30: acb6e31d e8c8e9fc 2f529382 eab3a7a5 90deb18a 6c26f17c 55e138ed 95816a75 b4eddb66 6982dc83", - "40: 973917b4 fb244062 a0aa1a22 436ccb21 1f20c834 d7ec176b e8117c25 30f35459 001073ef a16538f7", - "50: 052daa55 84a561da d8c34776 1f9f2c33 9b4f0ed6 e991a5c5 437a67cf 6955068d f7e64c83 ac5b1d69", + "0: 07b019d6 50ba0f48 2528ce0b 56751a0d f27c3816 3ad778af 510a0a14 de5f2a5e 8a8df520 9c4df1f6", + "10: 0e9abc72 9ab1beaa 7a8d2a09 cc167259 bfd342b3 c103e623 cb717084 554a1766 21de167e 487aeb4b", + "20: 3fb995ae 81bd9a1e 79b54054 e99e680d f2c453bb 4b10cf13 588f804a bf451280 912642c1 9e7a8ac3", + "30: 5a9c1d81 20c7aff9 2fecb4a5 03291755 c12620e1 5f0a007d ec1d6907 e64d3f41 f7397d3b 2e9ca3d4", + "40: 1bd23826 16a5fa48 410c3108 bc8f344f 149cffe2 49344e33 d0d37a83 d4926881 5860422c bf14dd85", + "50: 89573ef3 4d90e248 05101a4e 9886e68a 39c42ebf 55ca59be e01db9de cedb1bda 9a73528c b6ea452c", ] `; exports[`scroll behaviour snapshot > margin ios 1`] = ` [ - "0: 018f8bc1 3a7d502e e838ecd9 39fb33f0 08167c3f 68c0ffe1 2ad09a58 64ae8a6b e6cbbb22 0afc4c25", - "10: 6f7ebbb5 1ce7bc30 13f22c53 c148c8ec 14ab1ac1 ecee60a0 83262f2f 53754f4c 95ab6afb 216d3915", - "20: c72c4685 20db46ae 75e019ea 27e49ca2 22dc2b58 2f3bb231 7144e417 41821127 fae1bada a823f96b", - "30: d53f1b14 c264e66c fdd20473 443556ba 58ecae93 297593ed 0948c083 8319e805 143e728c 2c0c1154", - "40: 4f75cbae 10d1b2b9 d2ee5348 e7cf9b97 9ba76728 b34a15db db745b6d 1189858f 33312219 7fc66f6b", - "50: 3a707136 b84f25a9 c15682e0 20a25d74 5ea402c9 64d706c8 57fdb2d6 83602b38 5ed0daa5 8bc44cb4", + "0: 261ee8cc 0c7104ce d660522f 1ec6b870 04b73dd1 c3569067 ee016b0d a9e0ea4e 59fe0c9b 2aa76e6a", + "10: 80298488 1fad233c b47b8c57 b8cc23a1 3ec99fea d356dc6d 50cd4bd0 3d840e20 11b3151f c61a840f", + "20: 46215df2 cb7198cf 9af764d5 e5ad1c1d d11595a6 d0cffbec 91864f2d 09a185e0 13de058c 200d4ab4", + "30: daee6069 adb8145a 76ab5658 f4d2cc30 06a88aad f2b45513 21218db4 ae0aae24 94e08cb6 d7c3c8ec", + "40: 4a4806a4 1866c892 fc88e237 22ee5368 a5c3ad27 7f1a8f34 20a8933f 657292c1 18ac5575 12553696", + "50: 5baeb658 6ee3c4c0 c19ed580 f97f375d 3371d972 c1111271 ffba99f2 95424c35 80d71aa6 1cbe5ceb", ] `; exports[`scroll behaviour snapshot > margin web 1`] = ` [ - "0: 49373050 e55d4ec9 cee1b367 a3254862 1ef733d1 58bff51e d68cc542 64ae8a6b f14542ec 4bd369ad", - "10: 2b352d93 04119bf1 13f22c53 fef4c6fa 9f9e6742 ecee60a0 43c89810 1dcffc84 95ab6afb 216d3915", - "20: bc80e48e 20db46ae 78cf9df4 17df29c9 b4fd89c4 c8813f01 b750a0b3 d9a4907f fae1bada a823f96b", - "30: eafca965 7578434d 19072ae6 443556ba 0444fbdd 297593ed 1b0ce1f2 2f6c5f87 143e728c 88cd8be1", - "40: a9344c4a 83de4592 d5ec42ee e7cf9b97 a24fffb3 25efdf06 db745b6d c6e0b773 426d0896 0969cb81", - "50: 3a707136 b84f25a9 f5e55c65 1869f200 5ea402c9 86e83842 73608dd3 83602b38 5cdf3ac0 2fcf8d37", + "0: 261ee8cc 1bcd5eb5 322f7016 624f6fc6 19fb4c9e 15dfe2bb 3885748f a9e0ea4e 7f75e46a 47e71105", + "10: 1eb2c1cc e7142fbf b47b8c57 8d71e7f5 3293ed90 d356dc6d 50cd4bd0 f87e296b 11b3151f c61a840f", + "20: 9e4b77f5 cb7198cf 9af764d5 a3f06d61 167abfaf d0cffbec f4d9b186 d9d12542 13de058c 200d4ab4", + "30: aadc45f7 37ce7a93 13cb8f5e f4d2cc30 c28043e6 f2b45513 8801c1ac c8ac484a 94e08cb6 2171ac81", + "40: 62dfcbaf 657f1b43 a3a841e4 22ee5368 d3e0e255 bfb3f87c 20a8933f 0d454d4f 70957626 87f80beb", + "50: 5baeb658 6ee3c4c0 cec3a144 d5b8753e 3371d972 2ad8996d 2f514fed 95424c35 c8a0843e ce539614", ] `; exports[`scroll behaviour snapshot > padding ios 1`] = ` [ - "0: d3045c89 a54e0eda 8f2db94a d4e8f78e 03b80765 2a389364 83cebad2 0b0cef6b 2ae2094c 2a4d76eb", - "10: 5ae330a7 8d5be956 b1525893 76ca5911 d878c1db 11a5136d 760b05f7 850708e0 bf84b064 9ff484b0", - "20: a7bdbbbe 39c3a659 3488eba0 c7fb67e3 ada281e3 87232ce3 10ddc090 bcae7173 463a3308 d44c4b80", - "30: 838fc730 b3a64a42 0f4a2a52 090eb33f aa89e778 1ed26ae5 e6ff4491 86dc7002 9e8a9ec6 b7262b75", - "40: 7310c7a3 7ca8764d a0cac638 4be67f5c 37e91a94 df233e84 0f218eec 234019d7 147564f1 4839096f", - "50: c1d891be 46031c46 33613584 3e4aa2c1 eafdfeea 3c2af533 a63c6e71 74b5c434 ace495d9 6a5add71", + "0: e9721c4d dcb0f21f 18405d52 e436b769 f5c14497 07f7e518 a982c9e4 af8fca50 a6eb7f64 dee165cf", + "10: ba206f9a 5aefcded 26c9a16c f9840309 d361bfdc d22d2dd5 d4aeac18 2a07ffad 6ae4631e 49506420", + "20: d4877fd0 16d557b8 38e86f41 ff6ab12a f4961e61 b76dcd97 617d9bb0 93aecc40 985508df 5acd3353", + "30: 02990288 6d83029c 8fb1a01a 5f0500ca 462e5344 f169e58d 9b1787a0 3ee0819e d2426644 a4fea305", + "40: 9a038802 7c9998b7 9e53cc8c 463cadf3 b29581cb 5780bba7 367b0648 813d469f 21f1ae21 0d99ff3c", + "50: b6c70c73 b71f1b03 96523fcf 2f898413 c43aed20 cd25e67f 75a4fb65 0348b3bd a3efb38d 564ccd91", ] `; exports[`scroll behaviour snapshot > padding web 1`] = ` [ - "0: 262f9200 6cc65ec8 76d3e20e 59f6e440 3b2d9255 5f8ecbe5 dacfa504 0b0cef6b 2aeece4d c605d3e7", - "10: 9209f21f 9a76d74f b1525893 2d286847 7eb9a778 11a5136d 9d2b2319 301fcdae bf84b064 9ff484b0", - "20: ba67793f 39c3a659 79d02652 e65de77a f034a31e 2b525f87 32719c78 6dc03f3b 463a3308 d44c4b80", - "30: 5e0da9b0 3bfbd321 d24f1bc5 090eb33f c0552bc9 1ed26ae5 6758427f 44947dec 9e8a9ec6 8a2d1fda", - "40: 145e7cfe a49334ea a1717141 4be67f5c db3e39f2 8258a6e9 0f218eec 5f658c23 18a8d574 4fa2cd5e", - "50: c1d891be 46031c46 f6e8b31a 70e79ae5 eafdfeea a4afe30d 319c4097 74b5c434 038a4e91 f7d38c9a", + "0: e9721c4d af1d9abe e182cab2 7aa8c885 74180472 d8994dbb c80a88c1 af8fca50 fadc7677 add699ca", + "10: 933b080e 115d6622 26c9a16c 004719f5 ee44c967 d22d2dd5 42e6a809 6ce09593 6ae4631e 49506420", + "20: bdaed687 16d557b8 38e86f41 81161c78 019967ec b76dcd97 6f4e1938 388fa384 985508df 5acd3353", + "30: 9a11ccb6 9f638159 3f15048e 5f0500ca bffc6d8d f169e58d b05998f6 ade3fdcf d2426644 b81cee7d", + "40: e0f1e462 e3c3fb87 2f0b2154 463cadf3 9606c3c5 405da09f 367b0648 d045fc6a 4397f224 f02c1c2a", + "50: b6c70c73 b71f1b03 63791db8 83a98231 c43aed20 86e98758 f8464fd0 0348b3bd 5d2531e6 0dbf1083", ] `; exports[`scroll behaviour snapshot > predicate ios 1`] = ` [ - "0: 55ced412 06ec2779 29a71490 52ae616e 7ff4d61a 7cc8db13 0651e36a 9c09701d 6a2172ec 79006d50", - "10: c2613462 48593dd3 8c0dfa4f e63fc8bd a4bc5e0b 561aec84 ce137e95 57e0517c cdd88f3d 0f2b0911", - "20: 58bc106a 73ac63fa b2cae6d0 2ec0de96 6f0049e9 11c825de 717e6d9c 6a4de7af 50199ebc 669bc632", - "30: 191877d8 5f78905e a870d34f f4883cc3 70ca1a9c e6adacc4 3dbb43d8 e171cccc 063455d7 168763c3", - "40: 8954f2f7 621fde8c cfc5bd0a 5f2b5151 3ec31fb0 20302f8c deefedde f458a3c2 48cf9410 0e878c91", - "50: 3eee4840 08d2c17c c9b5e84d bab65960 68df2e14 6923c1e4 ef80266e 6cd6c09b ee76ebae 180ce8ad", + "0: 1503987b da28a13a 54033cbd 75995293 b092f4c8 d731f6c6 96911d82 a3c61d73 0b82d0ed 2b56adca", + "10: 75e68079 2bc31908 888425aa 5683ada3 2b4bbf15 89be8bb7 ebfa70d4 9e4371d0 8134fb03 750041da", + "20: 9f915474 4ca88661 54fd2515 1bc77d21 0daaca4c b368ca7e 35aedf09 b8e64de7 675f9530 e6c44365", + "30: 65d2a727 4f062147 b61ba9ff 4c406d77 0c47a6ac 7cbdb0c8 d11197a6 caebb6d3 82cf0b2e 087a5d0c", + "40: 079790e8 ae3a4374 d5dec481 ac3796cc a2874f32 2fba1d55 f5b117d9 384995f7 54142c26 d1dae953", + "50: 274e92c7 4040e298 953852cd 61422c39 9274c4ab 606ef818 51abbdc9 0ab9d71e e817a892 eab073cc", ] `; exports[`scroll behaviour snapshot > predicate web 1`] = ` [ - "0: ceb8aef1 4ea74420 bb609c62 de75aa51 5fc4f7d9 da63d4fc f9078e1c 9c09701d 64fa823a 8023cd25", - "10: afed87ee e49f63f7 abb793c9 a79e9a0b 20bd4665 561aec84 3e478366 57e0517c cdd88f3d 0f2b0911", - "20: d9eaf56d 9b5e6dab 2a5d09f5 0b96eee9 ad2d297a a6957b9a 43ddb32d e22c5e15 50199ebc 669bc632", - "30: 7ad89bbf 2a468fed 4fbf6499 f4883cc3 7e9faf3d e6adacc4 658f7ce7 ed93f986 063455d7 e76aff2c", - "40: 3fe9f887 f249d560 cfc5bd0a 5f2b5151 d9ce470c c93a259b b2cbaa3c ebc73663 c84e7f41 2b04619d", - "50: 3eee4840 6696c908 c7601071 7ccce5c6 68df2e14 0dc4fb86 fc4c3ef8 6cd6c09b 9b7f3b18 8d7cefab", + "0: 1ae1fc6f d2c3d67c e2050bb4 704e0476 1a3b7240 b3620452 ea6b67f3 a3c61d73 09968012 d9597113", + "10: c449bc54 0a115668 20a0834c a6fbd596 bec39b4c 89be8bb7 dddeb6fa 9e4371d0 8134fb03 750041da", + "20: a7943819 4ca88661 48a2a876 9c67b567 48734ee1 b368ca7e e989bf83 ce41bea9 675f9530 e6c44365", + "30: f30525e8 7cab4af4 5d76b154 4c406d77 7417e967 7cbdb0c8 9eeab09f ea061649 82cf0b2e 65080957", + "40: 81e56faa a8750438 d5dec481 ac3796cc 1d279633 03a1759f 046db9c2 1e074853 a0c847de 842f2579", + "50: 274e92c7 5b749586 ad467091 da0b01ac 9274c4ab e0f3944f f21db6d8 0ab9d71e d02c95f3 0ef2236c", ] `; exports[`scroll behaviour snapshot > smooth ios 1`] = ` [ - "0: 29560eee 394fee8b 577c0ab3 bf1ebb2f 8c4e0ffd c7477e32 e1862abe 4e69fa05 47a3f3b7 c8a09f5c", - "10: 0b70aa37 e9522bd0 78080d39 9e77b30e f4fdf51c 425c54ed c369b253 577590ad d9e2fc70 24d0175d", - "20: 45a192f8 d2561f12 054dfac1 9bfef053 ca3c6797 4863001d 5bdd0d27 bc5c94a8 b946b740 f9884ae7", - "30: ebe8f266 e35f5818 38472d8f c389598d ef52afb4 d277009e 54c9817c 9602c7e7 1130d5f2 ae9e39ef", - "40: 517bd4ad 1d2b9887 534ef4db a8d62bed 527091d5 bac62a70 d10aecd5 dd6f23ef 4f5c00c0 b3b02f05", - "50: 7583e0ce abc4d1a1 fff32c85 dc9a3a09 1bdbe3ee 22c69134 2d809341 fa65bc54 143f84f0 f9876d32", + "0: 413b0ced 0d2cec68 66c9821e 3bfbda41 3fda8ed3 550b7721 cc983b99 d2c2c7b5 206ab6a1 5349a5d1", + "10: 9cebb1b0 4551cde0 44b57ffe 957921f5 4aad38ab a18fdfbb 0774eacc 756893ae 4696c4a5 5eed2d84", + "20: e539345c b0a7dd6f 4896f81f 27b2d3ba 346d6565 84e55162 9ebde14c 7e222fc0 750c0379 058cdf95", + "30: 0951be3d b2f398b4 484a6a70 bf605550 a5b43ec3 dcb008be d63bc86d 8843c6fe 08adf872 f2174e51", + "40: d547d43d aa8537ce 07c0b8de e5550cbe ec8db268 8b69617d 8081644f 41db25d8 11184006 bdb8315c", + "50: 19abec1f 4fb6c1ab 8d61fd93 5dd57fc7 2ac08291 47498c09 f6f82128 8657fa1a 3e65831f 0a52e4f8", ] `; exports[`scroll behaviour snapshot > smooth web 1`] = ` [ - "0: 8fbb0495 5b01c685 91a6c6e4 bf1ebb2f afbd6f06 c7477e32 fd7e58a4 4e69fa05 a2835af5 c91a5a3b", - "10: 0b70aa37 d8af31d2 78080d39 d0771b77 f4fdf51c 425c54ed 32840af5 577590ad d9e2fc70 24d0175d", - "20: 45a192f8 d2561f12 054dfac1 d4a92be6 88407c5b 4863001d 83239375 f8cc8acb b946b740 f9884ae7", - "30: b93d44fc 2912b3af 824ed264 c389598d 1f9cb3ea d277009e 54c9817c 9602c7e7 1130d5f2 d7f24592", - "40: 6fabd248 1d2b9887 534ef4db a8d62bed 17df6866 bac62a70 d10aecd5 dd6f23ef 311b7f10 753fa9df", - "50: 7583e0ce abc4d1a1 1a86e0f2 dc9a3a09 1bdbe3ee 22c69134 ffa64041 fa65bc54 143f84f0 ae02b7c4", + "0: 5d0e73cf e79e71f6 d9d14c97 3bfbda41 5d032c26 550b7721 c5e88040 d2c2c7b5 c021f057 efa64341", + "10: 9cebb1b0 20adba06 44b57ffe 07ac2e71 4aad38ab a18fdfbb 788e5344 756893ae 4696c4a5 5eed2d84", + "20: e539345c b0a7dd6f 4896f81f 27b2d3ba 50fae1e7 84e55162 06db7ddb 791bf6e9 750c0379 058cdf95", + "30: fa3d2909 02f4ab33 18b8ebaf bf605550 a5b43ec3 dcb008be d63bc86d 8843c6fe 08adf872 41fd348c", + "40: a221087e aa8537ce 07c0b8de e5550cbe 29f17361 8b69617d 8081644f 41db25d8 4dc2862c d5ef069a", + "50: 19abec1f 4fb6c1ab 3e6d4bdb 5dd57fc7 2ac08291 47498c09 28f6d83f 8657fa1a 3e65831f 32a8803c", ] `; exports[`scroll behaviour snapshot > variable ios 1`] = ` [ - "0: 8146f037 6155e2a6 5fb13b22 9c426db8 800a7c78 aafb8878 a4f3574c ca52ec1d f83130c6 90a838ad", - "10: d9a4342f 385b3b3b ff092a81 7c803d97 431d8223 a8ba89e9 a22b0008 1411fce3 f9d943d7 a71816ef", - "20: d2020c60 9baead9b 8030788b 756ad8d1 bfb0a8e7 c3e995e4 6bb19c0e bffedfc7 d1137ceb a563038a", - "30: 5b0bd5a4 f51e21ba 9899a730 4cef1e03 9e86c65a 7685028b d9eff3c9 64b1a1ec c56b35b2 780547a4", - "40: ef852267 c6c96d9f 5a3a092a 4ee85f57 22a1eda8 f3bb74ee 0b5ef252 7c4cd982 c07aadf7 52a1513b", - "50: 7b360174 8844a6d3 6888fe36 9c5e8846 c2c92a5b 060c0f41 7a75f6ba 45bc6bde 10a5e16e 85cffc65", + "0: fba1d210 fa631490 10ca2747 e52f854d a8be165f 9e3f53bf 64e27748 f8555140 83d2f832 b8476e01", + "10: c3b629ee 2241edf9 3749dd54 2ec01d94 b80e2d99 35a5d702 9e405f75 00348828 e733c82e 8cf6ab83", + "20: b4503328 51a2636d a2d78904 8981d9d5 dbf6ef00 54b5c799 9db3ba75 f6b55614 15571a06 33c5fefe", + "30: 3a2d53e2 78feab0d 1d33a8fc 81af3bc0 f0822adb 5ae0b4b8 66390281 07985b90 f324d93f b5ca5f73", + "40: 3618653b 7a3e670d 3a0f5128 2eff8a63 15d50e38 4acfcd3d fb8201be 2bcbe71b 51789ded a9a59579", + "50: cf093dbf 4e46224b 0d0e32d4 9099a967 a1d38a54 bb0e9805 b7c7159e dfa18869 744f5055 55a140d2", ] `; exports[`scroll behaviour snapshot > variable web 1`] = ` [ - "0: 4befed9d d60af59c b3f9031a e05e1391 66dde016 d0db0f5e 01b76a1e ca52ec1d 2035229a d7829734", - "10: 7180696c 6d90fc69 ff092a81 6f9efb4f 1d9fadb7 a8ba89e9 cb6c2752 1411fce3 f9d943d7 a71816ef", - "20: 0443f0f4 9baead9b 2aab582d 4765385b add24019 7686aa4d 21b8c09a 934d4019 d1137ceb a563038a", - "30: ea4d0eca 41c250c8 c6c0904b 4cef1e03 b994c59c 7685028b 260a5782 b2bfaa33 c56b35b2 0b87eb37", - "40: e94aefcf b68ec494 b0f3a0fb 4ee85f57 18c33c8c 2cd3bbde 0b5ef252 5be529a2 f5af3da2 8ea35dfa", - "50: 7b360174 8844a6d3 d8080fce 5f9c9870 c2c92a5b 3fb6fe01 908013b8 45bc6bde f68930b5 9960546c", + "0: fba1d210 f7a493d1 c7edc065 7d2fa696 55db772c 700f9f0e ea581546 f8555140 08464bd4 a7ca5253", + "10: 4ebba73b 4ade12df 3749dd54 e3be55c0 623fed56 35a5d702 9e405f75 00348828 e733c82e 8cf6ab83", + "20: 767e932e 51a2636d a2d78904 7fff63a7 a2082489 54b5c799 6ec75f50 27c0ed86 15571a06 33c5fefe", + "30: 0485f0a2 404fd16b bf291888 81af3bc0 4f3ecf39 5ae0b4b8 c9fd1dd6 93ed46ad f324d93f 51d4859f", + "40: 75f87bba 0cb596ca 3d23fc8e 2eff8a63 a75e4c9c d7477845 fb8201be 2579843b e0c71981 160a8e9f", + "50: cf093dbf 4e46224b f0d6fcaa ae490ce3 a1d38a54 2cef4545 42ed8d60 dfa18869 6b511c01 f39bf4c5", ] `; diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 23ac44bbf..2a4c60965 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -2093,12 +2093,12 @@ test('iOS deferral: compensation is deferred during a touch-driven scroll and fl // 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['_iosDeferredAdjustment']).toBe(50) + expect(v['_holdOffset']).toBe(50) // The tail armed by the last momentum frame expires: gesture settled. expireTouchTail() expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(v['_iosDeferredAdjustment']).toBe(0) + expect(v['_holdOffset']).toBe(0) }) }) @@ -2116,70 +2116,81 @@ test('iOS deferral: multiple resizes during a fling accumulate and flush as one' v.resizeItem(2, 70) expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_iosDeferredAdjustment']).toBe(45) + expect(v['_holdOffset']).toBe(45) expireTouchTail() // Single flush call expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(v['_iosDeferredAdjustment']).toBe(0) + expect(v['_holdOffset']).toBe(0) }) }) -test('iOS deferral: scrollToIndex with no target still drops a pending deferred adjustment', () => { - // An absolute command drops the deferral even when it has no target to - // write (an empty list), as it did before the commands shared an entry - // point; otherwise the stale delta replays once the list refills (found by - // @phcorp's differential run on #1316). +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: deferred + 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 would replay the delta + expireTouchTail() // settles: the flush writes the layout position - expect(scrollToFn).not.toHaveBeenCalled() + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(140) + expect(v['_holdOffset']).toBe(0) }) }) -test('iOS deferral: an absolute scroll command invalidates a pending deferred adjustment', () => { - // Regression (#1233 manifestation A): scrollToOffset/scrollToIndex derive - // their target from CURRENT measurements, so any deferred compensation still - // pending is stale — replaying it on the next flush shifts the list off the - // just-established target by the accumulated delta. The absolute commands - // must drop the deferral. +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 - // Accumulate a deferred adjustment during the drag. + // Hold a correction during the drag. v.resizeItem(0, 100) - expect(v['_iosDeferredAdjustment']).toBe(50) + expect(v['_holdOffset']).toBe(50) + expect(v.scrollOffset).toBe(240) - // An absolute command should clear the stale deferral. + // The command writes the element 50 short of its layout target. v.scrollToOffset(300) - expect(v['_iosDeferredAdjustment']).toBe(0) + expect(v['_holdOffset']).toBe(50) + expect(scrollToFn.mock.calls.at(-1)![0]).toBe(250) - // scrollToIndex clears it too (finger still down, so the resize defers). - v.resizeItem(1, 100) - expect(v['_iosDeferredAdjustment']).toBe(50) + v.resizeItem(1, 100) // held too (finger still down) + expect(v['_holdOffset']).toBe(100) v.scrollToIndex(5) - expect(v['_iosDeferredAdjustment']).toBe(0) + expect(scrollToFn.mock.calls.at(-1)![0]).toBe(250 - 100 + 50) scrollToFn.mockClear() - // Settling must not replay any (now dropped) delta. + // 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() - scroll(300, false) - expect(v['_iosDeferredAdjustment']).toBe(0) - expect(scrollToFn).not.toHaveBeenCalled() + 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) }) }) @@ -2250,13 +2261,12 @@ test('iOS deferral: a resize during an in-flight scrollToIndex is not replayed a }) }) -test('iOS deferral: flushed delta is rolled into scrollAdjustments so back-to-back resizes stay consistent', () => { - // Regression: the deferred flush used to write `adjustments: delta` - // directly without updating `this.scrollAdjustments`. If a second resize - // landed before the resulting scroll event fired (and reset the - // accumulator), the comparison `itemStart < getScrollOffset() + - // scrollAdjustments` would miss the flushed delta and the next correction - // would compute from the stale offset. +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 }) @@ -2264,54 +2274,61 @@ test('iOS deferral: flushed delta is rolled into scrollAdjustments so back-to-ba touch('touchend') scroll(200, true) - // Build up a deferred adjustment of 50 during the fling. v.resizeItem(0, 100) - expect(v['_iosDeferredAdjustment']).toBe(50) + expect(v['_holdOffset']).toBe(50) + expect(v.scrollOffset).toBe(250) expect(v['scrollAdjustments']).toBe(0) - // Settle: the flush must roll the deferred delta into scrollAdjustments. expireTouchTail() expect(scrollToFn).toHaveBeenCalledTimes(1) - const [, opts] = scrollToFn.mock.calls[0]! - expect(opts.adjustments).toBe(50) - // The running accumulator must now reflect the flushed delta — any - // resize landing before the resulting scroll event fires has to see - // the correct effective offset. - expect(v['scrollAdjustments']).toBe(50) + 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 keeps the flushed adjustment', () => { - // `scrollAdjustments` tracks compensation written but not yet reported. - // A later write that passes no adjustments (a command, a reconcile, the - // anchor sync) does not report anything, so the flushed delta stays until - // the scroll event (found by @phcorp's mutation run on #1316). +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) // deferred under the finger + v.resizeItem(0, 100) // held under the finger touch('touchend') - expireTouchTail() // settles: the flush writes +50 - expect(v.scrollAdjustments).toBe(50) + 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(50) + 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 negative delta at the end clamp is dropped, not replayed', () => { - // Regression (#1233 manifestation B): with anchorTo: 'end' and the reader - // pinned at the bottom, a row above the viewport re-measuring *smaller* - // during a fling shrinks maxScrollOffset; the browser clamps scrollTop - // onto the new bottom, which is already the correct end-anchored position. - // The library also deferred a negative compensation for that same shrink — - // replaying it on the settled, already-correct position lifts the view off - // the bottom. The flush must drop the negative delta at the end clamp. +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( @@ -2321,18 +2338,20 @@ test('iOS deferral: a negative delta at the end clamp is dropped, not replayed', 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['_iosDeferredAdjustment']).toBe(-20) + expect(v['_holdOffset']).toBe(-20) + expect(v.scrollOffset).toBe(280) + expect(v.getTotalSize()).toBe(totalBefore) - // Settle. The browser already clamped scrollTop onto the new bottom - // (cur === max), so the deferred negative delta is stale and must not - // replay. expireTouchTail() - expect(v['_iosDeferredAdjustment']).toBe(0) - expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_holdOffset']).toBe(0) + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(280) + expect(v.getTotalSize()).toBe(totalBefore - 20) }) }) @@ -2351,11 +2370,11 @@ test('iOS deferral: a positive delta at the end clamp still replays (growth abov // 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['_iosDeferredAdjustment']).toBe(20) + expect(v['_holdOffset']).toBe(20) // Settle. Growth doesn't clamp, so the positive delta must replay. expireTouchTail() - expect(v['_iosDeferredAdjustment']).toBe(0) + expect(v['_holdOffset']).toBe(0) expect(scrollToFn).toHaveBeenCalledTimes(1) }) }) @@ -2610,10 +2629,10 @@ test('iOS deferral: a re-measure above the viewport is deferred scrolling forwar h.el._dispatch('touchstart') h.scroll(true, 1990) h.v.resizeItem(5, 100) - expect(h.v['_iosDeferredAdjustment']).toBe(0) + expect(h.v['_holdOffset']).toBe(0) h.scroll(true, 2020) h.v.resizeItem(5, 120) - expect(h.v['_iosDeferredAdjustment']).toBe(20) + expect(h.v['_holdOffset']).toBe(20) }) }) @@ -2624,7 +2643,7 @@ test('iOS deferral: a custom adjust predicate that opts out defers nothing', () h.el._dispatch('touchstart') h.scroll(true, 2010) h.v.resizeItem(5, 100) - expect(h.v['_iosDeferredAdjustment']).toBe(0) + expect(h.v['_holdOffset']).toBe(0) }) }) @@ -2636,7 +2655,7 @@ test('iOS deferral: the first measure of the row spanning the viewport top is co expect(h.rowTop(41)).toBe(40) // Row 40 starts at 2000, 10px above the viewport top. h.v.resizeItem(40, 100) - expect(h.v['_iosDeferredAdjustment']).toBe(50) + expect(h.v['_holdOffset']).toBe(50) }) h.touch('touchend') h.run(() => h.scroll(false)) @@ -2787,7 +2806,7 @@ test('iOS Phase 1: resize during active touch defers (no scrollTop write)', () = // Above-viewport item resizes mid-drag. Must defer. v.resizeItem(0, 100) expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_iosDeferredAdjustment']).toBe(50) + expect(v['_holdOffset']).toBe(50) }) }) @@ -2815,14 +2834,14 @@ test('iOS Phase 1: resize in post-touchend grace window defers; flushes when tim v.resizeItem(0, 100) v.resizeItem(1, 65) expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_iosDeferredAdjustment']).toBe(50 + 15) + 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['_iosTouch']).toBeNull() expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(v['_iosDeferredAdjustment']).toBe(0) + expect(v['_holdOffset']).toBe(0) }) }) @@ -2848,7 +2867,7 @@ test('iOS Phase 1: momentum scroll events re-arm the post-touchend tail until th v.resizeItem(0, 100) expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_iosDeferredAdjustment']).toBe(50) + expect(v['_holdOffset']).toBe(50) scroll(140, true) // still flinging: re-armed again, still deferred expect(v['_iosTouch']?.phase).toBe('tail') @@ -2861,7 +2880,7 @@ test('iOS Phase 1: momentum scroll events re-arm the post-touchend tail until th (v['_iosTouch'] as { timerId?: number } | null)?.timerId ?? null, ).toBeNull() expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(v['_iosDeferredAdjustment']).toBe(0) + expect(v['_holdOffset']).toBe(0) }) }) @@ -2950,14 +2969,14 @@ test('iOS Phase 1: scroll-element swap does not replay a stale deferred adjustme dispatchTouchEvent(holder.el, 'touchstart') v.resizeItem(0, 100) expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_iosDeferredAdjustment']).toBe(50) + 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['_iosDeferredAdjustment']).toBe(0) + expect(v['_holdOffset']).toBe(0) // The new element's first quiescence must not write the stale delta. scrollToFn.mockClear() @@ -3101,7 +3120,7 @@ test('Phase 2b: flush skipped when scrollTop is in elastic-overscroll zone (nega // Resize during the drag: defers v.resizeItem(0, 100) - expect(v['_iosDeferredAdjustment']).toBe(50) + 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 @@ -3111,13 +3130,13 @@ test('Phase 2b: flush skipped when scrollTop is in elastic-overscroll zone (nega touch('touchend') expireTouchTail() expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_iosDeferredAdjustment']).toBe(50) // still deferred + 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['_iosDeferredAdjustment']).toBe(0) + expect(v['_holdOffset']).toBe(0) }) }) @@ -3128,7 +3147,7 @@ test('Phase 2b: flush skipped when scrollTop > scrollHeight-clientHeight (oversc touch('touchstart') v.resizeItem(0, 100) - expect(v['_iosDeferredAdjustment']).toBe(50) + 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. @@ -3182,7 +3201,7 @@ test('Phase 2b: in-bounds flush proceeds normally (no regression)', () => { el.scrollTop = 150 scrollCb!(150, false) // in-bounds (0..300) expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(v['_iosDeferredAdjustment']).toBe(0) + expect(v['_holdOffset']).toBe(0) }) }) @@ -3266,7 +3285,7 @@ test('non-iOS: adjustment is applied immediately during scroll (no regression)', // Should have fired immediately expect(scrollToFn).toHaveBeenCalled() - expect(v['_iosDeferredAdjustment']).toBe(0) + expect(v['_holdOffset']).toBe(0) }) test('scroll-up jank: backward-scroll skips adjustment on re-measurement by default', () => { @@ -5876,7 +5895,7 @@ test('#1250: a programmatic scrollToIndex landing compensates synchronously with // 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['_iosDeferredAdjustment']).toBe(0) + expect(v['_holdOffset']).toBe(0) expect(scrollToFn).toHaveBeenCalledTimes(1) }) }) @@ -5898,7 +5917,7 @@ test('#1250: a tap that never scrolls does not latch the deferral gate', () => { // 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['_iosDeferredAdjustment']).toBe(0) + expect(v['_holdOffset']).toBe(0) expect(scrollToFn).toHaveBeenCalledTimes(1) }) }) @@ -5924,7 +5943,7 @@ test('#1250: a scrollToIndex issued from a tap handler lands compensated', () => scrollToFn.mockClear() v.resizeItem(0, 100) - expect(v['_iosDeferredAdjustment']).toBe(0) + expect(v['_holdOffset']).toBe(0) expect(scrollToFn).toHaveBeenCalledTimes(1) }) }) @@ -5941,11 +5960,11 @@ test('#1250: an absolute command under a dragging finger keeps deferring', () => v.resizeItem(0, 100) expect(scrollToFn).not.toHaveBeenCalled() - expect(v['_iosDeferredAdjustment']).toBe(50) + expect(v['_holdOffset']).toBe(50) }) }) -test('#1250: a scrollToIndex with no target leaves the fling running and drops a stale deferral', () => { +test('#1250: a scrollToIndex with no target leaves the fling running and the held correction', () => { withFakeIOSUserAgent(() => { const { v, scrollToFn, scroll, touch, expireTouchTail } = makeIOSTouchVirtualizer() @@ -5953,33 +5972,34 @@ test('#1250: a scrollToIndex with no target leaves the fling running and drops a touch('touchend') scroll(120, true) // momentum v.resizeItem(0, 100) - expect(v['_iosDeferredAdjustment']).toBe(50) + expect(v['_holdOffset']).toBe(50) const timer = (v['_iosTouch'] 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 deferral is dropped like on every absolute - // command, since it may describe rows that are gone (as in 3.19.2). + // 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['_iosDeferredAdjustment']).toBe(0) + expect(v['_holdOffset']).toBe(50) expect(v['_iosTouch']?.phase).toBe('tail') expect( (v['_iosTouch'] as { timerId?: number } | null)?.timerId ?? null, ).toBe(timer) - // A resize during the remaining momentum still defers rather than - // writing scrollTop into the fling. + // 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['_iosDeferredAdjustment']).toBe(30) + expect(v['_holdOffset']).toBe(80) expireTouchTail() expect(scrollToFn).toHaveBeenCalledTimes(1) - expect(v['_iosDeferredAdjustment']).toBe(0) + expect(scrollToFn.mock.calls[0]![0]).toBe(200) + expect(v['_holdOffset']).toBe(0) }) }) @@ -6023,7 +6043,7 @@ test.each([ scrollToFn.mockClear() v.resizeItem(0, 100) - expect(v['_iosDeferredAdjustment']).toBe(0) + expect(v['_holdOffset']).toBe(0) expect(scrollToFn).toHaveBeenCalledTimes(1) }) }, @@ -6153,3 +6173,62 @@ test('iOS touch: a row that stops touch propagation does not hide the gesture', 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/prepend-hold.test.ts b/packages/virtual-core/tests/prepend-hold.test.ts new file mode 100644 index 000000000..11bd3f8fb --- /dev/null +++ b/packages/virtual-core/tests/prepend-hold.test.ts @@ -0,0 +1,465 @@ +import { expect, test } from 'vitest' +import { Virtualizer, _resetIOSDetectionForTests } from '../src/index' +import type { VirtualItem } from '../src/index' + +// A prepend that lands while the element cannot take a scrollTop write +// (#1287, #1307). Both traces come from the react `chat` example +// (`anchorTo: 'end'`, 12 rows prepended 180ms after scrollTop < 120): +// +// - Desktop Safari 18.4: the load lands during the top rubber-band bounce. +// WebKit discards a scrollTop write made while the element is overscrolled +// and the bounce stalls where it was: the new rows are on screen, the top +// of the viewport is blank, and no scroll event comes until the user +// scrolls again. +// - iOS Safari 27: the load lands during the momentum tail of a touch. The +// anchor delta is deferred, the new rows show through the bounce, and the +// flush at the end of the tail snaps back to the reader's row. +// +// Each scenario runs the same event sequence on a control virtualizer that +// gets no prepend. At every step the reader's row must be at the same +// viewport position as on the control and no viewport pixel may be blank; +// nothing may be written while a write is impossible; and once a write is +// possible the element lands exactly one prepend further. + +const VIEWPORT = 200 +const ROW = 50 +const PREPEND = 12 +const IOS_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)' + +function withUserAgent(ios: boolean, fn: () => T): T { + if (ios) { + Object.defineProperty(navigator, 'userAgent', { + value: IOS_UA, + configurable: true, + }) + } + _resetIOSDetectionForTests() + try { + return fn() + } finally { + delete (navigator as any).userAgent + _resetIOSDetectionForTests() + } +} + +function makeHarness(ios: boolean) { + const timers = new Map void; due: number }>() + let timerId = 0 + let clock = 0 + const win = { + requestAnimationFrame: () => 1, + cancelAnimationFrame: () => {}, + setTimeout: (fn: () => void, ms = 0) => { + timers.set(++timerId, { fn, due: clock + ms }) + return timerId + }, + clearTimeout: (id: number) => timers.delete(id), + performance: { now: () => clock }, + } + const listeners: Record void>> = {} + let ids = Array.from({ length: 45 }, (_, i) => i) + let nextId = 0 + const writes: Array = [] + // The reader row's rendered start at the moment of each write. + const writeViews: Array<{ to: number; readerStart: number | null }> = [] + let touching = false + let dragged = false + // The bounce stalled after a discarded write: no more scroll events. + let stalled = false + const el = { + pos: 0, + size: 0, + get scrollTop() { + return this.pos + }, + get scrollHeight() { + return this.size + }, + clientHeight: VIEWPORT, + offsetHeight: VIEWPORT, + ownerDocument: { defaultView: win }, + addEventListener: (type: string, fn: () => void) => { + ;(listeners[type] ??= []).push(fn) + }, + removeEventListener: (type: string, fn: () => void) => { + listeners[type] = (listeners[type] ?? []).filter((f) => f !== fn) + }, + } + const max = () => Math.max(0, el.size - VIEWPORT) + let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = + null + let rendered: Array = [] + let rendering = false + let dirty = false + + const v = new Virtualizer({ + count: ids.length, + estimateSize: () => ROW, + getItemKey: (i) => ids[i]!, + getScrollElement: () => el as any, + anchorTo: 'end', + overscan: 1, + scrollToFn: (offset, { adjustments = 0 }) => { + const to = offset + adjustments + writes.push(to) + writeViews.push({ + to, + readerStart: + rendered.find((item) => ids[item.index] === 0)?.start ?? null, + }) + // iOS ignores a write while a finger drags the content. + if (ios && touching && dragged) return + // WebKit discards a write made while the element is overscrolled, and + // the bounce stalls where it was (#1287). + if (el.pos < 0 || el.pos > max()) { + stalled = true + return + } + el.pos = Math.max(0, Math.min(to, max())) + }, + observeElementRect: (_inst, cb) => { + cb({ width: VIEWPORT, height: VIEWPORT }) + return () => {} + }, + observeElementOffset: (_inst, cb) => { + scrollCallback = cb + cb(el.pos, false) + return () => {} + }, + onChange: (_inst, sync) => { + // A sync notify re-renders before paint; an async one before the next + // step. Either way the rows and the sizer catch up before anything is + // observed. + if (sync && !rendering) render() + else dirty = true + }, + }) + v._didMount() + + // What an adapter does on render: read the rows, run the update hook, and + // size the container. + const render = () => { + rendering = true + try { + rendered = v.getVirtualItems() + v._willUpdate() + el.size = v.getTotalSize() + rendered = v.getVirtualItems() + } finally { + rendering = false + dirty = false + } + } + const settle = () => { + if (dirty) render() + } + const fire = (type: string) => listeners[type]?.forEach((fn) => fn()) + const advance = (ms: number) => { + const until = clock + ms + for (;;) { + let next: [number, { fn: () => void; due: number }] | undefined + for (const entry of timers) { + if (entry[1].due <= until && (!next || entry[1].due < next[1].due)) { + next = entry + } + } + if (!next) break + timers.delete(next[0]) + clock = Math.max(clock, next[1].due) + next[1].fn() + } + clock = until + settle() + } + + return { + v, + el, + writes, + render, + // The element reports a position (the user, a bounce frame, or a + // momentum frame). A stalled bounce reports nothing. + scroll: (pos: number, isScrolling = true) => { + if (stalled) return + el.pos = pos + if (touching) dragged = true + scrollCallback!(pos, isScrolling) + settle() + }, + // The read-back of the element's current position. + readBack: (isScrolling = false) => { + scrollCallback!(el.pos, isScrolling) + settle() + }, + touchstart: () => { + touching = true + dragged = false + fire('touchstart') + }, + touchend: () => { + touching = false + fire('touchend') + }, + advance, + prepend: () => { + ids = [...Array.from({ length: PREPEND }, () => --nextId), ...ids] + v.setOptions({ ...v.options, count: ids.length }) + render() + }, + // The options change alone, before the adapter commits. + prependOptions: () => { + ids = [...Array.from({ length: PREPEND }, () => --nextId), ...ids] + v.setOptions({ ...v.options, count: ids.length }) + }, + writeViews, + // The screen: the viewport position of the reader's row (the first row + // of the original list) and the viewport pixels no rendered row covers. + // The gap a bounce opens above the content is not blank. + view: () => { + const reader = rendered.find((item) => ids[item.index] === 0) + const total = v.getTotalSize() + let blank = 0 + for ( + let px = Math.max(el.pos, 0); + px < Math.min(el.pos + VIEWPORT, total); + px++ + ) { + if (!rendered.some((item) => item.start <= px && px < item.end)) { + blank++ + } + } + return { + readerTop: reader ? reader.start - el.pos : null, + blank, + rows: `${rendered[0]?.index}-${rendered[rendered.length - 1]?.index}`, + } + }, + } +} + +type Harness = ReturnType + +// Drive the prepend run and the control run through the same steps; after +// each one the screen must match. +function run(ios: boolean, script: (h: Harness, prepend: () => void) => void) { + return withUserAgent(ios, () => { + const h = makeHarness(ios) + const control = makeHarness(ios) + h.render() + control.render() + // The mount-time write of the initial offset is not under test. + h.writes.length = 0 + control.writes.length = 0 + h.writeViews.length = 0 + control.writeViews.length = 0 + const steps: Array = [] + const both = (label: string, fn: (x: Harness) => void) => { + fn(h) + fn(control) + const a = h.view() + const b = control.view() + steps.push( + `${label}: pos=${h.el.pos} rows=${a.rows} ${JSON.stringify(a)}`, + ) + expect( + a.readerTop, + `${label}: reader row moved\n${steps.join('\n')}`, + ).toBe(b.readerTop) + expect(a.blank, `${label}: blank viewport\n${steps.join('\n')}`).toBe(0) + // The comparison above is vacuous when neither run renders the + // reader's row; at the prepend it must be on screen. + if (label === 'prepend') { + expect(a.readerTop, `${label}: reader row not rendered`).not.toBeNull() + } + } + const proxy: Harness = { + ...h, + scroll: (pos, isScrolling) => + both(`scroll ${pos}`, (x) => x.scroll(pos, isScrolling)), + readBack: (isScrolling) => + both('read-back', (x) => x.readBack(isScrolling)), + touchstart: () => both('touchstart', (x) => x.touchstart()), + touchend: () => both('touchend', (x) => x.touchend()), + advance: (ms) => both(`wait ${ms}`, (x) => x.advance(ms)), + } + script(proxy, () => both('prepend', (x) => x === h && x.prepend())) + return { h, control } + }) +} + +test('desktop: a prepend during the top rubber-band bounce holds the reader row and lands once the bounce ends (#1287)', () => { + const { h, control } = run(false, (h, prepend) => { + // A trackpad fling reaches the top and bounces past it. + h.scroll(0) + h.scroll(-38) + h.scroll(-128) + h.scroll(-94) + // History lands mid-bounce. No write is possible here. + prepend() + expect(h.writes).toEqual([]) + // The bounce springs back. A discarded write would have stalled it. + h.scroll(-80) + h.scroll(-40) + h.scroll(0) + h.readBack() + }) + // The element is in bounds: the prepend has been written, exactly once. + expect(h.writes).toEqual([PREPEND * ROW]) + expect(h.el.pos).toBe(PREPEND * ROW) + expect(control.el.pos).toBe(0) + // The rows are back in layout coordinates and the sizer holds them all. + expect( + h.v.getVirtualItems().find((item) => item.index === PREPEND)?.start, + ).toBe(PREPEND * ROW) + expect(h.v.getTotalSize()).toBe(control.v.getTotalSize() + PREPEND * ROW) + expect(h.v.scrollOffset).toBe(PREPEND * ROW) +}) + +test('desktop: a prepend at rest is written at once (no hold)', () => { + // At rest at the top, with the reader's row on screen, so the per-step + // comparison checks the row through the immediate write as well. + const { h } = run(false, (h, prepend) => { + h.scroll(0, false) + prepend() + expect(h.writes).toEqual([PREPEND * ROW]) + h.readBack() + }) + expect(h.el.pos).toBe(PREPEND * ROW) +}) + +test('iOS: a prepend during a momentum bounce holds the reader row and lands when the tail closes (#1307)', () => { + const { h, control } = run(true, (h, prepend) => { + h.scroll(112, false) + // Finger down, drag up to the top, release into momentum. + h.touchstart() + h.scroll(60) + h.touchend() + h.scroll(10) + h.scroll(-17) + h.scroll(-113) + // History lands mid-fling: the tail is open and the element overscrolled. + prepend() + expect(h.writes).toEqual([]) + h.scroll(-91) + h.scroll(-40) + h.scroll(0) + expect(h.writes).toEqual([]) + // The tail closes 150ms after the last momentum frame. + h.advance(160) + h.readBack() + }) + expect(h.writes).toEqual([PREPEND * ROW]) + expect(h.el.pos).toBe(PREPEND * ROW) + expect(control.el.pos).toBe(0) + expect( + h.v.getVirtualItems().find((item) => item.index === PREPEND)?.start, + ).toBe(PREPEND * ROW) + expect(h.v.scrollOffset).toBe(PREPEND * ROW) +}) + +test('iOS: two prepends in one tail hold together and land as one write', () => { + const { h } = run(true, (h, prepend) => { + h.scroll(112, false) + h.touchstart() + h.scroll(60) + h.touchend() + h.scroll(20) + prepend() + h.scroll(5) + prepend() + expect(h.writes).toEqual([]) + h.scroll(0) + h.advance(160) + h.readBack() + }) + expect(h.writes).toEqual([2 * PREPEND * ROW]) + expect(h.el.pos).toBe(2 * PREPEND * ROW) +}) + +test('desktop: the flush re-renders the rows in layout coordinates before it writes', () => { + const { h } = run(false, (h, prepend) => { + h.scroll(0) + h.scroll(-94) + prepend() + h.scroll(-40) + h.scroll(0) + h.readBack() + }) + // The write that releases the hold must find the rows already re-rendered + // in layout coordinates (a synchronous notify), or one paint shows the + // element moved under rows that have not. + expect(h.writeViews).toEqual([ + { to: PREPEND * ROW, readerStart: PREPEND * ROW }, + ]) +}) + +test('desktop: a user scroll back into bounds during a hold releases it where the user is', () => { + const { h, control } = run(false, (h, prepend) => { + h.scroll(0) + h.scroll(-94) + prepend() + expect(h.writes).toEqual([]) + // The user drags down through the top, past the bounce, into the list. + h.scroll(-20) + h.scroll(30) + h.readBack() + }) + // Released by the first in-bounds event, at the user's position. + expect(h.writes).toEqual([30 + PREPEND * ROW]) + expect(h.el.pos).toBe(30 + PREPEND * ROW) + expect(control.el.pos).toBe(30) +}) + +// Known gap in the `_willUpdate` fallback: a scroll event between +// `setOptions` and `_willUpdate` overwrites the eagerly bumped `scrollOffset` +// with the element's offset before the hold is applied, and the fallback +// then adds the hold without restoring the layout offset. For one event the +// range is computed from an element offset while the rows are shifted by the +// hold: rows 0-3 render at -600 and the viewport is blank (106px). The next +// scroll event self-heals. Without the fallback the old stall would return, +// so it stays; this pins the glitch. +test.fails( + 'desktop: an overscroll event between the options change and the commit still holds', + () => { + const { h, control } = run(false, (h, prepend) => { + h.scroll(0, false) + // The options change finds the element writable (at rest, in bounds)... + h.prependOptions() + // ...but a bounce event lands before the adapter commits (`_willUpdate` + // runs inside the render this event triggers). + h.scroll(-94) + expect(h.writes).toEqual([]) + h.scroll(-40) + h.scroll(0) + h.readBack() + void prepend + }) + expect(h.writes).toEqual([PREPEND * ROW]) + expect(h.el.pos).toBe(PREPEND * ROW) + expect(control.el.pos).toBe(0) + expect(h.v.scrollOffset).toBe(PREPEND * ROW) + }, +) + +test('desktop: an above-viewport grow during a bottom bounce holds and lands once in bounds', () => { + const max = (45 - 4) * ROW // 2050 + const { h, control } = run(false, (h) => { + h.scroll(max, false) + h.scroll(max + 30) + h.scroll(max + 70) + // Row 0 (above the viewport, at the end) grows by 30: held. + h.v.resizeItem(0, ROW + 30) + h.render() + expect(h.writes).toEqual([]) + h.scroll(max + 35) + h.scroll(max) + h.readBack() + }) + expect(h.writes).toEqual([max + 30]) + expect(h.el.pos).toBe(max + 30) + expect(control.el.pos).toBe(max) + // The last row sits where it did on the control. + const last = (x: Harness) => + x.v.getVirtualItems().find((i) => i.index === 44)!.start - x.el.pos + expect(last(h)).toBe(last(control)) +}) diff --git a/packages/virtual-core/tests/scroll-trace.test.ts b/packages/virtual-core/tests/scroll-trace.test.ts index e45fc4502..3d36b00e3 100644 --- a/packages/virtual-core/tests/scroll-trace.test.ts +++ b/packages/virtual-core/tests/scroll-trace.test.ts @@ -8,8 +8,8 @@ type ScrollAlignment = NonNullable // // Seeded random event sequences (scroll commands, read-backs, smooth // animation steps, content resizes delivered by ResizeObserver, prepends, -// user drags, touches, renders, frames, waits that fire due timers, the -// reconcile safety valve) +// user drags that rubber-band past an edge, touches, renders, frames, waits +// that fire due timers, the reconcile safety valve) // run against a simulated browser, on iOS and non-iOS, across a set of layout // options. Each sequence records only what a consumer can observe: every // write to the element, where it rests after each step, and what the adapter @@ -271,6 +271,17 @@ function simulate( // The browser keeps whole pixels, so a read-back can differ from a // fractional write. const clamp = (x: number) => Math.round(Math.max(0, Math.min(x, max()))) + // A drag past an edge rubber-bands by a share of the excess; each + // following frame springs half way back, and a rest report lands on the + // edge. WebKit discards a write made while the element is overscrolled and + // the bounce stalls: no scroll event comes until the user scrolls again + // (#1287). + const overscrolled = () => el.pos < 0 || el.pos > max() + let stalled = false + const spring = () => { + if (el.pos < 0) el.pos = Math.ceil(el.pos / 2) + else if (el.pos > max()) el.pos = max() + Math.floor((el.pos - max()) / 2) + } let ids = Array.from({ length: 100 }, (_, i) => i) let nextId = 100 const content = new Map() @@ -303,6 +314,12 @@ function simulate( // iOS ignores a programmatic write while a finger drags; under a // finger resting on the content the write lands. if (ios && touching && dragged) return + if (overscrolled()) { + stalled = true + return + } + // A write that lands is reported like any other move. + stalled = false if (behavior === 'smooth') { smoothTarget = clamp(to) lateFrame = null @@ -365,10 +382,17 @@ function simulate( if (unmounted) v.measureElement(null) v._willUpdate() el.size = v.getTotalSize() + (v.options.scrollMargin ?? 0) - el.pos = clamp(el.pos) - return `r${rendered[0] ?? '-'}-${rendered[rendered.length - 1] ?? '-'}/${el.size}` + // A bounce past the top is left alone: the content cannot clamp it. + el.pos = Math.min(el.pos, max()) + // The first row's exposed start as well as its index: a correction held + // while a write is impossible shifts what the rows render at, not which + // rows render, and this is where that shows. + return `r${rendered[0] ?? '-'}@${items[0]?.start ?? '-'}-${rendered[rendered.length - 1] ?? '-'}/${el.size}` + } + // A stalled bounce reports nothing. + const scroll = (isScrolling: boolean) => { + if (!stalled) scrollCallback!(el.pos, isScrolling) } - const scroll = (isScrolling: boolean) => scrollCallback!(el.pos, isScrolling) const frame = () => rafs.splice(0).forEach((cb) => cb(clock)) // Move the clock forward, firing each timeout at its due time in order. const advance = (ms: number) => { @@ -449,12 +473,15 @@ function simulate( extra = render() break case 'event': + spring() scroll(true) break case 'idle': + el.pos = clamp(el.pos) scroll(false) break case 'anim': + if (stalled) break if (lateFrame !== null) { el.pos = clamp(lateFrame) lateFrame = null @@ -469,14 +496,23 @@ function simulate( scroll(true) } break - case 'drag': + case 'drag': { // A user scroll stops a native smooth animation. smoothTarget = null lateFrame = null if (touching) dragged = true - el.pos = clamp(el.pos + step.d) + // The user scrolls: a stalled bounce reports again. + stalled = false + const to = el.pos + step.d + el.pos = + to < 0 + ? -Math.min(Math.round(-to / 3), 100) + : to > max() + ? max() + Math.min(Math.round((to - max()) / 3), 100) + : to scroll(true) break + } case 'touchstart': touching = true dragged = false @@ -522,8 +558,10 @@ function simulate( for (let i = 0; i < 20 && rafs.length > 0; i++) { frame() render() + spring() scroll(true) } + el.pos = clamp(el.pos) scroll(false) advance(1000) for (let i = 0; i < 20 && rafs.length > 0; i++) { From 9c762fe49021b602b1109e471ba38e3065cba5b5 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Thu, 15 Oct 2026 06:41:49 +0200 Subject: [PATCH 17/46] fix(virtual-core): finish the iOS scroll-correction plan and simplify the scroll state (#1324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes #1307 on top of #1319's hold: the step-5 command stalls, iOS 27 commands issued mid-fling (wait for the fling; a drag, second flick or tap cancels, read from scroll events since iOS sends no touch events for a touch on a fling), and the behaviour-neutral scroll-state refactor (P1–P4). Device checks on iOS 27/26.5, desktop Safari, Chromium and Firefox; e2e for the command-retire cases. Co-Authored-By: Claude Opus 5.5 --- .changeset/ios-27-fling-and-command-stalls.md | 11 + .changeset/scroll-state-refactor.md | 9 + docs/api/virtualizer.md | 4 +- .../e2e/app/command-retire/index.html | 10 + .../e2e/app/command-retire/main.tsx | 79 ++ .../e2e/app/ios-deferral/main.tsx | 2 - .../e2e/app/test/command-retire.spec.ts | 54 ++ packages/react-virtual/e2e/app/vite.config.ts | 1 + packages/virtual-core/src/index.ts | 529 +++++++---- .../virtual-core/src/scroll-classifier.ts | 213 +++++ .../__snapshots__/scroll-trace.test.ts.snap | 143 ++- .../tests/fixtures/ios27-flings.json | 432 +++++++++ packages/virtual-core/tests/index.test.ts | 869 ++++++++++++++++-- .../virtual-core/tests/prepend-hold.test.ts | 72 +- .../tests/scroll-classifier.test.ts | 398 ++++++++ .../virtual-core/tests/scroll-trace.test.ts | 77 +- 16 files changed, 2615 insertions(+), 288 deletions(-) create mode 100644 .changeset/ios-27-fling-and-command-stalls.md create mode 100644 .changeset/scroll-state-refactor.md create mode 100644 packages/react-virtual/e2e/app/command-retire/index.html create mode 100644 packages/react-virtual/e2e/app/command-retire/main.tsx create mode 100644 packages/react-virtual/e2e/app/test/command-retire.spec.ts create mode 100644 packages/virtual-core/src/scroll-classifier.ts create mode 100644 packages/virtual-core/tests/fixtures/ios27-flings.json create mode 100644 packages/virtual-core/tests/scroll-classifier.test.ts diff --git a/.changeset/ios-27-fling-and-command-stalls.md b/.changeset/ios-27-fling-and-command-stalls.md new file mode 100644 index 000000000..bca081484 --- /dev/null +++ b/.changeset/ios-27-fling-and-command-stalls.md @@ -0,0 +1,11 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): scroll commands and corrections that stalled or were lost (#1307) + +- On iOS 27 and later a `scrollTop` write no longer stops a fling but is overridden by it within a frame. A `scrollToIndex`, `scrollToOffset` or `scrollToEnd` issued during a fling now waits for the fling to end instead of being lost. The user taking over before then cancels it: a drag, a second flick, or a finger stopping the fling (iOS reports none of these with touch events while the fling runs). Corrections keep being held through the gesture as on every version. +- `scrollToOffset` / `scrollBy`: a row above the viewport growing after the write's read-back moved the element off the fixed target, and the command kept reconciling until the 5s safety valve. The target now moves with the correction. +- A prepend (`anchorTo: 'end'`) before the read-back of a scroll command was anchored against the stale offset and written over the command. It is now left to reconcile, like a resize in that window. +- A scroll event between the prepend's options change and the adapter's commit no longer loses the anchor (one blank frame when the element could not be written by commit time). +- A command's write clamped by a sizer that shrank below it before the read-back no longer reads as a user scroll; the command reaches its fresh target. diff --git a/.changeset/scroll-state-refactor.md b/.changeset/scroll-state-refactor.md new file mode 100644 index 000000000..ef7f885fc --- /dev/null +++ b/.changeset/scroll-state-refactor.md @@ -0,0 +1,9 @@ +--- +'@tanstack/virtual-core': patch +--- + +refactor(virtual-core): simplify the scroll-correction state (no behaviour change) + +- A scroll event is classified once (spurious re-emit, our own write's read-back, a clamped read-back or echo, or external) before the handler acts on it, a scroll command waiting for the iOS 27 momentum tail is a phase of its own, and the iOS touch gesture tracks a single `moved` flag. +- `scrollAdjustments` is deprecated: a correction is folded into `scrollOffset` at once, so it is always 0. Read `scrollOffset` alone; the getter stays. +- One edge case changes: corrections made while `enabled: false` used to accumulate, and a correction made after re-enabling but before the next update was added on top of them. They are no longer carried over. diff --git a/docs/api/virtualizer.md b/docs/api/virtualizer.md index 2c5349185..4eafa0c43 100644 --- a/docs/api/virtualizer.md +++ b/docs/api/virtualizer.md @@ -592,7 +592,7 @@ virtualizer.shouldAdjustScrollPositionOnItemSizeChange = ( delta, instance, ) => { - return item.start < instance.getScrollOffset() + instance.scrollAdjustments + return item.start < (instance.scrollOffset ?? 0) } ``` @@ -600,6 +600,8 @@ The callback receives the resized `item`, the size `delta`, and the `instance`; On iOS WebKit, scroll-position writes are deferred regardless of this callback while a finger is on screen, during momentum-scroll, and during elastic-overscroll bounce. The cumulative delta is flushed in a single write once the scroll settles, preserving iOS's native momentum physics. +On iOS 27 and later a write no longer stops a fling but is overridden by it within a frame, so a `scrollToIndex`, `scrollToOffset` or `scrollToEnd` issued during a fling is written once the fling ends (about 150ms after its last frame) rather than being lost; a drag before then cancels it, a tap does not. + ### `isScrolling` ```tsx diff --git a/packages/react-virtual/e2e/app/command-retire/index.html b/packages/react-virtual/e2e/app/command-retire/index.html new file mode 100644 index 000000000..56f418f61 --- /dev/null +++ b/packages/react-virtual/e2e/app/command-retire/index.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/packages/react-virtual/e2e/app/command-retire/main.tsx b/packages/react-virtual/e2e/app/command-retire/main.tsx new file mode 100644 index 000000000..e037bf05f --- /dev/null +++ b/packages/react-virtual/e2e/app/command-retire/main.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { useVirtualizer } from '@tanstack/react-virtual' + +// Scroll commands that must finish without the user's help (#1307 step 5, +// #1310): +// - `index-shrink`: scrollToIndex, then rows above the target shrink in the +// same click handler. The re-render shrinks the sizer before the scroll +// event, so the browser clamps the write; the command must still land on +// the fresh target. +// - scrollToOffset, then a row above grows as its read-back arrives (driven +// from the spec through `window.__v`). The compensation moves the element +// past the target; the command must retire rather than reconcile until the +// 5s safety valve. + +const COUNT = 100 +const EST = 50 + +const App = () => { + const parentRef = React.useRef(null) + const [sizes, setSizes] = React.useState(() => + Array.from({ length: COUNT }, () => EST), + ) + + const virtualizer = useVirtualizer({ + count: COUNT, + getScrollElement: () => parentRef.current, + estimateSize: () => EST, + overscan: 2, + }) + ;(window as any).__v = virtualizer + + return ( +
+ +
+
+ {virtualizer.getVirtualItems().map((item) => ( +
+
Row {item.index}
+
+ ))} +
+
+
+ ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/packages/react-virtual/e2e/app/ios-deferral/main.tsx b/packages/react-virtual/e2e/app/ios-deferral/main.tsx index d5cf7f0fb..75068541d 100644 --- a/packages/react-virtual/e2e/app/ios-deferral/main.tsx +++ b/packages/react-virtual/e2e/app/ios-deferral/main.tsx @@ -51,8 +51,6 @@ const state = () => { entry.off = v.scrollOffset entry.scrolling = v.isScrolling entry.dir = v.scrollDirection - entry.def = v._iosDeferredAdjustment - entry.adj = v.scrollAdjustments const s = v.scrollState entry.st = s ? [s.index, s.lastTargetOffset, s.phase, s.stableFrames] : null if (t.watch.length > 0 && el) { diff --git a/packages/react-virtual/e2e/app/test/command-retire.spec.ts b/packages/react-virtual/e2e/app/test/command-retire.spec.ts new file mode 100644 index 000000000..262175100 --- /dev/null +++ b/packages/react-virtual/e2e/app/test/command-retire.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from '@playwright/test' +import type { Page } from '@playwright/test' + +const state = (page: Page) => + page.evaluate(() => { + const v = (window as any).__v + const el = document.querySelector( + '[data-testid="scroll-container"]', + ) as HTMLElement + return { + scrollTop: el.scrollTop, + command: v.scrollState !== null, + row90: v.getMeasurements()[90].start, + } + }) + +test.beforeEach(async ({ page }) => { + await page.goto('/command-retire/') + await page.waitForFunction(() => (window as any).__v != null) +}) + +test('scrollToIndex lands on the fresh target when rows above shrink in the same handler', async ({ + page, +}) => { + await page.click('[data-testid="index-shrink"]') + // Rows 0–19 lost 30px each: row 90 moved from 4500 to 3900, and the max + // from 4600 to 4000, under the 4500 written first. + // Retired on its own; the safety valve would take 5s. + await expect + .poll(async () => (await state(page)).command, { timeout: 1000 }) + .toBe(false) + const { scrollTop, row90 } = await state(page) + expect(row90).toBe(3900) + expect(scrollTop).toBe(3900) +}) + +test('scrollToOffset retires after a row above grows, without waiting for the safety valve', async ({ + page, +}) => { + await page.evaluate(() => { + const v = (window as any).__v + const el = document.querySelector('[data-testid="scroll-container"]')! + // Row 10 is measured larger as the write's read-back arrives, before + // reconcile has seen the target reached. + el.addEventListener('scroll', () => v.resizeItem(10, 100), { once: true }) + v.scrollToOffset(2000) + }) + // The compensation keeps the content in place. + await expect.poll(async () => (await state(page)).scrollTop).toBe(2050) + // Retired within a few frames; the safety valve would take 5s. + await expect + .poll(async () => (await state(page)).command, { timeout: 1000 }) + .toBe(false) +}) diff --git a/packages/react-virtual/e2e/app/vite.config.ts b/packages/react-virtual/e2e/app/vite.config.ts index 9fab88e93..7b8cd7ddf 100644 --- a/packages/react-virtual/e2e/app/vite.config.ts +++ b/packages/react-virtual/e2e/app/vite.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ ), 'lanes-change': path.resolve(__dirname, 'lanes-change/index.html'), 'ios-deferral': path.resolve(__dirname, 'ios-deferral/index.html'), + 'command-retire': path.resolve(__dirname, 'command-retire/index.html'), 'cached-measurements': path.resolve( __dirname, 'cached-measurements/index.html', diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index a5da612e6..50b2e9003 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -3,6 +3,13 @@ import { getMeasurementKey, } from './lazy-measurements' import { approxEqual, debounce, memo, notUndefined } from './utils' +import { + classifyScrollEvent, + externalRetiresCommand, + flingCaught, + flingSpedUp, +} from './scroll-classifier' +import type { ScrollPhase, ScrollSample } from './scroll-classifier' // Browser-aware iOS detection. Programmatic `scrollTo`/`scrollTop` writes // during a momentum-scroll cancel the momentum on iOS WebKit, so we defer @@ -20,9 +27,28 @@ const isIOSWebKit = (): boolean => { navigator.platform === 'MacIntel' && mtp !== undefined && mtp > 0) } -// Test hook: reset the iOS detection cache. Not exported. +// iOS 27 changed what a scrollTop write does to a fling: it no longer stops +// the momentum, the fling carries on along its own path and the write shows +// for one frame (measured on the 27.0 simulator in #1307, device check still +// open). WebKit shipped `overflow-anchor` in the same release, so that is +// the feature check; iOS 26 and earlier report false. Cached like the iOS +// check; SSR-safe. +let _iosMomentumOverridesWritesResult: boolean | undefined +const iosMomentumOverridesWrites = (): boolean => { + if (_iosMomentumOverridesWritesResult !== undefined) { + return _iosMomentumOverridesWritesResult + } + return (_iosMomentumOverridesWritesResult = + isIOSWebKit() && + typeof CSS !== 'undefined' && + typeof CSS.supports === 'function' && + CSS.supports('overflow-anchor: auto')) +} + +// Test hook: reset the iOS detection caches. Not exported. export const _resetIOSDetectionForTests = () => { _isIOSResult = undefined + _iosMomentumOverridesWritesResult = undefined } export { approxEqual, debounce, memo, notUndefined } from './utils' @@ -417,15 +443,6 @@ export interface VirtualizerOptions< useCachedMeasurements?: boolean } -// Where an in-flight scroll command is in its lifecycle, which decides how -// its scroll events and measurements are read: -// smooth a smooth animation is travelling to the target -// smoothLanded a smooth scroll is at its target, waiting to retire -// smoothFinishing a smooth scroll switched to instant writes for the -// final approach; its animation can still report positions -// instant instant writes -type ScrollPhase = 'smooth' | 'smoothLanded' | 'smoothFinishing' | 'instant' - type ScrollState = { // what we want index: number | null @@ -435,6 +452,8 @@ type ScrollState = { // lifecycle startedAt: number + // how the command was asked to scroll; a queued command writes with it + behavior: ScrollBehavior // target tracking lastTargetOffset: number @@ -455,11 +474,6 @@ const initialScrollPhase = ( const isSmoothScroll = (state: ScrollState | null): state is ScrollState => state?.phase === 'smooth' || state?.phase === 'smoothLanded' -// The smooth animation may still be reporting positions, so a scroll event -// that isn't the read-back of our own write may not be the user. -const isAnimating = (state: ScrollState) => - state.phase === 'smooth' || state.phase === 'smoothFinishing' - // What `setOptions` decided about the scroll position for `_willUpdate` to // carry out once the consumer has rendered: keep the reading anchor in place // after a prepend (`scrollOffset` already moved by `delta`, the element has @@ -535,11 +549,13 @@ export class Virtualizer< */ scrollOffset: number | null = null scrollDirection: ScrollDirection | null = null - // Size compensation written to the element but not yet reflected in - // `scrollOffset`: the element is expected at `scrollOffset + - // scrollAdjustments` until the next scroll event reports it. + /** + * @deprecated Always 0: a scroll correction is folded into `scrollOffset` + * at once, so there is never an outstanding adjustment to add to it. Read + * `scrollOffset` alone. + */ get scrollAdjustments() { - return this.pendingWrite?.adjustments ?? 0 + return 0 } // Scroll corrections (a prepend's anchor delta, size changes above the // viewport) that could not be written to the element because a write was @@ -559,33 +575,54 @@ export class Virtualizer< // `scrollOffset` is no judge either, since an eager bump past the max while // the sizer has not grown yet (#1258) is not one. // - // Stored rather than read from the element when a write is decided: the + // Stored rather than read from the element when a write is decided. The // two differ once a write has landed but not been reported back, and in // that window the element reads in bounds while `scrollOffset` is still - // the pre-write value, so an anchor sync would write that stale offset - // over the command (the step 5 stall in #1307; 56 of 1080 snapshot seeds). - // Reading the element there is a behaviour change to take with that fix. + // the pre-write value. With the anchor sync now skipped in that window + // (`_awaitingCommandReadBack`), the remaining difference is the trim: a + // list that shrank under the position reads past the element's max until + // the browser clamps it, which is where the anchor's write has to go + // (`followOnAppend does not follow 'trim only'`; reading the element at + // write time still moves 49 of 1080 snapshot seeds). private _reportedOverscroll = false // The touch gesture in progress. iOS WebKit ignores a scrollTop write // while a finger drags the content (one resting on it does not stop the // write) and cancels momentum on one after it, so adjustments are deferred // through the whole gesture: `down` from touchstart to touchend/ - // touchcancel, with `dragged` set by the first scroll event that is not our - // own write's read-back, then `tail` while momentum runs. iOS fires no - // touch events during momentum, so every scroll event re-arms the tail and - // it closes ~150ms after the last one. Null between gestures, and after - // touchend on non-iOS, which arms no tail. The tail record owns its timer: + // touchcancel, then `tail` while momentum runs. iOS fires no touch events + // during momentum, so every scroll event re-arms the tail and it closes + // ~150ms after the last one. Null between gestures, and after touchend on + // non-iOS, which arms no tail. The tail record owns its timer: // `_endIosTouch` is the only way to drop either, so a cleared record never // leaves a timer behind, and a live timer always has its record. + // + // `moved`: the content moved under or after the finger other than by our + // own write's read-back. While `down`, a scroll event that changed the + // position sets it: the finger is dragging. The tail inherits it from the + // drag, and any other scroll event re-arming the tail sets it: a fling + // can be running. A tap's tail has it unset. private _iosTouch: - | { phase: 'down'; dragged: boolean } - | { phase: 'tail'; timerId: number } + | { phase: 'down'; moved: boolean } + | { phase: 'tail'; moved: boolean; timerId: number } | null = null + // The last position the scroll handler was given, in the element's own + // coordinates. The tail timer compares it with the element (see + // `_openIosTail`). + private _lastElementOffset: number | null = null + // The scroll events since a command started waiting for the tail, at + // most the last 200ms, for `flingSpedUp`. + private _flingSamples: Array = [] + // A drag ended and its fling has not reported yet: 'awaiting' from the + // touchend until a scroll event re-arms the tail; once the tail closes + // first, the time until which the fling's first frame may still come and + // reopen it (see the scroll handler). + private _lateFling: 'awaiting' | number | null = null // Our last write to the element, from `_scrollToOffset` until the next // scroll event reports it (the only place it is cleared). Until then // `scrollOffset` may still hold the old position. // - // - `target`: where the write puts the element (offset + adjustments). + // - `target`: where the write puts the element (offset + the adjustment + // passed to `scrollToFn`). // Safari (and Chrome/Firefox under certain DPRs) round writes to whole // pixels: wrote 12345.5, read back 12346. A read-back within 1.5px of // `target` is taken as our own and adopts the exact value, so reconcile @@ -593,12 +630,9 @@ export class Virtualizer< // further by the time it reports. // - `smooth`: the write animates, so until it lands the element is // somewhere between the old offset and `target`. - // - `adjustments`: see `scrollAdjustments`. Writes that pass none keep the - // previous value. private pendingWrite: { target: number smooth: boolean - adjustments: number } | null = null // A compensation write from `applyScrollAdjustment` whose target exceeded // the element's scroll max at the moment of the write. The browser clamps @@ -836,8 +870,18 @@ export class Virtualizer< // the virtualizer would render the wrong items for one frame (the // estimate-based positions are stale) and then correct in the next // frame, producing a visible "jump" on prepend with dynamic sizes. + // Not before a scroll command's write has been read back: `scrollOffset` + // is still the pre-command position, so the anchor would be resolved + // against rows that are no longer where the command put the element, and + // the sync would write that stale offset over the command. Reconcile + // retargets an index or end command from the new layout; an offset + // command lands where it was written (#1307, step 5). let anchorDelta: number | null = null - if (anchor && this.scrollOffset !== null) { + if ( + anchor && + this.scrollOffset !== null && + !this._awaitingCommandReadBack() + ) { const [anchorKey, anchorOffset] = anchor const newMeasurements = this.getMeasurements() const { count, getItemKey } = this.options @@ -873,6 +917,7 @@ export class Virtualizer< // the rows and the total shifted back by it, so the reader's row // stays put, and the flush writes it once a write is possible. this._holdOffset += anchorDelta + this._followOffsetTarget(anchorDelta) } } } @@ -895,6 +940,10 @@ export class Virtualizer< console.info('correction', delta) } + // The correction moves the content the viewport is on; an offset target + // moves with it, or reconcile would wait for a position it can never see. + this._followOffsetTarget(delta) + if (!this._canWriteScroll()) { // Hold the correction: `scrollOffset` takes it as if written, the rows // are exposed shifted back by it. The notify must be synchronous like @@ -904,10 +953,10 @@ export class Virtualizer< if (this.scrollOffset !== null) this.scrollOffset += delta return true } else { - const target = this.getScrollOffset() + this.scrollAdjustments + delta - this._clampedAdjustment = this._clampedBy(target) - this._scrollToOffset(this.getScrollOffset(), { - adjustments: this.scrollAdjustments + delta, + const offset = this.getScrollOffset() + this._clampedAdjustment = this._clampedBy(offset + delta) + const write = this._scrollToOffset(offset, { + adjustments: delta, behavior, }) // Eagerly carry the intended target in `scrollOffset` so callers that @@ -916,11 +965,11 @@ export class Virtualizer< // post-adjustment position even when the DOM `scrollTop` write was // clamped because the consumer hasn't grown the sizer yet (`notify()` // runs after this in `resizeItem`). Same idea as the eager - // `scrollOffset` adjustment for prepend in `setOptions` (#1176). The - // adjustment is now baked into `scrollOffset`, so zero - // `scrollAdjustments` to keep their sum invariant. + // `scrollOffset` adjustment for prepend in `setOptions` (#1176). A + // `scrollToFn` that reports the write synchronously has already set + // `scrollOffset` to the read-back (and cleared `pendingWrite`). if (this.scrollOffset !== null) { - this.scrollOffset += this.scrollAdjustments + if (this.pendingWrite === write) this.scrollOffset += delta // Clamp only the lower bound: a negative offset is unreachable, and // on an unscrollable element (content fits the viewport) no scroll // event ever fires to correct it, permanently skewing @@ -928,7 +977,6 @@ export class Virtualizer< // Upper-bound overflow stays untouched — it is transiently // legitimate mid-prepend while the consumer's sizer catches up. if (this.scrollOffset < 0) this.scrollOffset = 0 - if (this.pendingWrite) this.pendingWrite.adjustments = 0 } return true } @@ -1037,58 +1085,33 @@ export class Virtualizer< // judged only while scrolling: at rest the element has clamped, // and a stale sizer makes no bounce. const elementMax = isScrolling ? this._elementMaxScrollOffset() : null + const wasOverscrolled = this._reportedOverscroll this._reportedOverscroll = offset < 0 || (elementMax !== null && offset > elementMax + 1) - // Everything below works in layout coordinates: the element is - // `_holdOffset` behind them while a correction is held. - offset += this._holdOffset - - // A scroll event that reports movement but lands on the offset we - // already hold — and isn't a self-write read-back — is a spurious - // no-op re-emit that Safari/Firefox fire after a re-render's layout - // (Chrome doesn't). Treating it as scrolling re-arms `isScrolling`, - // which forces a render that triggers another such event: an - // infinite re-render loop. Ignore it. (Self-writes are matched to - // `pendingWrite` just below.) - if ( - isScrolling && - this.pendingWrite === null && - offset === this.scrollOffset - ) { - return - } + const elementOffset = offset + this._lastElementOffset = elementOffset + // Everything below works in layout coordinates. + offset = this._toLayoutOffset(offset) + + const kind = this._classifyScrollEvent( + offset, + isScrolling, + elementMax, + ) + if (kind === 'spurious') return - // The read-back of our own write: adopt the exact target over the - // browser's rounded value (see `pendingWrite`). - const pending = this.pendingWrite - const isReadBack = - pending !== null && Math.abs(offset - pending.target) < 1.5 - if (isReadBack) { - offset = pending.target + if (kind === 'readBack') { + // Adopt the exact target over the browser's rounded value (see + // `pendingWrite`). + offset = this.pendingWrite!.target } else if ( - this.scrollState?.index != null && - // A non-smooth write lands at once, so any other scroll event is - // the user's, even before reconcile saw the target reached (the - // target may keep moving, or iOS ignored the write under a - // finger). A write the browser clamped because the sizer hasn't - // grown yet reads back at the max, which is also where the fresh - // target clamps, so the check below keeps it reconciling (#1294). - // Not when rows above the target shrink before the read-back and - // pull the target below the new max: the read-back then looks - // like the user and the scroll stops at the max. That needs the - // sizer re-rendered before the scroll event, which browsers have - // not been seen to do (pinned by a `.fails` test). - // While a smooth scroll's animation may still report, its events - // look like a user's, so it yields only once it has landed. - !isAnimating(this.scrollState) && - !approxEqual(offset, this.scrollState.lastTargetOffset) && - !approxEqual(offset, this.getScrollStateTarget(this.scrollState)) && - (this._clampedAdjustment === null || - Math.abs(offset - this._clampedAdjustment.maxAtWrite) >= 1.5) + externalRetiresCommand({ + kind, + offset, + command: this.scrollState, + target: () => this.getScrollStateTarget(this.scrollState!), + }) ) { - // An external scroll moved away from the index scroll's target. - // Our own measurement updates (including clamped writes) still - // need to reconcile. this.scrollState = null } this.pendingWrite = null @@ -1097,6 +1120,9 @@ export class Virtualizer< // own read-back, which the browser reports at the scroll max we // saw at write time. Anything else is a real gesture (or the write // landing after all), so drop it rather than yank the user later. + // Not `kind !== 'clampedEcho'`: the classifier ranks `readBack` and + // `clampedReadBack` first, and either can also sit at that max, so + // the record must survive them too. if ( this._clampedAdjustment !== null && Math.abs(offset - this._clampedAdjustment.maxAtWrite) >= 1.5 @@ -1109,6 +1135,7 @@ export class Virtualizer< // into `scrollOffset`. There's no direction to infer, so leave // it alone; a real gesture always moves the offset. const prevOffset = this.getScrollOffset() + const prevDirection = this.scrollDirection this.scrollDirection = isScrolling ? prevOffset === offset ? this.scrollDirection @@ -1124,18 +1151,68 @@ export class Virtualizer< // Only an open tail is extended: a scroll event with no touch before // it (our own write's read-back) never opens one. if (isScrolling && this._iosTouch?.phase === 'tail') { - this._openIosTail() + this._lateFling = null + this._openIosTail(this._iosTouch.moved || kind !== 'readBack') + } else if (typeof this._lateFling === 'number') { + // 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. It reopens it, within 1s of a drag only: + // other scroll events with no touch before them must not defer + // corrections (#1250). Our smooth animation's frames are not one. + if (this.now() > this._lateFling) { + this._lateFling = null + } else if ( + kind === 'external' && + isScrolling && + offset !== prevOffset && + this._iosTouch === null && + this.scrollState?.phase !== 'smooth' && + this.scrollState?.phase !== 'smoothFinishing' + ) { + this._lateFling = null + this._openIosTail(true) + } + } + // A finger that lands on a fling fires no touch events (#1307), so + // while a command waits for the fling, the scroll events are all + // that show the user taking over: the scroll turns back, or speeds + // up (`flingSpedUp`). Momentum does neither inside the scroll + // range; a bounce does both, so no part of one counts. + if ( + this.scrollState?.phase === 'queued' && + kind === 'external' && + isScrolling + ) { + if (wasOverscrolled || this._reportedOverscroll) { + this._flingSamples = [] + } else { + const t = this.now() + this._flingSamples = this._flingSamples.filter( + (sample) => t - sample.t <= 200, + ) + this._flingSamples.push({ t, offset: elementOffset }) + if ( + (prevDirection !== null && + this.scrollDirection !== prevDirection) || + flingSpedUp(this._flingSamples) + ) { + this.scrollState = null + } + } } // A finger that moved the content is dragging. Our own write landing // under a resting finger is not, and neither is a report at rest // that leaves the position where it was. if ( - !isReadBack && + kind !== 'readBack' && isScrolling && offset !== prevOffset && this._iosTouch?.phase === 'down' ) { - this._iosTouch.dragged = true + this._iosTouch.moved = true + // The user scrolls: a command still waiting for the tail is + // theirs to override, like a written one would be. + if (this.scrollState?.phase === 'queued') this.scrollState = null } // A held correction is written once a write is possible. @@ -1157,11 +1234,13 @@ export class Virtualizer< const onTouchStart = () => { // A finger back down inside the grace window cancels its tail. this._endIosTouch() - this._iosTouch = { phase: 'down', dragged: false } + this._iosTouch = { phase: 'down', moved: false } } const onTouchEnd = () => { + const moved = this._iosTouch?.phase === 'down' && this._iosTouch.moved this._endIosTouch() - this._openIosTail() + this._lateFling = moved ? 'awaiting' : null + this._openIosTail(moved) } scrollEl.addEventListener( 'touchstart', @@ -1196,10 +1275,7 @@ export class Virtualizer< }) } - this._scrollToOffset(this.getScrollOffset(), { - adjustments: undefined, - behavior: undefined, - }) + this._scrollToOffset(this.getScrollOffset()) } const anchor = this.pendingScrollAnchor @@ -1217,6 +1293,7 @@ export class Virtualizer< // an update scheduled from a layout effect commits before paint. if (!this._canWriteScroll()) { this._holdOffset += anchor.delta + this._followOffsetTarget(anchor.delta) this.notify(false) } else if ( isSmoothScroll(this.scrollState) && @@ -1234,10 +1311,8 @@ export class Virtualizer< // A smooth scroll that has already landed (offset at its target, // reconcile not yet retired it) still gets the anchor sync. } else { - this._scrollToOffset(this.getScrollOffset(), { - adjustments: undefined, - behavior: undefined, - }) + this._followOffsetTarget(anchor.delta) + this._scrollToOffset(this.getScrollOffset()) } } else { this.scrollToEnd({ behavior: anchor.behavior }) @@ -1254,6 +1329,25 @@ export class Virtualizer< this._retryClampedAdjustment() } + // Classify a scroll event (see `ScrollEventKind`) from the state as it was + // before the event. `offset` is the reported position in layout + // coordinates; `elementMax` is the element's own max, read only while + // scrolling (null otherwise). + private _classifyScrollEvent = ( + offset: number, + isScrolling: boolean, + elementMax: number | null, + ) => + classifyScrollEvent({ + offset, + isScrolling, + elementMax, + holdOffset: this._holdOffset, + scrollOffset: this.scrollOffset, + pendingWrite: this.pendingWrite, + clampedMaxAtWrite: this._clampedAdjustment?.maxAtWrite ?? null, + }) + // Re-issue a compensation write the browser clamped because the sizer had // not grown yet (#1258, #1266). Called after `notify` in `resizeItem`, // which covers consumers that size the container synchronously inside @@ -1276,10 +1370,7 @@ export class Virtualizer< // the new max so the next opportunity retries. this._clampedAdjustment = target > max + 0.5 ? { target, maxAtWrite: max } : null - this._scrollToOffset(target, { - adjustments: undefined, - behavior: undefined, - }) + this._scrollToOffset(target) } } @@ -1293,24 +1384,98 @@ export class Virtualizer< // A finger is dragging the content, so iOS ignores our writes and no // read-back will come. A resting finger does not: the write lands. private _iosDragging = () => - this._iosTouch?.phase === 'down' && this._iosTouch.dragged + this._iosTouch?.phase === 'down' && this._iosTouch.moved // (Re)open the tail after the finger lifts, closing ~150ms after the last // touchend or momentum frame. On non-iOS there is nothing to protect. - private _openIosTail = () => { + private _openIosTail = (moved: boolean) => { if (!isIOSWebKit() || this.targetWindow == null) return this._endIosTouch() const tail = { phase: 'tail' as const, + moved, timerId: this.targetWindow.setTimeout(() => { + // The end of a fling can move less than a pixel per scroll event, so + // events can stop for 150ms while the fling is still running (seen + // on an iPhone, 27.0.1). A command waiting for it would then be + // written into the fling and overridden. The element having moved + // since the last event means it has not ended: wait again. Not while + // our own write has yet to report: that moved it. + const now = this._readElementOffset() + if ( + this._iosTouch === tail && + this.scrollState?.phase === 'queued' && + this.pendingWrite === null && + now !== null && + this._lastElementOffset !== null && + Math.abs(now - this._lastElementOffset) >= 1 + ) { + this._lastElementOffset = now + // It moved with no event: a sample, so the speed `flingCaught` + // reads when it stops is not the one from before the silence. + this._flingSamples.push({ t: this.now(), offset: now }) + this._openIosTail(tail.moved) + return + } if (this._iosTouch === tail) this._iosTouch = null - // The last momentum frame may have come before the timer. + if (this._lateFling === 'awaiting') this._lateFling = this.now() + 1000 + // A fling that stopped at speed was caught by a finger that is still + // down, with no touch events: the user has taken over, as with a + // drag, and iOS would undo the write anyway. + if ( + this.scrollState?.phase === 'queued' && + flingCaught(this._flingSamples) + ) { + this.scrollState = null + } + // The last momentum frame may have come before the timer. The hold + // is released first, so a command that waited for the tail is + // written over rows already back in layout coordinates. this._flushHoldIfReady(true) + this._writeAwaitingCommand() }, 150), } this._iosTouch = tail } + // Write the command that `_startScroll` left waiting for the momentum + // tail, against the layout as it is now (rows measured during the fling + // have moved an index target). + private _writeAwaitingCommand = () => { + const state = this.scrollState + if (state === null || state.phase !== 'queued') return + const { behavior } = state + this.getMeasurements() + const offset = this.getScrollStateTarget(state) + state.lastTargetOffset = offset + // The hold flushed just before can leave a clamp retry (#1258); it + // would write its old target over this command. + this._clampedAdjustment = null + state.phase = initialScrollPhase( + behavior, + state.index != null && approxEqual(offset, this.getScrollOffset()), + ) + this._scrollToOffset(offset, { behavior }) + this.scheduleScrollReconcile() + } + + // The element's position as the offset observers report it, or null for a + // scroll element that is neither an element nor a window. + private _readElementOffset = (): number | null => { + const el = this.scrollElement as unknown as Partial< + Element & Pick + > | null + if (el == null) return null + const { horizontal, isRtl } = this.options + if (typeof el.scrollTop === 'number') { + return horizontal ? el.scrollLeft! * ((isRtl && -1) || 1) : el.scrollTop + } + if (typeof el.scrollY === 'number') { + return horizontal ? el.scrollX! : el.scrollY + } + return null + } + private _endIosTouch = () => { if (this._iosTouch?.phase === 'tail' && this.targetWindow != null) { this.targetWindow.clearTimeout(this._iosTouch.timerId) @@ -1321,7 +1486,8 @@ export class Virtualizer< // Whether a scrollTop write made now would reach the element and stay: // - not on iOS while a touch gesture owns the scroll (`_iosTouch`): a // dragging finger ignores the write, momentum is cancelled by it on - // iOS 26 and earlier and overrides it on 27 (#884, #1307); + // iOS 26 and earlier and overrides it within a frame on 27 (#884, + // #1307), so a correction is held through the tail on every version; // - not while the element is overscrolled past either edge (the // rubber-band bounce): WebKit discards the write and the bounce can // stall where it was (#1287). @@ -1358,10 +1524,7 @@ export class Virtualizer< // A consumer that sizes the container after this write gets the retry // (#1258). this._clampedAdjustment = this._clampedBy(target) - this._scrollToOffset(target, { - adjustments: undefined, - behavior: undefined, - }) + this._scrollToOffset(target) } private rafId: number | null = null @@ -1397,6 +1560,13 @@ export class Virtualizer< return } + // Nothing to reconcile until the tail closes and the command is written; + // only the safety valve above applies. + if (this.scrollState.phase === 'queued') { + this.scheduleScrollReconcile() + return + } + // A resize since the last render leaves measurementsCache stale until // something reads getMeasurements(); rebuild it (memoized) so an index // target reflects it even when no render ran in between. @@ -1433,10 +1603,7 @@ export class Virtualizer< // end up at the EXACT computed position of item N — matching // virtuoso's 0px landing accuracy rather than our prior 0.5-1px. if (this.getScrollOffset() !== targetOffset) { - this._scrollToOffset(targetOffset, { - adjustments: undefined, - behavior: 'auto', - }) + this._scrollToOffset(targetOffset, { behavior: 'auto' }) } this.scrollState = null return @@ -1472,7 +1639,6 @@ export class Virtualizer< // until reconcile sees it at the target; 'instant' stays instant. this._scrollToOffset(targetOffset, { - adjustments: undefined, behavior: keepSmooth ? 'smooth' : 'auto', }) } @@ -1978,14 +2144,13 @@ export class Virtualizer< const prevTotalSize = wasAtEnd ? this._getLayoutTotalSize() : 0 // Default anchoring predicate (used unless the consumer supplies a // custom shouldAdjustScrollPositionOnItemSizeChange). - const scrollOffsetWithAdj = - this.getScrollOffset() + this.scrollAdjustments + const scrollOffset = this.getScrollOffset() const isFirstMeasure = !this.itemSizeCache.has(key) const defaultShouldAdjust = isFirstMeasure ? // First measurement: compensate any item whose top sits above the // fold — the estimate→actual delta must be corrected regardless of // scroll direction, since the whole estimated block was above it. - itemStart < scrollOffsetWithAdj + itemStart < scrollOffset : // Re-measurement: only compensate an item that is ENTIRELY above the // fold. An item that merely *spans* the fold (top above, bottom // below — e.g. a streaming chat message growing at its bottom) @@ -1993,7 +2158,7 @@ export class Virtualizer< // delta would drag the viewport downward on every growth (#1218). // Also skip during backward scroll to avoid the "items jump while // scrolling up" cascade. - itemStart + itemSize <= scrollOffsetWithAdj && + itemStart + itemSize <= scrollOffset && this.scrollDirection !== 'backward' const shouldAdjustScroll = !isSmoothScroll(this.scrollState) && @@ -2020,30 +2185,11 @@ export class Virtualizer< this.itemSizeCache.set(key, size) this.itemSizeCacheVersion++ - // A scroll command (or reconcile) wrote a new position that neither a - // scroll event nor an eager update has reflected in `scrollOffset` yet. - // The rows being measured were rendered for the old position, so - // "above the viewport" means nothing against either offset. An index - // or end target is retargeted from fresh measurements by - // reconcileScroll next frame; an offset target (scrollToOffset, - // scrollBy) lands where it was written and the delta is not applied, - // since compensating against the stale offset would overwrite the - // command. Compensation writes and the prepend anchor sync update - // `scrollOffset` eagerly, and the iOS flush writes exactly - // `scrollOffset + scrollAdjustments`, so none of them count. Under a - // dragging finger iOS ignores the write, so no read-back will come and - // the touch deferral below takes the correction instead. - const writtenOffset = this.getCommandWrittenOffset() - const awaitingCommandReadBack = - !(isIOSWebKit() && this._iosDragging()) && - writtenOffset !== null && - !approxEqual( - writtenOffset, - this.getScrollOffset() + this.scrollAdjustments, - ) - + // Before a command's read-back, an index or end target is retargeted + // from fresh measurements by reconcileScroll next frame; an offset + // target lands where it was written and the delta is not applied. let adjustedSync = false - if (!awaitingCommandReadBack) { + if (!this._awaitingCommandReadBack()) { if (wasAtEnd) { adjustedSync = this.applyScrollAdjustment( this._getLayoutTotalSize() - prevTotalSize, @@ -2136,6 +2282,22 @@ export class Virtualizer< private getMaxScrollOffset = () => (this._elementMaxScrollOffset() ?? 0) + this._holdOffset + // An element position in layout coordinates: how far the layout position + // is ahead of the element. The element is `_holdOffset` behind while a + // correction is held, and a prepend's anchor delta behind from + // `setOptions` (which moved `scrollOffset` by it) until `_willUpdate` + // writes or holds it. Writes (`_scrollToOffset`) and the clamp record + // (`_clampedBy`) deliberately subtract the hold alone, not this whole + // lead: by the time they run, the anchor delta is already in + // `scrollOffset`, and writing that layout position is what closes it. + private _toLayoutOffset = (elementOffset: number) => { + let offset = elementOffset + this._holdOffset + if (this.pendingScrollAnchor?.kind === 'anchor') { + offset += this.pendingScrollAnchor.delta + } + return offset + } + // The element's own scroll range, or null where it cannot be read (no // element, or a bare test double without `scrollHeight` / `document`). private _elementMaxScrollOffset = (): number | null => { @@ -2331,26 +2493,46 @@ export class Virtualizer< behavior: ScrollBehavior, kind: 'absolute' | 'relative', ) { - if (kind === 'absolute') { + if (kind === 'absolute' && this._iosTouch?.phase === 'tail') { + if (this._iosTouch.moved && iosMomentumOverridesWrites()) { + // iOS 27: a write now shows for one frame and the fling carries on + // along its own path, so the command would be lost (#1307, step 3). + // Leave the tail open and write when it closes, ~150ms after the + // last fling frame; a drag before then cancels the command (see the + // scroll handler), a tap does not. A relative command (`scrollBy`) + // is not postponed: its target is taken from the position at the + // call, which the fling leaves behind either way. + this.scrollState = { + ...target, + startedAt: this.now(), + lastTargetOffset: offset, + stableFrames: 0, + phase: 'queued', + behavior, + } + this._flingSamples = [] + this.scheduleScrollReconcile() + return + } // The command takes over the scroll, so close the tail: the landing's // corrections then apply before paint, even for a command issued from // a tap handler inside the tail (#1250). On iOS 26 and earlier the - // write stops the momentum; on 27 momentum can continue past it, but - // a write there does not stop momentum either (#1307, step 3). With a - // finger down the user owns the scroll: leave it. - if (this._iosTouch?.phase === 'tail') this._endIosTouch() + // write stops the momentum. With a finger down the user owns the + // scroll: leave it. + this._endIosTouch() } this.scrollState = { ...target, startedAt: this.now(), lastTargetOffset: offset, stableFrames: 0, + behavior, phase: initialScrollPhase( behavior, target.index != null && approxEqual(offset, this.getScrollOffset()), ), } - this._scrollToOffset(offset, { adjustments: undefined, behavior }) + this._scrollToOffset(offset, { behavior }) this.scheduleScrollReconcile() } @@ -2451,16 +2633,13 @@ export class Virtualizer< { adjustments, behavior, - }: { - adjustments: number | undefined - behavior: ScrollBehavior | undefined - }, + }: { adjustments?: number; behavior?: ScrollBehavior } = {}, ) => { - this.pendingWrite = { + const write = { target: offset + (adjustments ?? 0), smooth: behavior === 'smooth', - adjustments: adjustments ?? this.scrollAdjustments, } + this.pendingWrite = write // The element sits `_holdOffset` behind the layout while a correction // is held; `target` and the read-back stay in layout coordinates. this.options.scrollToFn( @@ -2468,6 +2647,7 @@ export class Virtualizer< { behavior, adjustments }, this, ) + return write } // Where the in-flight command's last instant write put the element, until @@ -2482,6 +2662,35 @@ export class Virtualizer< ? this.pendingWrite.target : null + // A scroll command (or reconcile) wrote a new position that neither a + // scroll event nor an eager update has reflected in `scrollOffset` yet. + // The rows on screen were rendered for the old position, so "above the + // viewport" means nothing against either offset: a correction decided now + // would be written over the command. Compensation writes and the prepend + // anchor sync update `scrollOffset` eagerly, and the hold's flush writes + // exactly `scrollOffset`, so none of them count. Under a dragging finger + // iOS ignores the write, so no read-back will come and the hold takes the + // correction instead. + private _awaitingCommandReadBack = () => { + const writtenOffset = this.getCommandWrittenOffset() + return ( + !(isIOSWebKit() && this._iosDragging()) && + writtenOffset !== null && + !approxEqual(writtenOffset, this.getScrollOffset()) + ) + } + + // An offset target (scrollToOffset, scrollBy) is a layout position. A + // correction for content above the viewport moves the viewport's layout + // position by `delta`, and the target moves with it, so reconcile sees the + // command reached rather than waiting for a position it can never see + // (#1307, step 5). Index and end targets are recomputed from the layout. + private _followOffsetTarget = (delta: number) => { + if (this.scrollState !== null && this.scrollState.index == null) { + this.scrollState.lastTargetOffset += delta + } + } + measure = () => { // Reset pendingMin so the next getMeasurements rebuilds from index 0. // Without this, a prior resizeItem() that left pendingMin > 0 would diff --git a/packages/virtual-core/src/scroll-classifier.ts b/packages/virtual-core/src/scroll-classifier.ts new file mode 100644 index 000000000..518a88320 --- /dev/null +++ b/packages/virtual-core/src/scroll-classifier.ts @@ -0,0 +1,213 @@ +import { approxEqual } from './utils' + +// Why a scroll event arrived, decided from the virtualizer's state as it was +// before the event, before the scroll handler mutates anything. The first +// match in this order wins: +// spurious Safari/Firefox re-emit at the position we hold, nothing +// pending +// readBack our last write reporting back (within 1.5px of its +// target) +// clampedReadBack our last non-smooth write, clamped at the element's +// current max +// clampedEcho a compensation write the browser clamped (#1258) +// reporting at the max seen at write time +// external anything else: the user, a fling, a smooth animation +// frame +type ScrollEventKind = + | 'spurious' + | 'readBack' + | 'clampedReadBack' + | 'clampedEcho' + | 'external' + +export interface ScrollEventInput { + // The reported position in layout coordinates (the element's offset plus + // the held correction and a pending prepend anchor's delta). + offset: number + isScrolling: boolean + // The element's own scroll max, read only while scrolling (null otherwise + // or where it cannot be read). + elementMax: number | null + // How far the element sits behind the layout while a correction is held. + holdOffset: number + // The layout position the virtualizer holds. + scrollOffset: number | null + // Our last write to the element, until a scroll event reports it. + pendingWrite: { target: number; smooth: boolean } | null + // The element's max when a clamped compensation write was made (#1258), + // or null when none is pending. + clampedMaxAtWrite: number | null +} + +export function classifyScrollEvent({ + offset, + isScrolling, + elementMax, + holdOffset, + scrollOffset, + pendingWrite, + clampedMaxAtWrite, +}: ScrollEventInput): ScrollEventKind { + // A scroll event that reports movement but lands on the offset we already + // hold — and isn't a self-write read-back — is a spurious no-op re-emit + // that Safari/Firefox fire after a re-render's layout (Chrome doesn't). + // Treating it as scrolling re-arms `isScrolling`, which forces a render + // that triggers another such event: an infinite re-render loop. + if (isScrolling && pendingWrite === null && offset === scrollOffset) { + return 'spurious' + } + if (pendingWrite !== null && Math.abs(offset - pendingWrite.target) < 1.5) { + return 'readBack' + } + // A non-smooth write past the element's max reads back at the max: ours, + // clamped because the sizer shrank between the write and the report (rows + // above the target measured smaller in a same-task render, #1310). The + // element is where it says. A finger that drags to the max while such a + // write is pending (iOS ignored it) is read the same way; the command then + // retires at the next frame, its target clamped to the same max. + if ( + pendingWrite !== null && + !pendingWrite.smooth && + elementMax !== null && + pendingWrite.target > elementMax + holdOffset + 0.5 && + Math.abs(offset - (elementMax + holdOffset)) < 1.5 + ) { + return 'clampedReadBack' + } + if ( + clampedMaxAtWrite !== null && + Math.abs(offset - clampedMaxAtWrite) < 1.5 + ) { + return 'clampedEcho' + } + return 'external' +} + +// Where an in-flight scroll command is in its lifecycle, which decides how +// its scroll events and measurements are read: +// queued the first write waits for the momentum tail to close +// (iOS 27, see `_startScroll`). Nothing has been written: +// the fling's scroll events do not retire the command, a +// drag does, and reconcile only keeps the safety valve +// smooth a smooth animation is travelling to the target +// smoothLanded a smooth scroll is at its target, waiting to retire +// smoothFinishing a smooth scroll switched to instant writes for the +// final approach; its animation can still report positions +// instant instant writes +export type ScrollPhase = + | 'queued' + | 'smooth' + | 'smoothLanded' + | 'smoothFinishing' + | 'instant' + +// The smooth animation may still be reporting positions, so a scroll event +// that isn't the read-back of our own write may not be the user. +const isAnimating = (phase: ScrollPhase) => + phase === 'smooth' || phase === 'smoothFinishing' + +export interface RetireInput { + kind: ScrollEventKind + // The reported position in layout coordinates, as classified. + offset: number + // The in-flight scroll command, or null. + command: { + index: number | null + phase: ScrollPhase + lastTargetOffset: number + } | null + // The command's target recomputed against the current layout. Called + // only once every cheaper clause has passed, and at most once. + target: () => number +} + +// Whether a scroll event that is not the read-back of our own write retires +// the in-flight index command: an external scroll moved away from its +// target. Our own measurement updates (including clamped writes) still need +// to reconcile. +export function externalRetiresCommand({ + kind, + offset, + command, + target, +}: RetireInput): boolean { + return ( + kind !== 'spurious' && + kind !== 'readBack' && + // Our own write, clamped (`clampedReadBack`, #1310): the command is not + // retired by it, and reconcile retargets below the new max. + kind !== 'clampedReadBack' && + command?.index != null && + // Nothing has been written for a command waiting for the tail; these + // are the fling's events. + command.phase !== 'queued' && + // A non-smooth write lands at once, so any other scroll event is the + // user's, even before reconcile saw the target reached (the target may + // keep moving, or iOS ignored the write under a finger). A write the + // browser clamped because the sizer hasn't grown yet reads back at the + // max, which is also where the fresh target clamps, so the check below + // keeps it reconciling (#1294). While a smooth scroll's animation may + // still report, its events look like a user's, so it yields only once it + // has landed. + !isAnimating(command.phase) && + !approxEqual(offset, command.lastTargetOffset) && + !approxEqual(offset, target()) && + // A compensation write's clamped echo (#1258) keeps the command. The + // order is deliberate, not tidiness: this clause stays after `target()` + // (and `clampedReadBack` before it) because `target()` can rebuild the + // measurements, so it must run for exactly the events it always did. + kind !== 'clampedEcho' + ) +} + +// A scroll position reported at a time, in the element's own coordinates. +export interface ScrollSample { + t: number + offset: number +} + +const FLING_WINDOW_MS = 40 + +// Whether the scroll sped up: a finger flicked again or dragged on during a +// fling. iOS fires no touch events for a touch that lands on a fling +// (#1307), so this, and a turn against the fling, is all that shows it. +// Momentum only slows down. Scroll events arrive 7–26ms apart and some +// carry two frames, so one event can read 2–3× faster than its neighbours; +// the speed is taken over the last 40ms and compared with the 40ms before. +// On an iPhone (27.0.1) the flicks read 2.4–6× faster than the window before +// and at least 1.5px/ms more, momentum alone at most 1.7× (#1307). +export function flingSpedUp(samples: ReadonlyArray): boolean { + const last = samples[samples.length - 1] + if (last === undefined) return false + let j = samples.length - 2 + while (j >= 0 && last.t - samples[j]!.t < FLING_WINDOW_MS) j-- + if (j < 0) return false + const mid = samples[j]! + let k = j - 1 + while (k >= 0 && mid.t - samples[k]!.t < FLING_WINDOW_MS) k-- + if (k < 0) return false + const first = samples[k]! + const recent = (last.offset - mid.offset) / (last.t - mid.t) + const before = (mid.offset - first.offset) / (mid.t - first.t) + return ( + recent * before >= 0 && + Math.abs(recent) >= 2 * Math.abs(before) && + Math.abs(recent) - Math.abs(before) >= 0.8 + ) +} + +// Whether a fling that sent its last scroll event was still going at +// 0.05px/ms or more over its last 40ms: a finger caught it. A fling that +// ends on its own fades to at most 0.015px/ms first; taps stopped one at +// 0.21–1.43px/ms (iPhones on 27.0.1, #1307). The finger stays down with no +// touch events, and iOS undoes a write made under it. A tap in the last +// pixels of a fling reads as its end. +export function flingCaught(samples: ReadonlyArray): boolean { + const last = samples[samples.length - 1] + if (last === undefined) return false + let j = samples.length - 2 + while (j >= 0 && last.t - samples[j]!.t < FLING_WINDOW_MS) j-- + if (j < 0) return false + const from = samples[j]! + return Math.abs(last.offset - from.offset) / (last.t - from.t) >= 0.05 +} diff --git a/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap b/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap index 31300e65b..4e3f679c8 100644 --- a/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap +++ b/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap @@ -2,23 +2,34 @@ exports[`scroll behaviour snapshot > anchorEnd ios 1`] = ` [ - "0: ea2cac43 85b526da ed35961e 2f2e6cc4 71c65ae4 0eeb9610 33f6e53e 2a95e498 9d2aaa43 c8d7218b", - "10: 6e7306be 67a48d7b 56c4a6ad ddd93b12 3952ce1c 83082b83 8b6948e0 7081e813 65b7d405 7f3041e0", - "20: 8e4723f5 f780f908 13bd300f d53e7a2d 80f708b1 ec458f03 7533b661 6408933e 9f3ec27c 09a1874e", - "30: d0da31f6 e08a7697 beba95dc b0cf23fd 436c1d33 9b9b908c c8c87f50 d9749dba 552b4b5b 93ac03b2", + "0: 9c30ad29 85b526da ed35961e 0a69fa03 86e37173 a7c6a294 33f6e53e 2a95e498 9d2aaa43 79c0b972", + "10: 56c43525 67a48d7b 56c4a6ad 8aad82fb 3952ce1c 83082b83 8b6948e0 7081e813 d1336903 7f3041e0", + "20: 8e4723f5 f780f908 13bd300f d53e7a2d ddda056c ebb40e0f 7533b661 6408933e 9f3ec27c 09a1874e", + "30: d0da31f6 2a263abe beba95dc b0cf23fd 436c1d33 9b9b908c c8c87f50 d9749dba 552b4b5b 93ac03b2", "40: 351e701d 2f446794 d8e6a0e3 4d60c709 0b280b4f ac75fa53 868d9e7b 7f5b2824 780cae35 8f0a8fe9", - "50: d2429922 8cc11ea8 810e9f05 bb003fd5 8fcc29b1 aee1412a bccee2e5 5d36e37b e9fb7a13 6dabad9d", + "50: d2429922 8cc11ea8 810e9f05 8861b4ff 8fcc29b1 aee1412a bccee2e5 e46aa4df e9fb7a13 ef6352c1", +] +`; + +exports[`scroll behaviour snapshot > anchorEnd ios27 1`] = ` +[ + "0: 9c30ad29 85b526da ed35961e a0c8a633 86e37173 a7c6a294 33f6e53e 2a95e498 9d2aaa43 da12bd22", + "10: 56c43525 321ccf6c 56c4a6ad 8aad82fb 341cd88e 83082b83 ad77153a fd903c9c d1336903 7f3041e0", + "20: 8e4723f5 b7484e9f 13bd300f d53e7a2d 4c85db64 ebb40e0f ec059677 6408933e a8bfc9cf 09a1874e", + "30: 3825d5d2 2a263abe beba95dc 6e007e5d 3ab04872 9b9b908c c8c87f50 4acd8bac 552b4b5b 93ac03b2", + "40: 351e701d 81b41eab d8e6a0e3 4d60c709 0018ede1 ac75fa53 868d9e7b 7f5b2824 3e95ee0d 8f0a8fe9", + "50: d2429922 8cc11ea8 90e19d07 7c8d21a0 11d479bb 15403d2b bccee2e5 e46aa4df e9fb7a13 ef6352c1", ] `; exports[`scroll behaviour snapshot > anchorEnd web 1`] = ` [ - "0: ea2cac43 4d0cdcdd 2054f343 f5c1a3e7 35821d3b dc6157c1 4594e9e3 2a95e498 dbc275c5 22ff1998", - "10: a764fdc3 aef54094 56c4a6ad 8ece1eee 59bfcd8d 83082b83 05d160db 100c06e5 65b7d405 7f3041e0", - "20: 6616fe9d f780f908 13bd300f 30643489 8107ab97 ec458f03 63ed23da 68a18267 253fe61c 09a1874e", - "30: 5e6688c5 0d4221f3 5aa253d8 46527f28 2872565f 9b9b908c e14b74d0 8f93c51a 552b4b5b 660f3603", + "0: 9c30ad29 d7e16aa1 2054f343 52eea5f6 29228cee 2b34f59e 4594e9e3 2a95e498 dbc275c5 d1814a7b", + "10: 77f37f3c aef54094 56c4a6ad d5af8722 59bfcd8d 83082b83 05d160db 100c06e5 d1336903 7f3041e0", + "20: 6616fe9d f780f908 13bd300f 30643489 ec2d1b0c ebb40e0f 63ed23da 68a18267 253fe61c 09a1874e", + "30: 5e6688c5 009cbdfe 8c8bdd3e 46527f28 f7133699 9b9b908c e14b74d0 8f93c51a 552b4b5b 660f3603", "40: 93dd81a7 c9c39478 622e6d21 4d60c709 e721cb06 5b6d2738 868d9e7b f3ef7742 2aa59586 886aae03", - "50: d2429922 8cc11ea8 81d0357b 5c7e085f 8fcc29b1 3cea4ec5 27af96a0 5d36e37b 3c8d8225 2c598250", + "50: d2429922 8cc11ea8 81d0357b cfd20837 8fcc29b1 3cea4ec5 27af96a0 e46aa4df 3c8d8225 18bcf75b", ] `; @@ -33,12 +44,23 @@ exports[`scroll behaviour snapshot > base ios 1`] = ` ] `; +exports[`scroll behaviour snapshot > base ios27 1`] = ` +[ + "0: a9fcb9d4 0d2cec68 ed35961e a0c8a633 0be21da6 3ec8eb85 869fa2d1 27716a81 f885c716 37400543", + "10: 9dedc3e2 bc62c925 455a1c1e f463e55c 21131058 83082b83 41ca8ab9 fd903c9c d1336903 7b73eb01", + "20: 65dd1acd b7484e9f 2c8bfacf 98bf94c9 2c2abba8 a7b02f04 ec059677 f6030c4d cc64ede2 a5002541", + "30: 3825d5d2 d8bbcf3a ea6dc035 2ecd0e38 b6979c84 46df8677 c4ac0a4d 38614562 5b30b633 d4289992", + "40: 351e701d f52511a4 2bf7e8fd feacb1d8 b612742b ccef0b14 f9ead96e 30236a00 bb18bdf4 3b4e9ef5", + "50: d2429922 07cac42c abfcf07e 7c8d21a0 10026e87 32de4492 b68d2291 1841edc4 8632a12b f6d88486", +] +`; + exports[`scroll behaviour snapshot > base web 1`] = ` [ "0: a9fcb9d4 e79e71f6 993e0b9d 52eea5f6 f7591689 ef961068 b71055b4 27716a81 8b825bba 94652b32", "10: 5c9cc689 e2e1c3ec 455a1c1e 18a26060 0accf4b5 83082b83 c0d001ca 100c06e5 d1336903 7b73eb01", "20: 7886a736 ceac4ab2 2c8bfacf 6865f6c2 d47af093 a7b02f04 63ed23da 715e0039 4ddf6da0 a5002541", - "30: 5978f80e 0c5f12e2 718ad4b7 aec3dd30 1b99b475 46df8677 4e88df21 8f93c51a 5b30b633 66d94fc8", + "30: 5978f80e 0c5f12e2 79ef7be1 aec3dd30 1b99b475 46df8677 4e88df21 8f93c51a 5b30b633 66d94fc8", "40: 8249f4ed 2170911f c938e8f5 feacb1d8 826217e9 8a2b7146 f9ead96e d1dc687f 36e554f8 03c3bcc1", "50: d2429922 07cac42c 78c9d354 cfd20837 b30f44cd 5f27e381 5c11a132 1841edc4 36b0937d 9ddde49c", ] @@ -55,12 +77,23 @@ exports[`scroll behaviour snapshot > horizontal ios 1`] = ` ] `; +exports[`scroll behaviour snapshot > horizontal ios27 1`] = ` +[ + "0: a9fcb9d4 0d2cec68 ed35961e a0c8a633 0be21da6 3ec8eb85 869fa2d1 27716a81 f885c716 37400543", + "10: 9dedc3e2 bc62c925 455a1c1e f463e55c 21131058 83082b83 41ca8ab9 fd903c9c d1336903 7b73eb01", + "20: 65dd1acd b7484e9f 2c8bfacf 98bf94c9 2c2abba8 a7b02f04 ec059677 f6030c4d cc64ede2 a5002541", + "30: 3825d5d2 d8bbcf3a ea6dc035 2ecd0e38 b6979c84 46df8677 c4ac0a4d 38614562 5b30b633 d4289992", + "40: 351e701d f52511a4 2bf7e8fd feacb1d8 b612742b ccef0b14 f9ead96e 30236a00 bb18bdf4 3b4e9ef5", + "50: d2429922 07cac42c abfcf07e 7c8d21a0 10026e87 32de4492 b68d2291 1841edc4 8632a12b f6d88486", +] +`; + exports[`scroll behaviour snapshot > horizontal web 1`] = ` [ "0: a9fcb9d4 e79e71f6 993e0b9d 52eea5f6 f7591689 ef961068 b71055b4 27716a81 8b825bba 94652b32", "10: 5c9cc689 e2e1c3ec 455a1c1e 18a26060 0accf4b5 83082b83 c0d001ca 100c06e5 d1336903 7b73eb01", "20: 7886a736 ceac4ab2 2c8bfacf 6865f6c2 d47af093 a7b02f04 63ed23da 715e0039 4ddf6da0 a5002541", - "30: 5978f80e 0c5f12e2 718ad4b7 aec3dd30 1b99b475 46df8677 4e88df21 8f93c51a 5b30b633 66d94fc8", + "30: 5978f80e 0c5f12e2 79ef7be1 aec3dd30 1b99b475 46df8677 4e88df21 8f93c51a 5b30b633 66d94fc8", "40: 8249f4ed 2170911f c938e8f5 feacb1d8 826217e9 8a2b7146 f9ead96e d1dc687f 36e554f8 03c3bcc1", "50: d2429922 07cac42c 78c9d354 cfd20837 b30f44cd 5f27e381 5c11a132 1841edc4 36b0937d 9ddde49c", ] @@ -70,19 +103,30 @@ exports[`scroll behaviour snapshot > lanes ios 1`] = ` [ "0: 02580197 f314892c fd721b30 93e795a5 84959476 631e3681 7114dede de5f2a5e 576db818 2e50c67d", "10: 514a82cf 25c644f3 7a8d2a09 2abee23f 5872f8d5 c103e623 c4d2fe64 554a1766 21de167e 487aeb4b", - "20: f2245f58 81bd9a1e 79b54054 6e12648a 38539662 4b10cf13 0bddc3e4 11317c75 bbfdc970 9e7a8ac3", + "20: f2245f58 81bd9a1e 79b54054 6e12648a 38539662 4b10cf13 0bddc3e4 11317c75 bbfdc970 d2432a43", "30: 12468132 20c7aff9 67dcb6d5 03291755 c9ffab63 5f0a007d ec1d6907 f41ff60d f7397d3b 701d9fb6", "40: d7be12c4 972dd8d5 cd9e5629 bc8f344f 60dc134e 96523d8a d0d37a83 08867236 7da74ce4 bbba1406", "50: 89573ef3 4d90e248 f737f69e c3acfdb6 39c42ebf 55ca59be e01db9de cedb1bda cc70c64d 00e88da4", ] `; +exports[`scroll behaviour snapshot > lanes ios27 1`] = ` +[ + "0: 02580197 f314892c fd721b30 34c4bce9 84959476 631e3681 7114dede de5f2a5e 576db818 4b924978", + "10: 514a82cf ddf40a6b 7a8d2a09 2abee23f af7ac992 c103e623 00ca2427 29c40818 21de167e 487aeb4b", + "20: f2245f58 2424b184 79b54054 6e12648a 38539662 4b10cf13 feb6fb5c 11317c75 42d7823b d2432a43", + "30: 65523943 20c7aff9 67dcb6d5 473c2a8f 7d23b05f 5f0a007d ec1d6907 e86742e8 f7397d3b 701d9fb6", + "40: d7be12c4 95d3e050 cd9e5629 bc8f344f d553998f 96523d8a d0d37a83 08867236 f50ddbf7 bbba1406", + "50: 89573ef3 4d90e248 53dfd836 9b5a1c5a b5b1ab21 8dd98e7e e01db9de cedb1bda cc70c64d 00e88da4", +] +`; + exports[`scroll behaviour snapshot > lanes web 1`] = ` [ "0: 07b019d6 50ba0f48 2528ce0b 56751a0d f27c3816 3ad778af 510a0a14 de5f2a5e 8a8df520 9c4df1f6", "10: 0e9abc72 9ab1beaa 7a8d2a09 cc167259 bfd342b3 c103e623 cb717084 554a1766 21de167e 487aeb4b", - "20: 3fb995ae 81bd9a1e 79b54054 e99e680d f2c453bb 4b10cf13 588f804a bf451280 912642c1 9e7a8ac3", - "30: 5a9c1d81 20c7aff9 2fecb4a5 03291755 c12620e1 5f0a007d ec1d6907 e64d3f41 f7397d3b 2e9ca3d4", + "20: 3fb995ae 81bd9a1e 79b54054 e99e680d f2c453bb 4b10cf13 588f804a bf451280 912642c1 d2432a43", + "30: 5a9c1d81 20c7aff9 9b7acc6c 03291755 c12620e1 5f0a007d ec1d6907 e64d3f41 f7397d3b 2e9ca3d4", "40: 1bd23826 16a5fa48 410c3108 bc8f344f 149cffe2 49344e33 d0d37a83 d4926881 5860422c bf14dd85", "50: 89573ef3 4d90e248 05101a4e 9886e68a 39c42ebf 55ca59be e01db9de cedb1bda 9a73528c b6ea452c", ] @@ -99,12 +143,23 @@ exports[`scroll behaviour snapshot > margin ios 1`] = ` ] `; +exports[`scroll behaviour snapshot > margin ios27 1`] = ` +[ + "0: 261ee8cc 0c7104ce d660522f 97219b51 04b73dd1 c3569067 ee016b0d a9e0ea4e 59fe0c9b 042ea9c0", + "10: 80298488 f939c6d6 b47b8c57 b8cc23a1 28718591 d356dc6d ae7605be fe3e214a 11b3151f c61a840f", + "20: 46215df2 4bbfd318 9af764d5 e5ad1c1d 56d60460 d0cffbec 93a50774 09a185e0 968d5120 200d4ab4", + "30: d8e56221 adb8145a 76ab5658 116e0db6 e6fec36b f2b45513 21218db4 a9114d57 94e08cb6 d7c3c8ec", + "40: 4a4806a4 eeaf3a73 fc88e237 22ee5368 b54e13a5 7f1a8f34 20a8933f 657292c1 9201ba9f 12553696", + "50: 5baeb658 6ee3c4c0 3a1d8120 e20a5163 1e363d48 359781c0 ffba99f2 95424c35 80d71aa6 1cbe5ceb", +] +`; + exports[`scroll behaviour snapshot > margin web 1`] = ` [ "0: 261ee8cc 1bcd5eb5 322f7016 624f6fc6 19fb4c9e 15dfe2bb 3885748f a9e0ea4e 7f75e46a 47e71105", "10: 1eb2c1cc e7142fbf b47b8c57 8d71e7f5 3293ed90 d356dc6d 50cd4bd0 f87e296b 11b3151f c61a840f", "20: 9e4b77f5 cb7198cf 9af764d5 a3f06d61 167abfaf d0cffbec f4d9b186 d9d12542 13de058c 200d4ab4", - "30: aadc45f7 37ce7a93 13cb8f5e f4d2cc30 c28043e6 f2b45513 8801c1ac c8ac484a 94e08cb6 2171ac81", + "30: aadc45f7 37ce7a93 d1388d46 f4d2cc30 c28043e6 f2b45513 8801c1ac c8ac484a 94e08cb6 2171ac81", "40: 62dfcbaf 657f1b43 a3a841e4 22ee5368 d3e0e255 bfb3f87c 20a8933f 0d454d4f 70957626 87f80beb", "50: 5baeb658 6ee3c4c0 cec3a144 d5b8753e 3371d972 2ad8996d 2f514fed 95424c35 c8a0843e ce539614", ] @@ -121,12 +176,23 @@ exports[`scroll behaviour snapshot > padding ios 1`] = ` ] `; +exports[`scroll behaviour snapshot > padding ios27 1`] = ` +[ + "0: e9721c4d dcb0f21f 18405d52 f7dd10b1 f5c14497 07f7e518 a982c9e4 af8fca50 a6eb7f64 3c4b43db", + "10: ba206f9a f8c67edc 26c9a16c f9840309 bb53526e d22d2dd5 c81d4797 379b813e 6ae4631e 49506420", + "20: d4877fd0 99187650 38e86f41 ff6ab12a f4730713 b76dcd97 7dd886b1 93aecc40 5af2e165 5acd3353", + "30: 8f20e4fc 6d83029c 8fb1a01a 15409980 533f63f0 f169e58d 9b1787a0 e6fddc73 d2426644 a4fea305", + "40: 9a038802 7b105968 9e53cc8c 463cadf3 834c3460 5780bba7 367b0648 813d469f 6c3098b4 0d99ff3c", + "50: b6c70c73 b71f1b03 8d8acb0e dbf27d71 cc62dc4b 37206b39 75a4fb65 0348b3bd a3efb38d 564ccd91", +] +`; + exports[`scroll behaviour snapshot > padding web 1`] = ` [ "0: e9721c4d af1d9abe e182cab2 7aa8c885 74180472 d8994dbb c80a88c1 af8fca50 fadc7677 add699ca", "10: 933b080e 115d6622 26c9a16c 004719f5 ee44c967 d22d2dd5 42e6a809 6ce09593 6ae4631e 49506420", "20: bdaed687 16d557b8 38e86f41 81161c78 019967ec b76dcd97 6f4e1938 388fa384 985508df 5acd3353", - "30: 9a11ccb6 9f638159 3f15048e 5f0500ca bffc6d8d f169e58d b05998f6 ade3fdcf d2426644 b81cee7d", + "30: 9a11ccb6 9f638159 d22c1c5b 5f0500ca bffc6d8d f169e58d b05998f6 ade3fdcf d2426644 b81cee7d", "40: e0f1e462 e3c3fb87 2f0b2154 463cadf3 9606c3c5 405da09f 367b0648 d045fc6a 4397f224 f02c1c2a", "50: b6c70c73 b71f1b03 63791db8 83a98231 c43aed20 86e98758 f8464fd0 0348b3bd 5d2531e6 0dbf1083", ] @@ -136,19 +202,30 @@ exports[`scroll behaviour snapshot > predicate ios 1`] = ` [ "0: 1503987b da28a13a 54033cbd 75995293 b092f4c8 d731f6c6 96911d82 a3c61d73 0b82d0ed 2b56adca", "10: 75e68079 2bc31908 888425aa 5683ada3 2b4bbf15 89be8bb7 ebfa70d4 9e4371d0 8134fb03 750041da", - "20: 9f915474 4ca88661 54fd2515 1bc77d21 0daaca4c b368ca7e 35aedf09 b8e64de7 675f9530 e6c44365", + "20: 9f915474 4ca88661 54fd2515 1bc77d21 0daaca4c b368ca7e 35aedf09 b8e64de7 675f9530 8caf71f9", "30: 65d2a727 4f062147 b61ba9ff 4c406d77 0c47a6ac 7cbdb0c8 d11197a6 caebb6d3 82cf0b2e 087a5d0c", "40: 079790e8 ae3a4374 d5dec481 ac3796cc a2874f32 2fba1d55 f5b117d9 384995f7 54142c26 d1dae953", "50: 274e92c7 4040e298 953852cd 61422c39 9274c4ab 606ef818 51abbdc9 0ab9d71e e817a892 eab073cc", ] `; +exports[`scroll behaviour snapshot > predicate ios27 1`] = ` +[ + "0: 1503987b da28a13a 54033cbd 6c0916d9 b092f4c8 d731f6c6 96911d82 a3c61d73 0b82d0ed a68350d1", + "10: 75e68079 1ff40218 888425aa 671c1e2a 9d2e455d 89be8bb7 de3c22d4 ccdc126b 8134fb03 750041da", + "20: 9f915474 4b896a03 54fd2515 1bc77d21 407334c6 b368ca7e 0727dcf4 b8e64de7 9effe82d 8caf71f9", + "30: 60b29a8e 4f062147 7ea2c9cf 6fb5a6e2 0ecf3bcb 7cbdb0c8 d11197a6 d6c24b0e 82cf0b2e 087a5d0c", + "40: 079790e8 7e7eb94d d5dec481 ac3796cc a3f65d93 2fba1d55 f5b117d9 384995f7 9c1a89ca d1dae953", + "50: 274e92c7 4040e298 3ce6e33b c61efe15 63c1fffd b193c811 51abbdc9 0ab9d71e e817a892 eab073cc", +] +`; + exports[`scroll behaviour snapshot > predicate web 1`] = ` [ - "0: 1ae1fc6f d2c3d67c e2050bb4 704e0476 1a3b7240 b3620452 ea6b67f3 a3c61d73 09968012 d9597113", + "0: 1ae1fc6f 54daaa9d e2050bb4 704e0476 1a3b7240 b3620452 ea6b67f3 a3c61d73 09968012 d9597113", "10: c449bc54 0a115668 20a0834c a6fbd596 bec39b4c 89be8bb7 dddeb6fa 9e4371d0 8134fb03 750041da", - "20: a7943819 4ca88661 48a2a876 9c67b567 48734ee1 b368ca7e e989bf83 ce41bea9 675f9530 e6c44365", - "30: f30525e8 7cab4af4 5d76b154 4c406d77 7417e967 7cbdb0c8 9eeab09f ea061649 82cf0b2e 65080957", + "20: a7943819 4ca88661 48a2a876 9c67b567 48734ee1 b368ca7e e989bf83 ce41bea9 675f9530 8caf71f9", + "30: f30525e8 7cab4af4 9250e3e4 4c406d77 7417e967 7cbdb0c8 9eeab09f ea061649 82cf0b2e 65080957", "40: 81e56faa a8750438 d5dec481 ac3796cc 1d279633 03a1759f 046db9c2 1e074853 a0c847de 842f2579", "50: 274e92c7 5b749586 ad467091 da0b01ac 9274c4ab e0f3944f f21db6d8 0ab9d71e d02c95f3 0ef2236c", ] @@ -165,6 +242,17 @@ exports[`scroll behaviour snapshot > smooth ios 1`] = ` ] `; +exports[`scroll behaviour snapshot > smooth ios27 1`] = ` +[ + "0: 413b0ced 0d2cec68 66c9821e b31939e8 3fda8ed3 550b7721 cc983b99 d2c2c7b5 206ab6a1 90c45313", + "10: f3a5a705 c07b2aec 44b57ffe 957921f5 1430db26 a18fdfbb e8af530c 6e2a63b1 4696c4a5 5eed2d84", + "20: e539345c dd0b4d07 65f8e69f 27b2d3ba 2a78f411 84e55162 557e9691 7e222fc0 d3a428bb 058cdf95", + "30: 0951be3d b2f398b4 484a6a70 2ecd0e38 27f1c0e4 dcb008be d63bc86d 93d54b36 08adf872 f2174e51", + "40: d547d43d 9d24f51f 1b2b8dbf e5550cbe 8b266de8 8b69617d 8081644f 41db25d8 a7995482 bdb8315c", + "50: 19abec1f 10f9e850 ae567d4e ba380d89 10026e87 8075a1b0 f6f82128 8657fa1a bd6410eb 0a52e4f8", +] +`; + exports[`scroll behaviour snapshot > smooth web 1`] = ` [ "0: 5d0e73cf e79e71f6 d9d14c97 3bfbda41 5d032c26 550b7721 c5e88040 d2c2c7b5 c021f057 efa64341", @@ -187,12 +275,23 @@ exports[`scroll behaviour snapshot > variable ios 1`] = ` ] `; +exports[`scroll behaviour snapshot > variable ios27 1`] = ` +[ + "0: fba1d210 fa631490 10ca2747 25957d9e a8be165f 9e3f53bf 64e27748 f8555140 83d2f832 dab62b3e", + "10: c3b629ee 229224d0 3749dd54 2ec01d94 3d9df721 35a5d702 e7808baa 807ecfad e733c82e 8cf6ab83", + "20: b4503328 0d525631 a2d78904 8981d9d5 dbf6ef00 54b5c799 bcda7c22 f6b55614 2f0d8117 33c5fefe", + "30: 1ab3f534 78feab0d 1d33a8fc 19c526ed 979df9b5 5ae0b4b8 66390281 ea5f1452 f324d93f b5ca5f73", + "40: 3618653b 4e68229d 3a0f5128 2eff8a63 c67dead9 4acfcd3d fb8201be 2bcbe71b 0c19eed3 a9a59579", + "50: cf093dbf 4e46224b cbf610b9 22b710f3 a770e40e 444c7e5b b7c7159e dfa18869 744f5055 55a140d2", +] +`; + exports[`scroll behaviour snapshot > variable web 1`] = ` [ "0: fba1d210 f7a493d1 c7edc065 7d2fa696 55db772c 700f9f0e ea581546 f8555140 08464bd4 a7ca5253", "10: 4ebba73b 4ade12df 3749dd54 e3be55c0 623fed56 35a5d702 9e405f75 00348828 e733c82e 8cf6ab83", "20: 767e932e 51a2636d a2d78904 7fff63a7 a2082489 54b5c799 6ec75f50 27c0ed86 15571a06 33c5fefe", - "30: 0485f0a2 404fd16b bf291888 81af3bc0 4f3ecf39 5ae0b4b8 c9fd1dd6 93ed46ad f324d93f 51d4859f", + "30: 0485f0a2 404fd16b a561815b 81af3bc0 4f3ecf39 5ae0b4b8 c9fd1dd6 93ed46ad f324d93f 51d4859f", "40: 75f87bba 0cb596ca 3d23fc8e 2eff8a63 a75e4c9c d7477845 fb8201be 2579843b e0c71981 160a8e9f", "50: cf093dbf 4e46224b f0d6fcaa ae490ce3 a1d38a54 2cef4545 42ed8d60 dfa18869 6b511c01 f39bf4c5", ] diff --git a/packages/virtual-core/tests/fixtures/ios27-flings.json b/packages/virtual-core/tests/fixtures/ios27-flings.json new file mode 100644 index 000000000..23d0da0b9 --- /dev/null +++ b/packages/virtual-core/tests/fixtures/ios27-flings.json @@ -0,0 +1,432 @@ +[ + { + "name": "one flick, glide to rest (reflick run)", + "samples": [ + [6492, 10401], + [6501, 10440], + [6517, 10477], + [6529, 10514], + [6538, 10549], + [6555, 10584], + [6567, 10618], + [6579, 10651], + [6588, 10683], + [6605, 10715], + [6613, 10745], + [6630, 10775], + [6642, 10804], + [6651, 10833], + [6667, 10860], + [6676, 10887], + [6693, 10914], + [6701, 10940], + [6718, 10965], + [6726, 10989], + [6744, 11013], + [6751, 11036], + [6768, 11081], + [6793, 11103], + [6801, 11124], + [6813, 11144], + [6831, 11164], + [6838, 11184], + [6856, 11203], + [6868, 11234], + [6890, 11257], + [6901, 11280], + [6923, 11313], + [6934, 11324], + [6951, 11344], + [6973, 11364], + [6984, 11384], + [7006, 11412], + [7018, 11421], + [7034, 11438], + [7056, 11455], + [7068, 11471], + [7084, 11495], + [7107, 11503], + [7118, 11517], + [7134, 11532], + [7157, 11552], + [7168, 11559], + [7184, 11572], + [7201, 11585], + [7218, 11597], + [7240, 11608], + [7251, 11620], + [7268, 11631], + [7285, 11647], + [7306, 11652], + [7318, 11662], + [7334, 11671], + [7351, 11681], + [7368, 11690], + [7384, 11698], + [7407, 11707], + [7418, 11715], + [7434, 11723], + [7451, 11734], + [7468, 11738], + [7484, 11745], + [7507, 11752], + [7518, 11758], + [7534, 11765], + [7551, 11771], + [7568, 11777], + [7584, 11783], + [7601, 11788], + [7618, 11794], + [7634, 11799], + [7657, 11804], + [7663, 11807], + [7676, 11810], + [7689, 11814], + [7701, 11817], + [7714, 11821], + [7726, 11824], + [7739, 11827], + [7751, 11830], + [7764, 11834], + [7776, 11837], + [7789, 11842], + [7814, 11845], + [7826, 11848], + [7844, 11850], + [7855, 11853], + [7864, 11855], + [7876, 11858], + [7889, 11860], + [7901, 11863], + [7920, 11867], + [7939, 11869], + [7951, 11871], + [7964, 11873], + [7976, 11875], + [7989, 11877], + [8001, 11879], + [8018, 11881], + [8035, 11884], + [8051, 11886], + [8068, 11888], + [8084, 11890], + [8101, 11892], + [8118, 11894], + [8134, 11896], + [8151, 11898], + [8176, 11900], + [8189, 11902], + [8201, 11904], + [8218, 11905], + [8235, 11907], + [8251, 11909], + [8268, 11910], + [8284, 11911], + [8301, 11913], + [8318, 11914], + [8334, 11916], + [8351, 11917], + [8368, 11918], + [8385, 11919], + [8401, 11920], + [8418, 11922], + [8435, 11923], + [8451, 11924], + [8468, 11925], + [8485, 11926], + [8501, 11927], + [8518, 11928], + [8551, 11929], + [8568, 11930], + [8585, 11931], + [8601, 11932], + [8635, 11933], + [8652, 11935], + [8701, 11936], + [8735, 11937], + [8751, 11938], + [8785, 11939], + [8818, 11940], + [8851, 11941], + [8885, 11942], + [8935, 11943], + [8968, 11944], + [9018, 11945], + [9068, 11946], + [9135, 11947], + [9201, 11948], + [9285, 11949], + [9372, 11950] + ] + }, + { + "name": "glide, flicked again the same way (back run)", + "samples": [ + [1600, 9576], + [1627, 9497], + [1651, 9423], + [1676, 9352], + [1688, 9318], + [1700, 9284], + [1722, 9252], + [1738, 9220], + [1750, 9189], + [1759, 9159], + [1776, 9130], + [1784, 9101], + [1801, 9073], + [1813, 9019], + [1839, 8993], + [1850, 8943], + [1872, 8919], + [1889, 8873], + [1897, 8851], + [1914, 8829], + [1922, 8808], + [1952, 8787], + [1959, 8767], + [1976, 8728], + [1984, 8709], + [1997, 8697], + [2009, 8673], + [2026, 8661], + [2048, 8639], + [2059, 8617], + [2081, 8595], + [2093, 8575], + [2109, 8555], + [2132, 8536], + [2143, 8517], + [2164, 8499], + [2176, 8482], + [2193, 8465], + [2215, 8449], + [2226, 8433], + [2243, 8418], + [2266, 8396], + [2276, 8389], + [2293, 8375], + [2310, 8355], + [2331, 8349], + [2343, 8330], + [2417, 8267], + [2435, 8178], + [2445, 8160], + [2468, 8125], + [2479, 8073], + [2492, 8022], + [2504, 7973], + [2517, 7924], + [2529, 7877] + ] + }, + { + "name": "glide, flicked again the same way (free run)", + "samples": [ + [5283, 10557], + [5296, 10626], + [5309, 10693], + [5321, 10758], + [5333, 10822], + [5346, 10885], + [5359, 11005], + [5384, 11063], + [5397, 11174], + [5422, 11228], + [5434, 11280], + [5446, 11331], + [5459, 11381], + [5471, 11430], + [5483, 11477], + [5496, 11523], + [5508, 11568], + [5521, 11612], + [5533, 11655], + [5543, 11697], + [5559, 11738], + [5571, 11778], + [5583, 11816], + [5596, 11854], + [5605, 11891], + [5622, 11927], + [5633, 11962], + [5643, 11996], + [5660, 12030], + [5671, 12062], + [5680, 12094], + [5698, 12125], + [5708, 12155], + [5718, 12185], + [5744, 12213], + [5773, 12303], + [5793, 12439], + [5807, 12493], + [5830, 12573] + ] + }, + { + "name": "glide almost at rest, flicked again (free run)", + "samples": [ + [8010, 15710], + [8026, 15711], + [8043, 15713], + [8060, 15714], + [8076, 15715], + [8093, 15716], + [8110, 15717], + [8126, 15718], + [8143, 15720], + [8160, 15722], + [8210, 15723], + [8226, 15724], + [8243, 15725], + [8260, 15726], + [8277, 15727], + [8293, 15728], + [8326, 15729], + [8343, 15730], + [8377, 15731], + [8393, 15732], + [8427, 15733], + [8443, 15734], + [8477, 15735], + [8510, 15736], + [8543, 15737], + [8577, 15738], + [8627, 15739], + [8672, 15740], + [8708, 15867], + [8728, 15987], + [8751, 16091], + [8763, 16192] + ] + }, + { + "name": "glide stopped by a tap 1", + "samples": [ + [17457, 9543], + [17467, 9501], + [17483, 9459], + [17495, 9419], + [17507, 9380], + [17520, 9342], + [17529, 9305], + [17546, 9268], + [17558, 9233], + [17569, 9198], + [17579, 9164], + [17596, 9132], + [17607, 9100], + [17620, 9068], + [17633, 9038], + [17642, 9008], + [17659, 8979], + [17667, 8951], + [17684, 8923], + [17695, 8896], + [17704, 8870], + [17721, 8844], + [17729, 8819], + [17747, 8795], + [17754, 8771], + [17772, 8748], + [17779, 8725], + [17792, 8703], + [17809, 8682], + [17817, 8661], + [17834, 8640], + [17842, 8620], + [17854, 8601], + [17876, 8570], + [17891, 8546], + [17904, 8522], + [17926, 8500], + [17942, 8478], + [17954, 8457], + [17976, 8436], + [17988, 8416], + [18009, 8407] + ] + }, + { + "name": "glide stopped by a tap 2", + "samples": [ + [5915, 9461], + [5927, 9414], + [5939, 9368], + [5952, 9323], + [5964, 9279], + [5976, 9237], + [5990, 9195], + [5999, 9155], + [6015, 9115], + [6027, 9076], + [6040, 9039], + [6049, 9002], + [6066, 8966], + [6077, 8932], + [6089, 8897], + [6099, 8864], + [6115, 8832], + [6126, 8800], + [6139, 8770], + [6152, 8740], + [6161, 8710], + [6178, 8682], + [6186, 8654], + [6203, 8627], + [6214, 8600], + [6227, 8574], + [6239, 8549], + [6249, 8525], + [6266, 8477], + [6286, 8454], + [6304, 8432], + [6311, 8411], + [6328, 8389], + [6348, 8369] + ] + }, + { + "name": "glide stopped by a tap 3", + "samples": [ + [3553, 9536], + [3565, 9498], + [3574, 9461], + [3591, 9425], + [3603, 9390], + [3612, 9356], + [3628, 9323], + [3640, 9290], + [3655, 9228], + [3677, 9197], + [3687, 9168], + [3703, 9139], + [3712, 9111], + [3730, 9084], + [3737, 9058], + [3755, 9032], + [3762, 9006], + [3779, 8982], + [3787, 8958], + [3805, 8934], + [3812, 8911], + [3829, 8889], + [3837, 8867], + [3853, 8846], + [3862, 8825], + [3874, 8805], + [3892, 8785], + [3899, 8766], + [3922, 8735], + [3933, 8711], + [3954, 8688], + [3966, 8666], + [3988, 8644], + [3999, 8623], + [4016, 8603], + [4038, 8584], + [4062, 8565] + ] + } +] diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 2a4c60965..7e72ad824 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -1,4 +1,4 @@ -import { expect, test, vi } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { Virtualizer, _resetIOSDetectionForTests, @@ -9,6 +9,7 @@ import { observeWindowOffset, windowScroll, } from '../src/index' +import flings from './fixtures/ios27-flings.json' import type { VirtualizerOptions } from '../src/index' test('should export the Virtualizer class', () => { @@ -1998,7 +1999,7 @@ test('lazy fast path: 1M-item list returns a sparse view, not an eagerly-allocat // ─── iOS momentum-safe scroll adjustments ─────────────────────────────────── -function withFakeIOSUserAgent(fn: () => T): T { +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. @@ -2006,11 +2007,18 @@ function withFakeIOSUserAgent(fn: () => T): T { 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() } } @@ -2064,8 +2072,11 @@ function makeIOSTouchVirtualizer( v, el, scrollToFn, - scroll: (offset: number, isScrolling: boolean) => - scrollCallback!(offset, isScrolling), + // 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: () => { @@ -2379,17 +2390,18 @@ test('iOS deferral: a positive delta at the end clamp still replays (growth abov }) }) -// ─── #1307: orderings the summed deferral gets wrong, and ones to keep ────── +// ─── #1307: orderings the hold has to get right, and ones to keep ─────────── // -// `test.fails` cases document current behaviour that #1307 is meant to fix: -// each one asserts the wanted outcome and fails today. The plain tests pin -// behaviour a redesign must not change by accident. 100 rows × 50px, 200px -// viewport, hand-driven rAF and scroll events. +// 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; start?: number } = {}) { +}: { ios?: boolean; ios27?: boolean; start?: number } = {}) { const rafCallbacks: Array = [] let now = 0 const timers = new Map void>() @@ -2416,7 +2428,10 @@ function makeDeferralHarness({ }) let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = null - const run = (fn: () => T) => (ios ? withFakeIOSUserAgent(fn) : fn()) + 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, @@ -2424,6 +2439,8 @@ function makeDeferralHarness({ 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) => { @@ -2444,6 +2461,8 @@ function makeDeferralHarness({ 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 @@ -2480,6 +2499,36 @@ function landedIndexScrollWithResizeAbove(ios = true) { 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') @@ -2535,25 +2584,54 @@ test('iOS deferral (#1307): scrollBy replacing an index scroll keeps the resize }) }) -test.fails( - 'iOS deferral (#1307): a correction is not replayed once the viewport is above the resized row', - () => { - 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) - }) - h.expireTail() - expect(h.rowTop(20)).toBe(0) - }, -) +// 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 @@ -2621,6 +2699,393 @@ test('iOS deferral: a deferred correction follows the viewport through momentum' 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(() => { @@ -2734,7 +3199,7 @@ test('iOS Phase 1: touchstart enters the `down` phase and ends a prior tail', () dispatchTouchEvent(el, 'touchend') // leaves a tail armed expect(v['_iosTouch']?.phase).toBe('tail') dispatchTouchEvent(el, 'touchstart') - expect(v['_iosTouch']).toEqual({ phase: 'down', dragged: false }) + expect(v['_iosTouch']).toEqual({ phase: 'down', moved: false }) }) }) @@ -2754,7 +3219,7 @@ test('iOS Phase 1: touchcancel clears touching the same as touchend, and cleanup expect(el._hasListener('touchcancel')).toBe(true) dispatchTouchEvent(el, 'touchstart') - expect(v['_iosTouch']).toEqual({ phase: 'down', dragged: false }) + expect(v['_iosTouch']).toEqual({ phase: 'down', moved: false }) dispatchTouchEvent(el, 'touchcancel') expect(v['_iosTouch']?.phase).toBe('tail') @@ -2884,6 +3349,91 @@ test('iOS Phase 1: momentum scroll events re-arm the post-touchend tail until th }) }) +// 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() @@ -2907,7 +3457,7 @@ test('iOS Phase 1: new touchstart during grace window cancels pending flush time dispatchTouchEvent(el, 'touchstart') // The pending timer must have been canceled. expect(timers.has(firstTimerId)).toBe(false) - expect(v['_iosTouch']).toEqual({ phase: 'down', dragged: false }) + expect(v['_iosTouch']).toEqual({ phase: 'down', moved: false }) }) }) @@ -2996,7 +3546,7 @@ test('iOS Phase 1: scroll-element swap mid-touch does not strand the `down` phas mockWindow, ) dispatchTouchEvent(holder.el, 'touchstart') - expect(v['_iosTouch']).toEqual({ phase: 'down', dragged: false }) + expect(v['_iosTouch']).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 @@ -3249,7 +3799,7 @@ test('iOS Phase 1: non-iOS still does NOT install touch state machine', () => { const { v, el } = makeIOSVirtualizerWithRealEl(scrollToFn, mockWindow) dispatchTouchEvent(el, 'touchstart') - expect(v['_iosTouch']).toEqual({ phase: 'down', dragged: false }) // touchstart still records (cheap) + expect(v['_iosTouch']).toEqual({ phase: 'down', moved: false }) // touchstart still records (cheap) dispatchTouchEvent(el, 'touchend') // Non-iOS path returns before arming a tail expect(v['_iosTouch']).toBeNull() @@ -3540,6 +4090,40 @@ test('self-write read-back rounded by the browser still does not latch a directi 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 }) @@ -5604,6 +6188,154 @@ test('scrollBy past the end does not leave the next scrollBy building on an unre 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() @@ -5672,29 +6404,48 @@ test('a smooth scrollToIndex switched to auto near the end is not cancelled by a expect(v.scrollState).toBeNull() }) -test.fails( - 'a scrollToIndex write clamped by a sizer that shrank below it still reaches the fresh target', - () => { - // Not seen in a real browser: the sizer has to re-render before the - // scroll event reports the write. 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 neither our write nor the target, so it - // reads as a user scroll and the index scroll stops 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) - }, -) +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 @@ -6160,7 +6911,7 @@ test('iOS touch: a row that stops touch propagation does not hide the gesture', v._willUpdate() row.dispatchEvent(new Event('touchstart', { bubbles: true })) - expect(v['_iosTouch']).toEqual({ phase: 'down', dragged: false }) + expect(v['_iosTouch']).toEqual({ phase: 'down', moved: false }) row.dispatchEvent(new Event('touchend', { bubbles: true })) expect(v['_iosTouch']?.phase).toBe('tail') diff --git a/packages/virtual-core/tests/prepend-hold.test.ts b/packages/virtual-core/tests/prepend-hold.test.ts index 11bd3f8fb..bddcb8d8e 100644 --- a/packages/virtual-core/tests/prepend-hold.test.ts +++ b/packages/virtual-core/tests/prepend-hold.test.ts @@ -410,36 +410,48 @@ test('desktop: a user scroll back into bounds during a hold releases it where th expect(control.el.pos).toBe(30) }) -// Known gap in the `_willUpdate` fallback: a scroll event between -// `setOptions` and `_willUpdate` overwrites the eagerly bumped `scrollOffset` -// with the element's offset before the hold is applied, and the fallback -// then adds the hold without restoring the layout offset. For one event the -// range is computed from an element offset while the rows are shifted by the -// hold: rows 0-3 render at -600 and the viewport is blank (106px). The next -// scroll event self-heals. Without the fallback the old stall would return, -// so it stays; this pins the glitch. -test.fails( - 'desktop: an overscroll event between the options change and the commit still holds', - () => { - const { h, control } = run(false, (h, prepend) => { - h.scroll(0, false) - // The options change finds the element writable (at rest, in bounds)... - h.prependOptions() - // ...but a bounce event lands before the adapter commits (`_willUpdate` - // runs inside the render this event triggers). - h.scroll(-94) - expect(h.writes).toEqual([]) - h.scroll(-40) - h.scroll(0) - h.readBack() - void prepend - }) - expect(h.writes).toEqual([PREPEND * ROW]) - expect(h.el.pos).toBe(PREPEND * ROW) - expect(control.el.pos).toBe(0) - expect(h.v.scrollOffset).toBe(PREPEND * ROW) - }, -) +// A scroll event between `setOptions` and `_willUpdate` reports the element, +// which is still the anchor delta behind the eagerly bumped `scrollOffset`. +// The handler counts the pending anchor like a hold, so the layout offset +// survives the event and the `_willUpdate` fallback holds a delta that is +// already in it. Before that, the event overwrote the layout offset, the +// fallback added the hold, and for one event the range was computed from an +// element offset while the rows were shifted: rows 0-3 at -600, a blank +// viewport. +test('desktop: an overscroll event between the options change and the commit still holds', () => { + const { h, control } = run(false, (h, prepend) => { + h.scroll(0, false) + // The options change finds the element writable (at rest, in bounds)... + h.prependOptions() + // ...but a bounce event lands before the adapter commits (`_willUpdate` + // runs inside the render this event triggers). + h.scroll(-94) + expect(h.writes).toEqual([]) + h.scroll(-40) + h.scroll(0) + h.readBack() + void prepend + }) + expect(h.writes).toEqual([PREPEND * ROW]) + expect(h.el.pos).toBe(PREPEND * ROW) + expect(control.el.pos).toBe(0) + expect(h.v.scrollOffset).toBe(PREPEND * ROW) +}) + +test('desktop: an in-bounds scroll event between the options change and the commit keeps the anchor', () => { + // Same window, no bounce: the user moves before the adapter commits, and + // the sync writes the moved position plus the prepend. + const { h, control } = run(false, (h, prepend) => { + h.scroll(40, false) + h.prependOptions() + h.scroll(70) + h.readBack() + void prepend + }) + expect(h.writes).toEqual([70 + PREPEND * ROW]) + expect(h.el.pos).toBe(70 + PREPEND * ROW) + expect(control.el.pos).toBe(70) +}) test('desktop: an above-viewport grow during a bottom bounce holds and lands once in bounds', () => { const max = (45 - 4) * ROW // 2050 diff --git a/packages/virtual-core/tests/scroll-classifier.test.ts b/packages/virtual-core/tests/scroll-classifier.test.ts new file mode 100644 index 000000000..2267440cc --- /dev/null +++ b/packages/virtual-core/tests/scroll-classifier.test.ts @@ -0,0 +1,398 @@ +import { describe, expect, test, vi } from 'vitest' +import { + classifyScrollEvent, + externalRetiresCommand, + flingCaught, + flingSpedUp, +} from '../src/scroll-classifier' +import type { + RetireInput, + ScrollEventInput, + ScrollSample, +} from '../src/scroll-classifier' +import flings from './fixtures/ios27-flings.json' + +const at = (input: Partial) => + classifyScrollEvent({ + offset: 1000, + isScrolling: true, + elementMax: null, + holdOffset: 0, + scrollOffset: 500, + pendingWrite: null, + clampedMaxAtWrite: null, + ...input, + }) + +describe('classifyScrollEvent', () => { + test('a scroll event with nothing pending is external', () => { + expect(at({})).toBe('external') + expect(at({ isScrolling: false })).toBe('external') + }) + + describe('spurious', () => { + test('scrolling at the held offset with no write pending', () => { + expect(at({ offset: 500, scrollOffset: 500 })).toBe('spurious') + }) + + test('needs an exact match', () => { + expect(at({ offset: 500.5, scrollOffset: 500 })).toBe('external') + }) + + test('a report at rest is not spurious', () => { + expect(at({ offset: 500, scrollOffset: 500, isScrolling: false })).toBe( + 'external', + ) + }) + + test('a pending write makes it a read-back instead', () => { + expect( + at({ + offset: 500, + scrollOffset: 500, + pendingWrite: { target: 500, smooth: false }, + }), + ).toBe('readBack') + }) + + test('beats a clamped echo at the same position', () => { + expect( + at({ offset: 500, scrollOffset: 500, clampedMaxAtWrite: 500 }), + ).toBe('spurious') + }) + }) + + describe('readBack', () => { + test('within 1.5px of the pending target, either side', () => { + const pendingWrite = { target: 1000, smooth: false } + expect(at({ offset: 1001.4, pendingWrite })).toBe('readBack') + expect(at({ offset: 998.6, pendingWrite })).toBe('readBack') + expect(at({ offset: 1001.5, pendingWrite })).toBe('external') + }) + + test('a smooth write reads back the same way', () => { + expect( + at({ offset: 1000, pendingWrite: { target: 1000, smooth: true } }), + ).toBe('readBack') + }) + + test('reported at rest', () => { + expect( + at({ + offset: 1000, + isScrolling: false, + pendingWrite: { target: 1000, smooth: false }, + }), + ).toBe('readBack') + }) + + test('beats a clamped read-back when the target is just past the max', () => { + expect( + at({ + offset: 1000.5, + elementMax: 1000, + pendingWrite: { target: 1001, smooth: false }, + }), + ).toBe('readBack') + }) + + test('beats a clamped echo near the max at write time', () => { + expect( + at({ + offset: 1000.4, + pendingWrite: { target: 1000, smooth: false }, + clampedMaxAtWrite: 1000, + }), + ).toBe('readBack') + }) + }) + + describe('clampedReadBack', () => { + const pendingWrite = { target: 1200, smooth: false } + + test('a non-smooth write past the max reporting at the max', () => { + expect(at({ offset: 1000, elementMax: 1000, pendingWrite })).toBe( + 'clampedReadBack', + ) + expect(at({ offset: 1001.4, elementMax: 1000, pendingWrite })).toBe( + 'clampedReadBack', + ) + expect(at({ offset: 1001.5, elementMax: 1000, pendingWrite })).toBe( + 'external', + ) + }) + + test('the max is shifted by a held correction', () => { + expect( + at({ offset: 1000, elementMax: 950, holdOffset: 50, pendingWrite }), + ).toBe('clampedReadBack') + expect( + at({ offset: 950, elementMax: 950, holdOffset: 50, pendingWrite }), + ).toBe('external') + }) + + test('the target must exceed the max by more than 0.5px', () => { + expect( + at({ + offset: 998.6, + elementMax: 1000, + pendingWrite: { target: 1000.5, smooth: false }, + }), + ).toBe('external') + }) + + test('not for a smooth write', () => { + expect( + at({ + offset: 1000, + elementMax: 1000, + pendingWrite: { target: 1200, smooth: true }, + }), + ).toBe('external') + }) + + test('not without the element max (a report at rest)', () => { + expect(at({ offset: 1000, elementMax: null, pendingWrite })).toBe( + 'external', + ) + }) + + test('beats a clamped echo at the same max', () => { + expect( + at({ + offset: 1000, + elementMax: 1000, + pendingWrite, + clampedMaxAtWrite: 1000, + }), + ).toBe('clampedReadBack') + }) + }) + + describe('clampedEcho', () => { + test('within 1.5px of the max at write time', () => { + expect(at({ offset: 1000, clampedMaxAtWrite: 1000 })).toBe('clampedEcho') + expect(at({ offset: 1001.4, clampedMaxAtWrite: 1000 })).toBe( + 'clampedEcho', + ) + expect(at({ offset: 1001.5, clampedMaxAtWrite: 1000 })).toBe('external') + }) + + test('with an unrelated write pending', () => { + expect( + at({ + offset: 1000, + pendingWrite: { target: 2000, smooth: true }, + clampedMaxAtWrite: 1000, + }), + ).toBe('clampedEcho') + }) + + test('reported at rest', () => { + expect( + at({ offset: 1000, isScrolling: false, clampedMaxAtWrite: 1000 }), + ).toBe('clampedEcho') + }) + }) +}) + +describe('externalRetiresCommand', () => { + const command: NonNullable = { + index: 20, + phase: 'instant', + lastTargetOffset: 1000, + } + const retires = (input: Partial, targetAt = 1000) => { + const target = vi.fn(() => targetAt) + const result = externalRetiresCommand({ + kind: 'external', + offset: 500, + command, + target, + ...input, + }) + return { result, targetCalls: target.mock.calls.length } + } + + test('an external scroll away from an index command retires it', () => { + expect(retires({})).toEqual({ result: true, targetCalls: 1 }) + }) + + test.each(['spurious', 'readBack', 'clampedReadBack'] as const)( + 'a %s event never retires, without computing the target', + (kind) => { + expect(retires({ kind })).toEqual({ result: false, targetCalls: 0 }) + }, + ) + + test('a clamped echo keeps the command, but only after the target check', () => { + expect(retires({ kind: 'clampedEcho' })).toEqual({ + result: false, + targetCalls: 1, + }) + }) + + test('no command, or an offset or end command, is not retired here', () => { + expect(retires({ command: null })).toEqual({ + result: false, + targetCalls: 0, + }) + expect(retires({ command: { ...command, index: null } })).toEqual({ + result: false, + targetCalls: 0, + }) + }) + + test.each(['queued', 'smooth', 'smoothFinishing'] as const)( + 'a %s command is not retired', + (phase) => { + expect(retires({ command: { ...command, phase } })).toEqual({ + result: false, + targetCalls: 0, + }) + }, + ) + + test('a landed smooth command is retired like an instant one', () => { + expect(retires({ command: { ...command, phase: 'smoothLanded' } })).toEqual( + { result: true, targetCalls: 1 }, + ) + }) + + test('an event at the last target keeps the command', () => { + expect(retires({ offset: 1000.5 })).toEqual({ + result: false, + targetCalls: 0, + }) + }) + + test('an event at the recomputed target keeps the command', () => { + expect(retires({ offset: 1200.5 }, 1200)).toEqual({ + result: false, + targetCalls: 1, + }) + }) +}) + +// Scroll events recorded on an iPhone (iOS 27.0.1) while a `scrollToIndex` +// waited for the fling (#1307): `[time ms, scrollTop]`. A second flick during +// a fling fires no touch events, so these are the only trace of it. +describe('flingSpedUp', () => { + // When `flingSpedUp` first holds, fed one event at a time with the last + // 200ms kept, as the scroll handler does. + const firstSpeedUp = (name: string) => { + const recorded = flings.find((fling) => fling.name === name)!.samples + let samples: Array = [] + for (const [t, offset] of recorded) { + samples = samples.filter((sample) => t! - sample.t <= 200) + samples.push({ t: t!, offset: offset! }) + if (flingSpedUp(samples)) return t + } + return null + } + + test('a fling alone never speeds up, down to rest', () => { + expect(firstSpeedUp('one flick, glide to rest (reflick run)')).toBeNull() + }) + + test.each([ + // [fling, when the second flick shows in the events, when it is seen] + ['glide, flicked again the same way (back run)', 2417, 2468], + ['glide, flicked again the same way (free run)', 5773, 5793], + ['glide almost at rest, flicked again (free run)', 8708, 8708], + ])('a second flick is seen within 60ms: %s', (name, flick, seen) => { + expect(firstSpeedUp(name)).toBe(seen) + expect(seen - flick).toBeLessThanOrEqual(60) + }) + + const samples = (...points: Array<[number, number]>) => + points.map(([t, offset]) => ({ t, offset })) + + test('needs two 40ms windows', () => { + expect(flingSpedUp(samples([0, 0], [20, 0], [40, 200]))).toBe(false) + expect(flingSpedUp(samples([0, 0], [40, 0], [80, 200]))).toBe(true) + }) + + test('a turn back is not a speed-up (handled as a reversal)', () => { + expect(flingSpedUp(samples([0, 0], [40, 40], [80, -200]))).toBe(false) + }) + + test('a 2x rise of less than 0.8px/ms is not one', () => { + expect(flingSpedUp(samples([0, 0], [40, 20], [80, 60]))).toBe(false) + expect(flingSpedUp(samples([0, 0], [40, 20], [80, 100]))).toBe(true) + }) + + test('a 1.7x rise, as from a merged frame, is not one', () => { + expect(flingSpedUp(samples([0, 0], [40, 80], [80, 216]))).toBe(false) + }) +}) + +describe('flingCaught', () => { + // The last 200ms of a recorded fling, as the scroll handler keeps them. + const tail = (name: string) => { + const recorded = flings.find((fling) => fling.name === name)!.samples + const [end] = recorded[recorded.length - 1]! + return recorded + .filter(([t]) => end! - t! <= 200) + .map(([t, offset]) => ({ t: t!, offset: offset! })) + } + + test.each([ + 'glide stopped by a tap 1', + 'glide stopped by a tap 2', + 'glide stopped by a tap 3', + ])('a fling stopped by a finger: %s', (name) => { + expect(flingCaught(tail(name))).toBe(true) + }) + + test('a fling that ends on its own is not caught', () => { + expect(flingCaught(tail('one flick, glide to rest (reflick run)'))).toBe( + false, + ) + }) + + test('needs a 40ms window', () => { + expect(flingCaught([])).toBe(false) + expect( + flingCaught([ + { t: 0, offset: 0 }, + { t: 20, offset: 40 }, + ]), + ).toBe(false) + expect( + flingCaught([ + { t: 0, offset: 0 }, + { t: 40, offset: 2 }, + ]), + ).toBe(true) + expect( + flingCaught([ + { t: 0, offset: 0 }, + { t: 40, offset: 1 }, + ]), + ).toBe(false) + }) + + // A fling at a steady speed for 100ms, 16ms a frame. + const atSpeed = (pxPerMs: number) => + Array.from({ length: 7 }, (_, i) => ({ + t: i * 16, + offset: Math.round(i * 16 * pxPerMs), + })) + + test('late taps (0.21–0.35px/ms, another iPhone) are caught', () => { + expect(flingCaught(atSpeed(0.21))).toBe(true) + expect(flingCaught(atSpeed(0.35))).toBe(true) + }) + + test('the slowest natural end recorded (0.015px/ms) is not', () => { + // 1px every 67ms, as the last frames of a fling report. + expect( + flingCaught([ + { t: 0, offset: 0 }, + { t: 67, offset: 1 }, + { t: 134, offset: 2 }, + ]), + ).toBe(false) + }) +}) diff --git a/packages/virtual-core/tests/scroll-trace.test.ts b/packages/virtual-core/tests/scroll-trace.test.ts index 3d36b00e3..1001c915d 100644 --- a/packages/virtual-core/tests/scroll-trace.test.ts +++ b/packages/virtual-core/tests/scroll-trace.test.ts @@ -10,7 +10,8 @@ type ScrollAlignment = NonNullable // animation steps, content resizes delivered by ResizeObserver, prepends, // user drags that rubber-band past an edge, touches, renders, frames, waits // that fire due timers, the reconcile safety valve) -// run against a simulated browser, on iOS and non-iOS, across a set of layout +// run against a simulated browser, on iOS 26 and earlier, iOS 27 and non-iOS, +// across a set of layout // options. Each sequence records only what a consumer can observe: every // write to the element, where it rests after each step, and what the adapter // renders. The traces are hashed per seed and compared with the committed @@ -23,8 +24,18 @@ type ScrollAlignment = NonNullable // SCROLL_TRACE=lanes/ios/17 pnpm vitest run tests/scroll-trace.test.ts // // Private state is deliberately not recorded: it is what refactors change. +// +// Platforms: `web` takes every write; `ios` (26 and earlier) ignores a write +// under a dragging finger and a write cancels momentum; `ios27` additionally +// runs a fling on its own path after touchend, which a write interrupts for +// one frame only (measured on the 27.0 simulator in #1307: `scrollToIndex` +// mid-fling shows its target, then the fling carries on and ends where it +// would have). On `ios27` the virtualizer sees `CSS.supports('overflow-anchor: +// auto')` as true, which is how it tells 27 apart. type Behavior = 'auto' | 'smooth' +type Platform = 'web' | 'ios' | 'ios27' +const PLATFORMS: Array = ['web', 'ios', 'ios27'] type Step = | { t: 'index'; i: number; align: ScrollAlignment; behavior: Behavior } @@ -178,18 +189,28 @@ function makeSteps(seed: number, smooth: number): Array { return steps } -function runTrace(config: Config, ios: boolean, seed: number): Array { - if (ios) { +function runTrace( + config: Config, + platform: Platform, + seed: number, +): Array { + if (platform !== 'web') { Object.defineProperty(navigator, 'userAgent', { value: IOS_UA, configurable: true, }) } + if (platform === 'ios27') { + ;(globalThis as any).CSS = { + supports: (query: string) => query === 'overflow-anchor: auto', + } + } _resetIOSDetectionForTests() try { - return simulate(config, ios, makeSteps(seed, config.smooth ?? 0.3)) + return simulate(config, platform, makeSteps(seed, config.smooth ?? 0.3)) } finally { delete (navigator as any).userAgent + delete (globalThis as any).CSS _resetIOSDetectionForTests() } } @@ -202,9 +223,11 @@ type Row = { function simulate( config: Config, - ios: boolean, + platform: Platform, steps: Array, ): Array { + const ios = platform !== 'web' + const ios27 = platform === 'ios27' const options = config.options ?? {} const rafs: Array = [] const timers = new Map void; due: number }>() @@ -295,6 +318,11 @@ function simulate( let touching = false // The finger has moved the content since it touched down. let dragged = false + // iOS 27: a fling after a drag runs on its own path until it rests or a + // finger stops it. A write shows until the next fling frame, which puts the + // element back on the path; a smooth write never shows at all. + let momentum = false + let pathPos = 0 // A native smooth scroll in flight, and a frame of it computed before an // instant write replaced it, which WebKit can still apply and report. let smoothTarget: number | null = null @@ -321,6 +349,7 @@ function simulate( // A write that lands is reported like any other move. stalled = false if (behavior === 'smooth') { + if (momentum) return smoothTarget = clamp(to) lateFrame = null } else { @@ -473,15 +502,20 @@ function simulate( extra = render() break case 'event': + // A fling frame reports the path, not a write made since. + if (momentum) el.pos = pathPos spring() + if (momentum) pathPos = el.pos scroll(true) break case 'idle': + if (momentum) el.pos = pathPos + momentum = false el.pos = clamp(el.pos) scroll(false) break case 'anim': - if (stalled) break + if (stalled || momentum) break if (lateFrame !== null) { el.pos = clamp(lateFrame) lateFrame = null @@ -503,25 +537,32 @@ function simulate( if (touching) dragged = true // The user scrolls: a stalled bounce reports again. stalled = false - const to = el.pos + step.d + // After a touch on iOS 27 this is a fling frame, on its own path. + const to = (momentum ? pathPos : el.pos) + step.d el.pos = to < 0 ? -Math.min(Math.round(-to / 3), 100) : to > max() ? max() + Math.min(Math.round((to - max()) / 3), 100) : to + if (momentum) pathPos = el.pos scroll(true) break } case 'touchstart': touching = true dragged = false + momentum = false smoothTarget = null lateFrame = null fire('touchstart') break case 'touchend': touching = false + if (ios27 && dragged) { + momentum = true + pathPos = el.pos + } fire('touchend') break case 'touchcancel': @@ -549,6 +590,11 @@ function simulate( touching = false fire('touchend') } + // A fling in progress ends where its path is. + if (momentum) { + el.pos = pathPos + momentum = false + } lateFrame = null if (smoothTarget !== null) { el.pos = smoothTarget @@ -574,18 +620,18 @@ function simulate( return trace } -const key = (config: Config, ios: boolean, seed: number) => - `${config.name}/${ios ? 'ios' : 'web'}/${seed}` +const key = (config: Config, platform: Platform, seed: number) => + `${config.name}/${platform}/${seed}` const only = process.env.SCROLL_TRACE describe.skipIf(!!only)('scroll behaviour snapshot', () => { for (const config of configs) { - for (const ios of [false, true]) { - test(`${config.name} ${ios ? 'ios' : 'web'}`, () => { + for (const platform of PLATFORMS) { + test(`${config.name} ${platform}`, () => { const hashes: Array = [] for (let seed = 0; seed < SEEDS; seed++) { - hashes.push(hash(runTrace(config, ios, seed).join('\n'))) + hashes.push(hash(runTrace(config, platform, seed).join('\n'))) } // Ten per row so a diff points at the seeds that moved. const rows: Array = [] @@ -603,10 +649,13 @@ describe.runIf(!!only)('scroll trace', () => { const [name, platform, seed] = only!.split('/') const config = configs.find((c) => c.name === name) if (!config) throw new Error(`unknown config ${name}`) - const trace = runTrace(config, platform === 'ios', Number(seed)) + if (!PLATFORMS.includes(platform as Platform)) { + throw new Error(`unknown platform ${platform}`) + } + const trace = runTrace(config, platform as Platform, Number(seed)) // Vitest captures console output; write straight to stderr. process.stderr.write( - `${key(config, platform === 'ios', Number(seed))}\n${trace.join('\n')}\n`, + `${key(config, platform as Platform, Number(seed))}\n${trace.join('\n')}\n`, ) }) }) From 77cb05eef131699bcef0f0a5877b60503e54a366 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Thu, 15 Oct 2026 08:33:23 +0200 Subject: [PATCH 18/46] refactor(virtual-core): extract the iOS touch gesture into IosGesture (#1325) Moves the iOS touch gesture state out of the Virtualizer into an internal IosGesture class. The late-fling window is now a gesture phase. A waiting command keeps its fling samples. One fix: a scroll-element change no longer carries over a pending late-fling window. Co-Authored-By: Claude Opus 5.5 --- .changeset/ios-gesture.md | 5 + packages/virtual-core/src/index.ts | 348 +++++------------- packages/virtual-core/src/ios-gesture.ts | 229 ++++++++++++ packages/virtual-core/tests/index.test.ts | 90 ++--- .../virtual-core/tests/ios-gesture.test.ts | 343 +++++++++++++++++ 5 files changed, 720 insertions(+), 295 deletions(-) create mode 100644 .changeset/ios-gesture.md create mode 100644 packages/virtual-core/src/ios-gesture.ts create mode 100644 packages/virtual-core/tests/ios-gesture.test.ts diff --git a/.changeset/ios-gesture.md b/.changeset/ios-gesture.md new file mode 100644 index 000000000..3da4d8c33 --- /dev/null +++ b/.changeset/ios-gesture.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +refactor(virtual-core): move the iOS touch gesture tracking into its own internal module. One fix along the way: changing the scroll element no longer carries over a pending iOS late-fling window, which could defer corrections on the new element for 150ms. diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 50b2e9003..275f0f653 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -9,48 +9,14 @@ import { flingCaught, flingSpedUp, } from './scroll-classifier' +import { + IosGesture, + iosMomentumOverridesWrites, + isIOSWebKit, +} from './ios-gesture' import type { ScrollPhase, ScrollSample } from './scroll-classifier' -// Browser-aware iOS detection. Programmatic `scrollTo`/`scrollTop` writes -// during a momentum-scroll cancel the momentum on iOS WebKit, so we defer -// scroll-position adjustments triggered by mid-scroll resizes until the -// scroll settles. SSR-safe (returns false when navigator is unavailable). -let _isIOSResult: boolean | undefined -const isIOSWebKit = (): boolean => { - if (_isIOSResult !== undefined) return _isIOSResult - if (typeof navigator === 'undefined') return (_isIOSResult = false) - if (/iP(hone|od|ad)/.test(navigator.userAgent)) return (_isIOSResult = true) - // iPadOS 13+ reports as MacIntel; touch-points distinguishes it from desktop. - const mtp = (navigator as Navigator & { maxTouchPoints?: number }) - .maxTouchPoints - return (_isIOSResult = - navigator.platform === 'MacIntel' && mtp !== undefined && mtp > 0) -} - -// iOS 27 changed what a scrollTop write does to a fling: it no longer stops -// the momentum, the fling carries on along its own path and the write shows -// for one frame (measured on the 27.0 simulator in #1307, device check still -// open). WebKit shipped `overflow-anchor` in the same release, so that is -// the feature check; iOS 26 and earlier report false. Cached like the iOS -// check; SSR-safe. -let _iosMomentumOverridesWritesResult: boolean | undefined -const iosMomentumOverridesWrites = (): boolean => { - if (_iosMomentumOverridesWritesResult !== undefined) { - return _iosMomentumOverridesWritesResult - } - return (_iosMomentumOverridesWritesResult = - isIOSWebKit() && - typeof CSS !== 'undefined' && - typeof CSS.supports === 'function' && - CSS.supports('overflow-anchor: auto')) -} - -// Test hook: reset the iOS detection caches. Not exported. -export const _resetIOSDetectionForTests = () => { - _isIOSResult = undefined - _iosMomentumOverridesWritesResult = undefined -} - +export { _resetIOSDetectionForTests } from './ios-gesture' export { approxEqual, debounce, memo, notUndefined } from './utils' export type { NoInfer, PartialKeys } from './utils' @@ -205,14 +171,6 @@ const addEventListenerOptions = { passive: true, } -// Touch listeners on the scroll element run in the capture phase, so a row -// that stops a touch event's propagation cannot hide the gesture from the -// iOS deferral. -const touchListenerOptions = { - capture: true, - passive: true, -} - export const observeWindowRect = ( instance: Virtualizer, cb: (rect: Rect) => void, @@ -461,6 +419,11 @@ type ScrollState = { // settling stableFrames: number phase: ScrollPhase + + // A `queued` command's view of the fling it waits for: the scroll events + // since it was queued, at most the last 200ms (`flingSpedUp`, + // `flingCaught`). Read only while `queued`. + samples?: Array } const initialScrollPhase = ( @@ -585,38 +548,55 @@ export class Virtualizer< // (`followOnAppend does not follow 'trim only'`; reading the element at // write time still moves 49 of 1080 snapshot seeds). private _reportedOverscroll = false - // The touch gesture in progress. iOS WebKit ignores a scrollTop write - // while a finger drags the content (one resting on it does not stop the - // write) and cancels momentum on one after it, so adjustments are deferred - // through the whole gesture: `down` from touchstart to touchend/ - // touchcancel, then `tail` while momentum runs. iOS fires no touch events - // during momentum, so every scroll event re-arms the tail and it closes - // ~150ms after the last one. Null between gestures, and after touchend on - // non-iOS, which arms no tail. The tail record owns its timer: - // `_endIosTouch` is the only way to drop either, so a cleared record never - // leaves a timer behind, and a live timer always has its record. - // - // `moved`: the content moved under or after the finger other than by our - // own write's read-back. While `down`, a scroll event that changed the - // position sets it: the finger is dragging. The tail inherits it from the - // drag, and any other scroll event re-arming the tail sets it: a fling - // can be running. A tap's tail has it unset. - private _iosTouch: - | { phase: 'down'; moved: boolean } - | { phase: 'tail'; moved: boolean; timerId: number } - | null = null + // The touch gesture in progress (see `IosGesture`). Its tail closing on + // its own makes a write possible again: the last momentum frame may have + // come before the timer. The hold is released first, so a command that + // waited for the tail is written over rows already back in layout + // coordinates. + private _iosGesture = new IosGesture({ + getWindow: () => this.targetWindow, + now: () => this.now(), + // The end of a fling can move less than a pixel per scroll event, so + // events can stop for 150ms while the fling is still running (seen on + // an iPhone, 27.0.1). A command waiting for it would then be written + // into the fling and overridden. The element having moved since the + // last event means it has not ended: wait again. Not while our own + // write has yet to report: that moved it. + tailStillMoving: () => { + const state = this.scrollState + if (state?.phase !== 'queued' || this.pendingWrite !== null) { + return false + } + const now = this._readElementOffset() + if ( + now === null || + this._lastElementOffset === null || + Math.abs(now - this._lastElementOffset) < 1 + ) { + return false + } + this._lastElementOffset = now + // It moved with no event: a sample, so the speed `flingCaught` reads + // when it stops is not the one from before the silence. + state.samples = [...(state.samples ?? []), { t: this.now(), offset: now }] + return true + }, + onTailClosed: () => { + // A fling that stopped at speed was caught by a finger that is still + // down, with no touch events: the user has taken over, as with a + // drag, and iOS would undo the write anyway. + const state = this.scrollState + if (state?.phase === 'queued' && flingCaught(state.samples ?? [])) { + this.scrollState = null + } + this._flushHoldIfReady(true) + this._writeAwaitingCommand() + }, + }) // The last position the scroll handler was given, in the element's own - // coordinates. The tail timer compares it with the element (see - // `_openIosTail`). + // coordinates. The tail compares it with the element (`tailStillMoving` + // above). private _lastElementOffset: number | null = null - // The scroll events since a command started waiting for the tail, at - // most the last 200ms, for `flingSpedUp`. - private _flingSamples: Array = [] - // A drag ended and its fling has not reported yet: 'awaiting' from the - // touchend until a scroll event re-arms the tail; once the tail closes - // first, the time until which the fling's first frame may still come and - // reopen it (see the scroll handler). - private _lateFling: 'awaiting' | number | null = null // Our last write to the element, from `_scrollToOffset` until the next // scroll event reports it (the only place it is cleared). Until then // `scrollOffset` may still hold the old position. @@ -1028,12 +1008,12 @@ export class Virtualizer< // capture), so the new element never reports it. Carrying the state // over would replay a stale deferred delta on the new element's first // flush, and a cleanup that lands mid-touch or inside the post-touchend - // grace window would strand `_iosTouch` (the listeners that end it were + // grace window would strand the gesture (the listeners that end it were // just removed), deferring every adjustment on the new element until // its next touch cycle. this._holdOffset = 0 this._reportedOverscroll = false - this._endIosTouch() + // (The gesture was ended with its listeners, in the unsubs above.) this._clampedAdjustment = null // A write to the old element will never be read back. this.pendingWrite = null @@ -1146,73 +1126,53 @@ export class Virtualizer< this.scrollOffset = offset this.isScrolling = isScrolling - // Momentum after touchend fires a scroll event per frame but no - // touch events, so each one re-arms the tail to span the fling. - // Only an open tail is extended: a scroll event with no touch before - // it (our own write's read-back) never opens one. - if (isScrolling && this._iosTouch?.phase === 'tail') { - this._lateFling = null - this._openIosTail(this._iosTouch.moved || kind !== 'readBack') - } else if (typeof this._lateFling === 'number') { - // 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. It reopens it, within 1s of a drag only: - // other scroll events with no touch before them must not defer - // corrections (#1250). Our smooth animation's frames are not one. - if (this.now() > this._lateFling) { - this._lateFling = null - } else if ( - kind === 'external' && - isScrolling && - offset !== prevOffset && - this._iosTouch === null && - this.scrollState?.phase !== 'smooth' && - this.scrollState?.phase !== 'smoothFinishing' - ) { - this._lateFling = null - this._openIosTail(true) - } - } // A finger that lands on a fling fires no touch events (#1307), so // while a command waits for the fling, the scroll events are all // that show the user taking over: the scroll turns back, or speeds // up (`flingSpedUp`). Momentum does neither inside the scroll // range; a bounce does both, so no part of one counts. + const queued = this.scrollState if ( - this.scrollState?.phase === 'queued' && + queued?.phase === 'queued' && kind === 'external' && isScrolling ) { if (wasOverscrolled || this._reportedOverscroll) { - this._flingSamples = [] + queued.samples = [] } else { const t = this.now() - this._flingSamples = this._flingSamples.filter( - (sample) => t - sample.t <= 200, - ) - this._flingSamples.push({ t, offset: elementOffset }) + queued.samples = [ + ...(queued.samples ?? []).filter( + (sample) => t - sample.t <= 200, + ), + { t, offset: elementOffset }, + ] if ( (prevDirection !== null && this.scrollDirection !== prevDirection) || - flingSpedUp(this._flingSamples) + flingSpedUp(queued.samples) ) { this.scrollState = null } } } - // A finger that moved the content is dragging. Our own write landing - // under a resting finger is not, and neither is a report at rest - // that leaves the position where it was. if ( - kind !== 'readBack' && - isScrolling && - offset !== prevOffset && - this._iosTouch?.phase === 'down' - ) { - this._iosTouch.moved = true + this._iosGesture.onScroll({ + isScrolling, + ownReadBack: kind === 'readBack', + moved: offset !== prevOffset, + // Neither our write's read-back nor a frame of our smooth + // animation: the user or a fling moved it. + external: + kind === 'external' && + this.scrollState?.phase !== 'smooth' && + this.scrollState?.phase !== 'smoothFinishing', + }) && // The user scrolls: a command still waiting for the tail is // theirs to override, like a written one would be. - if (this.scrollState?.phase === 'queued') this.scrollState = null + this.scrollState?.phase === 'queued' + ) { + this.scrollState = null } // A held correction is written once a write is possible. @@ -1225,54 +1185,11 @@ export class Virtualizer< }), ) - // Touch event listeners (iOS-aware deferral). We attach unconditionally - // — the listeners are passive and cheap; on non-touch devices they - // simply never fire. The gating by isIOSWebKit() lives in resizeItem - // and _canWriteScroll so we only burn the path on iOS. + // Touch event listeners (iOS-aware deferral). if ('addEventListener' in this.scrollElement) { - const scrollEl = this.scrollElement as unknown as EventTarget - const onTouchStart = () => { - // A finger back down inside the grace window cancels its tail. - this._endIosTouch() - this._iosTouch = { phase: 'down', moved: false } - } - const onTouchEnd = () => { - const moved = this._iosTouch?.phase === 'down' && this._iosTouch.moved - this._endIosTouch() - this._lateFling = moved ? 'awaiting' : null - this._openIosTail(moved) - } - scrollEl.addEventListener( - 'touchstart', - onTouchStart, - touchListenerOptions, - ) - scrollEl.addEventListener('touchend', onTouchEnd, touchListenerOptions) - // Same handler as touchend: iOS can end a touch with touchcancel - // (system gesture, interrupted scroll) and never fire touchend. - scrollEl.addEventListener( - 'touchcancel', - onTouchEnd, - touchListenerOptions, + this.unsubs.push( + this._iosGesture.listen(this.scrollElement as unknown as EventTarget), ) - this.unsubs.push(() => { - scrollEl.removeEventListener( - 'touchstart', - onTouchStart, - touchListenerOptions, - ) - scrollEl.removeEventListener( - 'touchend', - onTouchEnd, - touchListenerOptions, - ) - scrollEl.removeEventListener( - 'touchcancel', - onTouchEnd, - touchListenerOptions, - ) - this._endIosTouch() - }) } this._scrollToOffset(this.getScrollOffset()) @@ -1374,70 +1291,6 @@ export class Virtualizer< } } - // A touch gesture owns the scroll (see `_iosTouch`): on iOS a write now - // would be ignored or would cancel the momentum, so adjustments wait for - // the flush below. `isScrolling` is not part of it: any scroll event sets - // it, including the read-back of our own write, and a programmatic scroll - // has no momentum to protect (#1250). - private _iosInMotion = () => this._iosTouch !== null - - // A finger is dragging the content, so iOS ignores our writes and no - // read-back will come. A resting finger does not: the write lands. - private _iosDragging = () => - this._iosTouch?.phase === 'down' && this._iosTouch.moved - - // (Re)open the tail after the finger lifts, closing ~150ms after the last - // touchend or momentum frame. On non-iOS there is nothing to protect. - private _openIosTail = (moved: boolean) => { - if (!isIOSWebKit() || this.targetWindow == null) return - this._endIosTouch() - const tail = { - phase: 'tail' as const, - moved, - timerId: this.targetWindow.setTimeout(() => { - // The end of a fling can move less than a pixel per scroll event, so - // events can stop for 150ms while the fling is still running (seen - // on an iPhone, 27.0.1). A command waiting for it would then be - // written into the fling and overridden. The element having moved - // since the last event means it has not ended: wait again. Not while - // our own write has yet to report: that moved it. - const now = this._readElementOffset() - if ( - this._iosTouch === tail && - this.scrollState?.phase === 'queued' && - this.pendingWrite === null && - now !== null && - this._lastElementOffset !== null && - Math.abs(now - this._lastElementOffset) >= 1 - ) { - this._lastElementOffset = now - // It moved with no event: a sample, so the speed `flingCaught` - // reads when it stops is not the one from before the silence. - this._flingSamples.push({ t: this.now(), offset: now }) - this._openIosTail(tail.moved) - return - } - if (this._iosTouch === tail) this._iosTouch = null - if (this._lateFling === 'awaiting') this._lateFling = this.now() + 1000 - // A fling that stopped at speed was caught by a finger that is still - // down, with no touch events: the user has taken over, as with a - // drag, and iOS would undo the write anyway. - if ( - this.scrollState?.phase === 'queued' && - flingCaught(this._flingSamples) - ) { - this.scrollState = null - } - // The last momentum frame may have come before the timer. The hold - // is released first, so a command that waited for the tail is - // written over rows already back in layout coordinates. - this._flushHoldIfReady(true) - this._writeAwaitingCommand() - }, 150), - } - this._iosTouch = tail - } - // Write the command that `_startScroll` left waiting for the momentum // tail, against the layout as it is now (rows measured during the fling // have moved an index target). @@ -1476,15 +1329,8 @@ export class Virtualizer< return null } - private _endIosTouch = () => { - if (this._iosTouch?.phase === 'tail' && this.targetWindow != null) { - this.targetWindow.clearTimeout(this._iosTouch.timerId) - } - this._iosTouch = null - } - // Whether a scrollTop write made now would reach the element and stay: - // - not on iOS while a touch gesture owns the scroll (`_iosTouch`): a + // - not on iOS while a touch gesture owns the scroll (`IosGesture`): a // dragging finger ignores the write, momentum is cancelled by it on // iOS 26 and earlier and overrides it within a frame on 27 (#884, // #1307), so a correction is held through the tail on every version; @@ -1494,7 +1340,7 @@ export class Virtualizer< // A write that is unwanted for another reason (a travelling smooth scroll // owns the position) is a separate check at the call sites. private _canWriteScroll = () => - !(isIOSWebKit() && this._iosInMotion()) && !this._reportedOverscroll + !(isIOSWebKit() && this._iosGesture.inMotion()) && !this._reportedOverscroll // Write a held correction once a write is possible, from the scroll // handler, the touch tail timer, `_willUpdate` and a resize notify. The @@ -2493,13 +2339,15 @@ export class Virtualizer< behavior: ScrollBehavior, kind: 'absolute' | 'relative', ) { - if (kind === 'absolute' && this._iosTouch?.phase === 'tail') { - if (this._iosTouch.moved && iosMomentumOverridesWrites()) { + const tail = this._iosGesture.tail() + if (kind === 'absolute' && tail !== null) { + if (tail === 'fling' && iosMomentumOverridesWrites()) { // iOS 27: a write now shows for one frame and the fling carries on // along its own path, so the command would be lost (#1307, step 3). // Leave the tail open and write when it closes, ~150ms after the - // last fling frame; a drag before then cancels the command (see the - // scroll handler), a tap does not. A relative command (`scrollBy`) + // last fling frame; the user taking over before then (a drag, a + // second flick, a finger stopping the fling) cancels the command + // (see the scroll handler and `onTailClosed`). A relative command (`scrollBy`) // is not postponed: its target is taken from the position at the // call, which the fling leaves behind either way. this.scrollState = { @@ -2509,8 +2357,8 @@ export class Virtualizer< stableFrames: 0, phase: 'queued', behavior, + samples: [], } - this._flingSamples = [] this.scheduleScrollReconcile() return } @@ -2519,7 +2367,7 @@ export class Virtualizer< // a tap handler inside the tail (#1250). On iOS 26 and earlier the // write stops the momentum. With a finger down the user owns the // scroll: leave it. - this._endIosTouch() + this._iosGesture.end() } this.scrollState = { ...target, @@ -2674,7 +2522,7 @@ export class Virtualizer< private _awaitingCommandReadBack = () => { const writtenOffset = this.getCommandWrittenOffset() return ( - !(isIOSWebKit() && this._iosDragging()) && + !(isIOSWebKit() && this._iosGesture.dragging()) && writtenOffset !== null && !approxEqual(writtenOffset, this.getScrollOffset()) ) diff --git a/packages/virtual-core/src/ios-gesture.ts b/packages/virtual-core/src/ios-gesture.ts new file mode 100644 index 000000000..e33787e1b --- /dev/null +++ b/packages/virtual-core/src/ios-gesture.ts @@ -0,0 +1,229 @@ +// Browser-aware iOS detection. Programmatic `scrollTo`/`scrollTop` writes +// during a momentum-scroll cancel the momentum on iOS WebKit, so we defer +// scroll-position adjustments triggered by mid-scroll resizes until the +// scroll settles. SSR-safe (returns false when navigator is unavailable). +let _isIOSResult: boolean | undefined +export const isIOSWebKit = (): boolean => { + if (_isIOSResult !== undefined) return _isIOSResult + if (typeof navigator === 'undefined') return (_isIOSResult = false) + if (/iP(hone|od|ad)/.test(navigator.userAgent)) return (_isIOSResult = true) + // iPadOS 13+ reports as MacIntel; touch-points distinguishes it from desktop. + const mtp = (navigator as Navigator & { maxTouchPoints?: number }) + .maxTouchPoints + return (_isIOSResult = + navigator.platform === 'MacIntel' && mtp !== undefined && mtp > 0) +} + +// iOS 27 changed what a scrollTop write does to a fling: it no longer stops +// the momentum, the fling carries on along its own path and the write shows +// for one frame (measured on the 27.0 simulator in #1307, device check still +// open). WebKit shipped `overflow-anchor` in the same release, so that is +// the feature check; iOS 26 and earlier report false. Cached like the iOS +// check; SSR-safe. +let _iosMomentumOverridesWritesResult: boolean | undefined +export const iosMomentumOverridesWrites = (): boolean => { + if (_iosMomentumOverridesWritesResult !== undefined) { + return _iosMomentumOverridesWritesResult + } + return (_iosMomentumOverridesWritesResult = + isIOSWebKit() && + typeof CSS !== 'undefined' && + typeof CSS.supports === 'function' && + CSS.supports('overflow-anchor: auto')) +} + +// Test hook: reset the iOS detection caches. Not exported. +export const _resetIOSDetectionForTests = () => { + _isIOSResult = undefined + _iosMomentumOverridesWritesResult = undefined +} + +// Touch listeners on the scroll element run in the capture phase, so a row +// that stops a touch event's propagation cannot hide the gesture from the +// iOS deferral. +const touchListenerOptions = { + capture: true, + passive: true, +} + +// The touch gesture in progress. iOS WebKit ignores a scrollTop write while +// a finger drags the content (one resting on it does not stop the write) +// and cancels momentum on one after it, so adjustments are deferred through +// the whole gesture: `down` from touchstart to touchend/touchcancel, then +// `tail` while momentum runs. iOS fires no touch events during momentum, so +// every scroll event re-arms the tail and it closes ~150ms after the last +// one. Null between gestures, and after touchend on non-iOS, which arms no +// tail. The tail record owns its timer: every replacement of the state goes +// through `end`, which clears it, so a live timer always has its record. +// +// `moved`: the content moved under or after the finger other than by our +// own write's read-back. While `down`, a scroll event that changed the +// position sets it: the finger is dragging. The tail inherits it from the +// drag, and any other scroll event re-arming the tail sets it: a fling can +// be running. A tap's tail has it unset. +// +// `awaitingFling`: the tail follows a drag whose fling has not reported +// yet. The first frame of a fling can come 150–250ms after touchend when +// the main thread is busy (27.0 simulator, #1307); if the tail closes +// first, `lateFling` waits until `until` for that frame to reopen it. It +// defers nothing: other scroll events with no touch before them must not +// defer corrections (#1250). +type IosTouch = + | { phase: 'down'; moved: boolean } + | { phase: 'tail'; moved: boolean; awaitingFling: boolean; timerId: number } + | { phase: 'lateFling'; until: number } + | null + +type TimerWindow = Pick + +export class IosGesture { + private _touch: IosTouch = null + + constructor( + private readonly host: { + // The scroll element's window, which runs the tail timer; null when + // there is no element. + getWindow: () => TimerWindow | null + now: () => number + // Asked when the tail's timer fires: true keeps it open another + // 150ms (the fling still moves between scroll events). + tailStillMoving: () => boolean + // The tail closed on its own: a write is possible again. + onTailClosed: () => void + }, + ) {} + + get touch(): IosTouch { + return this._touch + } + + // A touch gesture owns the scroll: on iOS a write now would be ignored or + // would cancel the momentum, so adjustments wait for the tail to close. + // `isScrolling` is not part of it: any scroll event sets it, including + // the read-back of our own write, and a programmatic scroll has no + // momentum to protect (#1250). + inMotion = () => this._touch !== null && this._touch.phase !== 'lateFling' + + // A finger is dragging the content, so iOS ignores our writes and no + // read-back will come. A resting finger does not: the write lands. + dragging = () => this._touch?.phase === 'down' && this._touch.moved + + // The open tail, if any: after a tap, or after a drag or fling. On iOS 27 + // a fling's momentum overrides a write within a frame (see + // `iosMomentumOverridesWrites`). + tail = (): 'tap' | 'fling' | null => + this._touch?.phase === 'tail' ? (this._touch.moved ? 'fling' : 'tap') : null + + // A scroll event reached the handler. `ownReadBack`: it is our own + // write's read-back; `moved`: it changed the layout position; `external`: + // the user or a fling moved it (not our write nor our smooth animation). + // Returns whether it shows a finger dragging the content. + onScroll = ({ + isScrolling, + ownReadBack, + moved, + external, + }: { + isScrolling: boolean + ownReadBack: boolean + moved: boolean + external: boolean + }): boolean => { + // Momentum after touchend fires a scroll event per frame but no touch + // events, so each one re-arms the tail to span the fling. Only an open + // tail is extended: a scroll event with no touch before it (our own + // write's read-back) never opens one. + if (isScrolling && this._touch?.phase === 'tail') { + this._openTail(this._touch.moved || !ownReadBack, false) + } else if (this._touch?.phase === 'lateFling') { + // A drag's fling reporting after its tail closed reopens it. + if (this.host.now() > this._touch.until) { + this._touch = null + } else if (external && isScrolling && moved) { + this._openTail(true, false) + } + } + // A finger that moved the content is dragging. Our own write landing + // under a resting finger is not, and neither is a report at rest that + // leaves the position where it was. + if (!ownReadBack && isScrolling && moved && this._touch?.phase === 'down') { + this._touch.moved = true + return true + } + return false + } + + touchStart = () => { + // A finger back down inside the grace window cancels its tail. + this.end() + this._touch = { phase: 'down', moved: false } + } + + touchEnd = () => { + const moved = this._touch?.phase === 'down' && this._touch.moved + this.end() + this._openTail(moved, moved) + } + + // Drop the gesture, and the tail's timer with it. + end = () => { + const win = this.host.getWindow() + if (this._touch?.phase === 'tail' && win != null) { + win.clearTimeout(this._touch.timerId) + } + this._touch = null + } + + // Follow the touch events on `target`; the returned function stops and + // drops the gesture. Listening is unconditional: the listeners are + // passive and cheap, and on non-touch devices they never fire. The gating + // by `isIOSWebKit()` lives where a write is decided. + listen = (target: EventTarget) => { + const onTouchStart = () => this.touchStart() + const onTouchEnd = () => this.touchEnd() + target.addEventListener('touchstart', onTouchStart, touchListenerOptions) + target.addEventListener('touchend', onTouchEnd, touchListenerOptions) + // Same handler as touchend: iOS can end a touch with touchcancel (system + // gesture, interrupted scroll) and never fire touchend. + target.addEventListener('touchcancel', onTouchEnd, touchListenerOptions) + return () => { + target.removeEventListener( + 'touchstart', + onTouchStart, + touchListenerOptions, + ) + target.removeEventListener('touchend', onTouchEnd, touchListenerOptions) + target.removeEventListener( + 'touchcancel', + onTouchEnd, + touchListenerOptions, + ) + this.end() + } + } + + // (Re)open the tail after the finger lifts, closing ~150ms after the last + // touchend or momentum frame. On non-iOS there is nothing to protect. + private _openTail = (moved: boolean, awaitingFling: boolean) => { + const win = this.host.getWindow() + if (!isIOSWebKit() || win == null) return + this.end() + const tail = { + phase: 'tail' as const, + moved, + awaitingFling, + // Only fires while this tail is current: `end` clears it otherwise. + timerId: win.setTimeout(() => { + if (this.host.tailStillMoving()) { + this._openTail(tail.moved, tail.awaitingFling) + return + } + this._touch = tail.awaitingFling + ? { phase: 'lateFling', until: this.host.now() + 1000 } + : null + this.host.onTailClosed() + }, 150), + } + this._touch = tail + } +} diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 7e72ad824..615778d9b 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -2081,7 +2081,7 @@ function makeIOSTouchVirtualizer( el._dispatch(type), expireTouchTail: () => { const id = - (v['_iosTouch'] as { timerId?: number } | null)?.timerId ?? null + (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() @@ -3197,9 +3197,9 @@ test('iOS Phase 1: touchstart enters the `down` phase and ends a prior tail', () const { v, el } = makeIOSVirtualizerWithRealEl(vi.fn(), mockWindow) dispatchTouchEvent(el, 'touchstart') dispatchTouchEvent(el, 'touchend') // leaves a tail armed - expect(v['_iosTouch']?.phase).toBe('tail') + expect(v['_iosGesture'].touch?.phase).toBe('tail') dispatchTouchEvent(el, 'touchstart') - expect(v['_iosTouch']).toEqual({ phase: 'down', moved: false }) + expect(v['_iosGesture'].touch).toEqual({ phase: 'down', moved: false }) }) }) @@ -3219,9 +3219,9 @@ test('iOS Phase 1: touchcancel clears touching the same as touchend, and cleanup expect(el._hasListener('touchcancel')).toBe(true) dispatchTouchEvent(el, 'touchstart') - expect(v['_iosTouch']).toEqual({ phase: 'down', moved: false }) + expect(v['_iosGesture'].touch).toEqual({ phase: 'down', moved: false }) dispatchTouchEvent(el, 'touchcancel') - expect(v['_iosTouch']?.phase).toBe('tail') + expect(v['_iosGesture'].touch?.phase).toBe('tail') const unmount = v._didMount() unmount() @@ -3246,13 +3246,13 @@ test('iOS Phase 1: touchend sets justTouchEnded + starts grace timer, then expir const { v, el } = makeIOSVirtualizerWithRealEl(vi.fn(), mockWindow) dispatchTouchEvent(el, 'touchstart') dispatchTouchEvent(el, 'touchend') - const touch = v['_iosTouch'] + 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['_iosTouch']).toBeNull() + expect(v['_iosGesture'].touch).toBeNull() }) }) @@ -3291,7 +3291,7 @@ test('iOS Phase 1: resize in post-touchend grace window defers; flushes when tim const { v, el } = makeIOSVirtualizerWithRealEl(scrollToFn, mockWindow) dispatchTouchEvent(el, 'touchstart') dispatchTouchEvent(el, 'touchend') - const touch = v['_iosTouch'] + const touch = v['_iosGesture'].touch expect(touch?.phase).toBe('tail') scrollToFn.mockClear() @@ -3304,7 +3304,7 @@ test('iOS Phase 1: resize in post-touchend grace window defers; flushes when tim // Expire the grace timer; the timer callback flushes the accumulated delta. const fn = timers.get((touch as { timerId: number }).timerId)! fn() - expect(v['_iosTouch']).toBeNull() + expect(v['_iosGesture'].touch).toBeNull() expect(scrollToFn).toHaveBeenCalledTimes(1) expect(v['_holdOffset']).toBe(0) }) @@ -3317,7 +3317,7 @@ test('iOS Phase 1: momentum scroll events re-arm the post-touchend tail until th touch('touchstart') touch('touchend') const firstTimer = - (v['_iosTouch'] as { timerId?: number } | null)?.timerId ?? null + (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null expect(firstTimer).not.toBeNull() // iOS fires no touch events during momentum, only scroll events. Each @@ -3325,9 +3325,9 @@ test('iOS Phase 1: momentum scroll events re-arm the post-touchend tail until th // fling that lasts a second or more, and the next resize would write // scrollTop and kill the momentum (#884). scroll(120, true) - expect(v['_iosTouch']?.phase).toBe('tail') + expect(v['_iosGesture'].touch?.phase).toBe('tail') expect( - (v['_iosTouch'] as { timerId?: number } | null)?.timerId ?? null, + (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null, ).not.toBe(firstTimer) v.resizeItem(0, 100) @@ -3335,14 +3335,14 @@ test('iOS Phase 1: momentum scroll events re-arm the post-touchend tail until th expect(v['_holdOffset']).toBe(50) scroll(140, true) // still flinging: re-armed again, still deferred - expect(v['_iosTouch']?.phase).toBe('tail') + 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['_iosTouch']?.phase).not.toBe('tail') + expect(v['_iosGesture'].touch?.phase).not.toBe('tail') expect( - (v['_iosTouch'] as { timerId?: number } | null)?.timerId ?? null, + (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null, ).toBeNull() expect(scrollToFn).toHaveBeenCalledTimes(1) expect(v['_holdOffset']).toBe(0) @@ -3450,14 +3450,14 @@ test('iOS Phase 1: new touchstart during grace window cancels pending flush time const { v, el } = makeIOSVirtualizerWithRealEl(scrollToFn, mockWindow) dispatchTouchEvent(el, 'touchstart') dispatchTouchEvent(el, 'touchend') - const firstTimerId = (v['_iosTouch'] as { timerId: number }).timerId + 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['_iosTouch']).toEqual({ phase: 'down', moved: false }) + expect(v['_iosGesture'].touch).toEqual({ phase: 'down', moved: false }) }) }) @@ -3546,7 +3546,7 @@ test('iOS Phase 1: scroll-element swap mid-touch does not strand the `down` phas mockWindow, ) dispatchTouchEvent(holder.el, 'touchstart') - expect(v['_iosTouch']).toEqual({ phase: 'down', moved: false }) + 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 @@ -3554,7 +3554,7 @@ test('iOS Phase 1: scroll-element swap mid-touch does not strand the `down` phas // the flush blocked until the user's next full touch cycle. holder.el = makeEl() v._willUpdate() - expect(v['_iosTouch']).toBeNull() + expect(v['_iosGesture'].touch).toBeNull() }) }) @@ -3576,14 +3576,14 @@ test('iOS Phase 1: scroll-element swap during grace window does not strand the t ) dispatchTouchEvent(holder.el, 'touchstart') dispatchTouchEvent(holder.el, 'touchend') - expect(v['_iosTouch']?.phase).toBe('tail') + 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['_iosTouch']).toBeNull() + expect(v['_iosGesture'].touch).toBeNull() expect(timers.size).toBe(0) }) }) @@ -3799,10 +3799,10 @@ test('iOS Phase 1: non-iOS still does NOT install touch state machine', () => { const { v, el } = makeIOSVirtualizerWithRealEl(scrollToFn, mockWindow) dispatchTouchEvent(el, 'touchstart') - expect(v['_iosTouch']).toEqual({ phase: 'down', moved: false }) // touchstart still records (cheap) + 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['_iosTouch']).toBeNull() + expect(v['_iosGesture'].touch).toBeNull() }) test('non-iOS: adjustment is applied immediately during scroll (no regression)', () => { @@ -6640,7 +6640,7 @@ test('#1250: a programmatic scrollToIndex landing compensates synchronously with expect(scrollToFn).toHaveBeenCalledTimes(1) scroll(250, true) // the write's own scroll-event echo: isScrolling=true expect(v.isScrolling).toBe(true) - expect(v['_iosTouch']?.phase).not.toBe('tail') // an echo never opens the tail + 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 @@ -6659,10 +6659,10 @@ test('#1250: a tap that never scrolls does not latch the deferral gate', () => { touch('touchstart') touch('touchend') expireTouchTail() - expect(v['_iosTouch']?.phase).not.toBe('down') - expect(v['_iosTouch']?.phase).not.toBe('tail') + expect(v['_iosGesture'].touch?.phase).not.toBe('down') + expect(v['_iosGesture'].touch?.phase).not.toBe('tail') expect( - (v['_iosTouch'] as { timerId?: number } | null)?.timerId ?? null, + (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null, ).toBeNull() // Every piece of touch state is timer-bounded, so the next compensation @@ -6681,16 +6681,16 @@ test('#1250: a scrollToIndex issued from a tap handler lands compensated', () => // the post-touchend window. touch('touchstart') touch('touchend') - expect(v['_iosTouch']?.phase).toBe('tail') + 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['_iosTouch']?.phase).not.toBe('tail') + expect(v['_iosGesture'].touch?.phase).not.toBe('tail') expect( - (v['_iosTouch'] as { timerId?: number } | null)?.timerId ?? null, + (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null, ).toBeNull() scroll(250, true) // the echo must not re-open it - expect(v['_iosTouch']?.phase).not.toBe('tail') + expect(v['_iosGesture'].touch?.phase).not.toBe('tail') scrollToFn.mockClear() v.resizeItem(0, 100) @@ -6706,7 +6706,7 @@ test('#1250: an absolute command under a dragging finger keeps deferring', () => touch('touchstart') scroll(90, true) // the finger drags v.scrollToIndex(8) // the dragging finger still owns the scroll - expect(v['_iosTouch']?.phase).toBe('down') + expect(v['_iosGesture'].touch?.phase).toBe('down') scrollToFn.mockClear() v.resizeItem(0, 100) @@ -6725,7 +6725,7 @@ test('#1250: a scrollToIndex with no target leaves the fling running and the hel v.resizeItem(0, 100) expect(v['_holdOffset']).toBe(50) const timer = - (v['_iosTouch'] as { timerId?: number } | null)?.timerId ?? null + (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 @@ -6736,9 +6736,9 @@ test('#1250: a scrollToIndex with no target leaves the fling running and the hel v.scrollToIndex(5) expect(scrollToFn).not.toHaveBeenCalled() expect(v['_holdOffset']).toBe(50) - expect(v['_iosTouch']?.phase).toBe('tail') + expect(v['_iosGesture'].touch?.phase).toBe('tail') expect( - (v['_iosTouch'] as { timerId?: number } | null)?.timerId ?? null, + (v['_iosGesture'].touch as { timerId?: number } | null)?.timerId ?? null, ).toBe(timer) // A resize during the remaining momentum is held too rather than @@ -6761,13 +6761,13 @@ test('iOS Phase 1: touchcancel releases the touch like touchend', () => { // A system gesture steals the touch: iOS fires touchcancel, never // touchend. Without handling it the touch would stay `down` forever. touch('touchstart') - expect(v['_iosTouch']?.phase).toBe('down') + expect(v['_iosGesture'].touch?.phase).toBe('down') touch('touchcancel') - expect(v['_iosTouch']?.phase).not.toBe('down') - expect(v['_iosTouch']?.phase).toBe('tail') + expect(v['_iosGesture'].touch?.phase).not.toBe('down') + expect(v['_iosGesture'].touch?.phase).toBe('tail') expireTouchTail() - expect(v['_iosTouch']?.phase).not.toBe('tail') + expect(v['_iosGesture'].touch?.phase).not.toBe('tail') v.resizeItem(0, 100) expect(scrollToFn).toHaveBeenCalledTimes(1) @@ -6785,11 +6785,11 @@ test.each([ touch('touchstart') touch('touchend') - expect(v['_iosTouch']?.phase).toBe('tail') + expect(v['_iosGesture'].touch?.phase).toBe('tail') // The command's own write stops any momentum, so it closes the tail. command(v) - expect(v['_iosTouch']?.phase).not.toBe('tail') + expect(v['_iosGesture'].touch?.phase).not.toBe('tail') scroll(el.scrollTop, true) // the read-back must not re-open it scrollToFn.mockClear() @@ -6856,7 +6856,7 @@ test('iOS deferral: a retarget iOS ignores under a finger keeps the deferred cor h.v.setOptions({ ...h.v.options, scrollToFn: (offset, opts, inst) => { - if (!h.v['_iosDragging']()) write(offset, opts, inst) + if (!h.v['_iosGesture'].dragging()) write(offset, opts, inst) }, }) h.touch('touchstart') @@ -6911,13 +6911,13 @@ test('iOS touch: a row that stops touch propagation does not hide the gesture', v._willUpdate() row.dispatchEvent(new Event('touchstart', { bubbles: true })) - expect(v['_iosTouch']).toEqual({ phase: 'down', moved: false }) + expect(v['_iosGesture'].touch).toEqual({ phase: 'down', moved: false }) row.dispatchEvent(new Event('touchend', { bubbles: true })) - expect(v['_iosTouch']?.phase).toBe('tail') + expect(v['_iosGesture'].touch?.phase).toBe('tail') unmount() row.dispatchEvent(new Event('touchstart', { bubbles: true })) - expect(v['_iosTouch']).toBeNull() + expect(v['_iosGesture'].touch).toBeNull() scrollEl.remove() }) } finally { diff --git a/packages/virtual-core/tests/ios-gesture.test.ts b/packages/virtual-core/tests/ios-gesture.test.ts new file mode 100644 index 000000000..f4a95d241 --- /dev/null +++ b/packages/virtual-core/tests/ios-gesture.test.ts @@ -0,0 +1,343 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { + IosGesture, + _resetIOSDetectionForTests, + iosMomentumOverridesWrites, +} from '../src/ios-gesture' + +const setUserAgent = (ios: boolean, ios27 = false) => { + if (ios) { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)', + configurable: true, + }) + } else { + delete (navigator as any).userAgent + } + if (ios27) { + ;(globalThis as any).CSS = { + supports: (query: string) => query === 'overflow-anchor: auto', + } + } + _resetIOSDetectionForTests() +} + +const make = () => { + const onTailClosed = vi.fn() + const tailStillMoving = vi.fn(() => false) + let win: Window | null = window + const gesture = new IosGesture({ + getWindow: () => win, + now: () => Date.now(), + tailStillMoving, + onTailClosed, + }) + return { + gesture, + onTailClosed, + tailStillMoving, + detachWindow: () => { + win = null + }, + } +} + +const scroll = ( + gesture: IosGesture, + { + isScrolling = true, + ownReadBack = false, + moved = true, + external = !ownReadBack, + } = {}, +) => gesture.onScroll({ isScrolling, ownReadBack, moved, external }) + +beforeEach(() => { + vi.useFakeTimers() + setUserAgent(true) +}) + +afterEach(() => { + vi.useRealTimers() + delete (navigator as any).userAgent + delete (globalThis as any).CSS + _resetIOSDetectionForTests() +}) + +describe('IosGesture', () => { + test('a tap: down, then a tail that closes 150ms after touchend', () => { + const { gesture, onTailClosed } = make() + expect(gesture.touch).toBeNull() + expect(gesture.inMotion()).toBe(false) + + gesture.touchStart() + expect(gesture.touch).toEqual({ phase: 'down', moved: false }) + expect(gesture.inMotion()).toBe(true) + expect(gesture.dragging()).toBe(false) + + gesture.touchEnd() + expect(gesture.touch).toMatchObject({ phase: 'tail', moved: false }) + expect(gesture.tail()).toBe('tap') + + vi.advanceTimersByTime(149) + expect(onTailClosed).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(gesture.touch).toBeNull() + expect(onTailClosed).toHaveBeenCalledTimes(1) + }) + + test('a drag: a scroll event that moves the position under the finger', () => { + const { gesture } = make() + gesture.touchStart() + expect(scroll(gesture)).toBe(true) + expect(gesture.dragging()).toBe(true) + // Already dragging: still reported. + expect(scroll(gesture)).toBe(true) + + gesture.touchEnd() + expect(gesture.touch).toMatchObject({ phase: 'tail', moved: true }) + expect(gesture.dragging()).toBe(false) + }) + + test.each([ + ['our own read-back', { ownReadBack: true }], + ['a report at rest', { isScrolling: false }], + ['an event that leaves the position', { moved: false }], + ])('%s under the finger is not a drag', (_, event) => { + const { gesture } = make() + gesture.touchStart() + expect(scroll(gesture, event)).toBe(false) + expect(gesture.touch).toEqual({ phase: 'down', moved: false }) + }) + + test('a scroll event in the tail re-arms it; a read-back keeps a tap a tap', () => { + const { gesture, onTailClosed } = make() + gesture.touchStart() + gesture.touchEnd() + + vi.advanceTimersByTime(100) + expect(scroll(gesture, { ownReadBack: true, moved: false })).toBe(false) + expect(gesture.touch).toMatchObject({ phase: 'tail', moved: false }) + + vi.advanceTimersByTime(100) + expect(onTailClosed).not.toHaveBeenCalled() + // A momentum frame: the tail now has a fling. + expect(scroll(gesture, { moved: false })).toBe(false) + expect(gesture.touch).toMatchObject({ phase: 'tail', moved: true }) + + vi.advanceTimersByTime(149) + expect(onTailClosed).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(onTailClosed).toHaveBeenCalledTimes(1) + }) + + test('a report at rest does not re-arm the tail', () => { + const { gesture, onTailClosed } = make() + gesture.touchStart() + gesture.touchEnd() + vi.advanceTimersByTime(100) + scroll(gesture, { isScrolling: false }) + vi.advanceTimersByTime(50) + expect(onTailClosed).toHaveBeenCalledTimes(1) + }) + + test('a finger back down inside the tail cancels its timer', () => { + const { gesture, onTailClosed } = make() + gesture.touchStart() + gesture.touchEnd() + gesture.touchStart() + vi.advanceTimersByTime(1000) + expect(onTailClosed).not.toHaveBeenCalled() + expect(gesture.touch).toEqual({ phase: 'down', moved: false }) + }) + + test('end drops the gesture and its timer', () => { + const { gesture, onTailClosed } = make() + gesture.touchStart() + gesture.touchEnd() + gesture.end() + expect(gesture.touch).toBeNull() + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(1000) + expect(onTailClosed).not.toHaveBeenCalled() + }) + + test('a second touchend restarts the tail with one timer', () => { + const { gesture, onTailClosed } = make() + gesture.touchStart() + gesture.touchEnd() + vi.advanceTimersByTime(100) + gesture.touchEnd() + expect(vi.getTimerCount()).toBe(1) + vi.advanceTimersByTime(100) + expect(onTailClosed).not.toHaveBeenCalled() + vi.advanceTimersByTime(50) + expect(onTailClosed).toHaveBeenCalledTimes(1) + }) + + test('no tail on non-iOS, or without a window', () => { + setUserAgent(false) + const { gesture } = make() + gesture.touchStart() + gesture.touchEnd() + expect(gesture.touch).toBeNull() + + setUserAgent(true) + const other = make() + other.detachWindow() + other.gesture.touchStart() + other.gesture.touchEnd() + expect(other.gesture.touch).toBeNull() + }) + + test('tail: none while down, a tap, then a fling once a momentum frame comes', () => { + const { gesture } = make() + gesture.touchStart() + expect(gesture.tail()).toBeNull() + gesture.touchEnd() + expect(gesture.tail()).toBe('tap') + scroll(gesture) + expect(gesture.tail()).toBe('fling') + + const drag = make() + drag.gesture.touchStart() + scroll(drag.gesture) + drag.gesture.touchEnd() + expect(drag.gesture.tail()).toBe('fling') + }) + + test('iosMomentumOverridesWrites: only on iOS 27', () => { + expect(iosMomentumOverridesWrites()).toBe(false) + setUserAgent(true, true) + expect(iosMomentumOverridesWrites()).toBe(true) + setUserAgent(false, true) + expect(iosMomentumOverridesWrites()).toBe(false) + }) + + describe('listen', () => { + test('follows touch events in the capture phase and stops on detach', () => { + const { gesture, onTailClosed } = make() + const el = document.createElement('div') + const add = vi.spyOn(el, 'addEventListener') + const remove = vi.spyOn(el, 'removeEventListener') + const stop = gesture.listen(el) + + const options = { capture: true, passive: true } + expect(add.mock.calls.map(([type, , opts]) => [type, opts])).toEqual([ + ['touchstart', options], + ['touchend', options], + ['touchcancel', options], + ]) + + el.dispatchEvent(new Event('touchstart')) + expect(gesture.touch).toEqual({ phase: 'down', moved: false }) + el.dispatchEvent(new Event('touchcancel')) + expect(gesture.tail()).toBe('tap') + + stop() + expect(remove.mock.calls.map(([type]) => type)).toEqual([ + 'touchstart', + 'touchend', + 'touchcancel', + ]) + // Detaching drops the gesture and its timer. + expect(gesture.touch).toBeNull() + vi.advanceTimersByTime(1000) + expect(onTailClosed).not.toHaveBeenCalled() + + el.dispatchEvent(new Event('touchstart')) + expect(gesture.touch).toBeNull() + }) + }) + + test('a tail whose fling still moves stays open another 150ms', () => { + const { gesture, onTailClosed, tailStillMoving } = make() + gesture.touchStart() + scroll(gesture) + gesture.touchEnd() + tailStillMoving.mockReturnValueOnce(true) + vi.advanceTimersByTime(150) + expect(tailStillMoving).toHaveBeenCalledTimes(1) + expect(onTailClosed).not.toHaveBeenCalled() + expect(gesture.touch).toMatchObject({ phase: 'tail', moved: true }) + vi.advanceTimersByTime(150) + expect(onTailClosed).toHaveBeenCalledTimes(1) + // A drag's tail closed before its fling reported: the late-fling window. + expect(gesture.touch).toMatchObject({ phase: 'lateFling' }) + expect(gesture.inMotion()).toBe(false) + }) + + test('a drag whose fling reports after the tail closed reopens it, within 1s', () => { + const { gesture } = make() + gesture.touchStart() + scroll(gesture) + gesture.touchEnd() + vi.advanceTimersByTime(150) + // Waiting for the fling's first frame defers nothing. + expect(gesture.touch).toMatchObject({ phase: 'lateFling' }) + expect(gesture.inMotion()).toBe(false) + vi.advanceTimersByTime(100) + scroll(gesture) + expect(gesture.touch).toMatchObject({ phase: 'tail', moved: true }) + }) + + test('a momentum frame in time leaves no late-fling window', () => { + const { gesture } = make() + gesture.touchStart() + scroll(gesture) + gesture.touchEnd() + vi.advanceTimersByTime(50) + scroll(gesture) + vi.advanceTimersByTime(150) + expect(gesture.touch).toBeNull() + }) + + test('a finger down or end() drops the late-fling window', () => { + const { gesture } = make() + gesture.touchStart() + scroll(gesture) + gesture.touchEnd() + vi.advanceTimersByTime(150) + gesture.touchStart() + // A drag under the new finger, not a late fling. + expect(scroll(gesture)).toBe(true) + expect(gesture.touch).toEqual({ phase: 'down', moved: true }) + + const swapped = make() + swapped.gesture.touchStart() + scroll(swapped.gesture) + swapped.gesture.touchEnd() + vi.advanceTimersByTime(150) + // The scroll element changed (cleanup): nothing carries over. + swapped.gesture.end() + scroll(swapped.gesture) + expect(swapped.gesture.touch).toBeNull() + }) + + test('the late-fling window closes after 1s and never follows a tap', () => { + const late = make() + late.gesture.touchStart() + scroll(late.gesture) + late.gesture.touchEnd() + vi.advanceTimersByTime(150 + 1001) + scroll(late.gesture) + expect(late.gesture.touch).toBeNull() + + const tap = make() + tap.gesture.touchStart() + tap.gesture.touchEnd() + vi.advanceTimersByTime(250) + scroll(tap.gesture) + expect(tap.gesture.touch).toBeNull() + }) + + test('our own write or animation does not reopen it', () => { + const { gesture } = make() + gesture.touchStart() + scroll(gesture) + gesture.touchEnd() + vi.advanceTimersByTime(250) + scroll(gesture, { external: false }) + expect(gesture.tail()).toBeNull() + }) +}) From 16c786a2256734607efd4e6aabbfcf8846bde2d4 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Thu, 15 Oct 2026 09:01:40 +0200 Subject: [PATCH 19/46] test(virtual-core): split index.test.ts into topic files (#1328) --- .../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) +}) From d2c7911d6b749aaa47e9aab1dc10125040363bd0 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Thu, 15 Oct 2026 11:58:59 +0200 Subject: [PATCH 20/46] fix(virtual-core): keep an instant scroll command through a transient clamp (#1329) * fix(virtual-core): keep an instant scroll command through a transient clamp Rows measured before an instant scrollToEnd() reports back can shrink the sizer below the written position and then grow it again in the same task. The browser clamps the write to the smaller max, and the scroll event reports that position: neither the write target nor the current max, so it was classified as external and the command was retired short of the end. resizeItem now follows the scroll max while a command's instant write is unreported (element max read once, then each delta, no measurement rebuild), and classifyScrollEvent reads an event between the lowest max and the target as clampedReadBack, so reconcile retargets to the end. Co-Authored-By: Claude Opus 5.5 * fix(virtual-core): count a pre-shrunk max and rewrite after a transient clamp Review follow-ups for the transient-clamp read-back: - The element max read at the first resize after the write counts toward `lowestMax`: the sizer can already be below the target when that resize arrives. - After such a clamp, an index or end command moves `lastTargetOffset` to the clamped position, so reconcile writes the target again even when the layout came back to the same end. Only for a clamp below the element's current max; the clamp at the current max (#1310) is unchanged. - The max is followed only while `_awaitingCommandReadBack()` holds (not under an iOS dragging finger) and only with one lane, where a row's delta is the total's. Scroll-trace snapshots are unchanged. Co-Authored-By: Claude Opus 5.5 * fix(virtual-core): match a transient clamp to a max the layout took A clamped read-back below the element's current max was accepted anywhere between the lowest tracked max and the target, so a user scroll that landed in that range before the read-back kept the command. The browser clamps to a max the sizer actually had, so `pendingWrite` now keeps each tracked max below the target (`lowMaxes`) and the classifier requires the offset to be within the read-back tolerance of one of them. Co-Authored-By: Claude Opus 5.5 * fix(virtual-core): record a transient max below zero at 0 A row that shrinks the tracked max below zero leaves the element clamped at 0, not at the negative value, so a read-back at 0 after the sizer grew back matched no recorded max and retired the command. `lowMaxes` now records the reachable value (the element's 0, which is the hold in layout coordinates); the raw max still carries the next delta. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .changeset/clamped-end-survives.md | 7 + packages/virtual-core/src/index.ts | 41 +++++ .../virtual-core/src/scroll-classifier.ts | 25 ++- .../tests/scroll-classifier.test.ts | 28 +++ .../tests/scroll-commands.test.ts | 169 ++++++++++++++++++ 5 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 .changeset/clamped-end-survives.md diff --git a/.changeset/clamped-end-survives.md b/.changeset/clamped-end-survives.md new file mode 100644 index 000000000..50c85358f --- /dev/null +++ b/.changeset/clamped-end-survives.md @@ -0,0 +1,7 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): keep an instant scroll command when the browser clamps its write before rows grow back + +Rows measured right after an instant `scrollToEnd()` (or `scrollToIndex`) can first shrink the sizer below the written position and then grow it again in the same task. The browser clamps the write to the smaller max, and the scroll event reports that position. It was read as the user scrolling away, so the command was cancelled and the list stopped short of the end (a chat opened with `estimateSize` larger than its rows). The virtualizer now follows the scroll max through those measurements and reads such an event as its own clamped write, so the command retargets and lands at the end. diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 275f0f653..69037c660 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -610,9 +610,16 @@ export class Virtualizer< // further by the time it reports. // - `smooth`: the write animates, so until it lands the element is // somewhere between the old offset and `target`. + // - `max`, `lowMaxes`: for a command's instant write, the scroll max as + // rows measured since the write moved it (`resizeItem`), and each value + // it took below `target`. The browser clamps the write to one of them + // at a layout, and the sizer can grow back before the report, so the + // read-back is that max rather than the element's current one. private pendingWrite: { target: number smooth: boolean + max?: number + lowMaxes?: Array } | null = null // A compensation write from `applyScrollAdjustment` whose target exceeded // the element's scroll max at the moment of the write. The browser clamps @@ -1084,6 +1091,16 @@ export class Virtualizer< // Adopt the exact target over the browser's rounded value (see // `pendingWrite`). offset = this.pendingWrite!.target + } else if ( + kind === 'clampedReadBack' && + this.scrollState?.index != null && + elementMax !== null && + offset < elementMax + this._holdOffset - 1.5 + ) { + // Our write, clamped at a max the sizer has since grown back + // from: reconcile writes the target again, even when the layout + // came back to the same end. + this.scrollState.lastTargetOffset = offset } else if ( externalRetiresCommand({ kind, @@ -2031,6 +2048,30 @@ export class Virtualizer< this.itemSizeCache.set(key, size) this.itemSizeCacheVersion++ + // Follow the scroll max through the sizes measured before a command's + // instant write reports back (see `pendingWrite`). The element's max + // is read once, at the first resize after the write, before that + // resize reaches the sizer; it may already be below the target. With + // lanes, a row's delta is not the total's, so nothing is followed. + const write = + this.options.lanes === 1 && this._awaitingCommandReadBack() + ? this.pendingWrite! + : null + const followMax = (max: number) => { + write!.max = max + // The element cannot clamp below its own 0, which is the hold in + // layout coordinates; the raw max stays for the next delta. + const reachable = Math.max(max, this._holdOffset) + if (reachable < write!.target) { + ;(write!.lowMaxes ??= []).push(reachable) + } + } + if (write !== null && write.max === undefined) { + const elementMax = this._elementMaxScrollOffset() + if (elementMax !== null) followMax(elementMax + this._holdOffset) + } + if (write?.max !== undefined) followMax(write.max + delta) + // Before a command's read-back, an index or end target is retargeted // from fresh measurements by reconcileScroll next frame; an offset // target lands where it was written and the delta is not applied. diff --git a/packages/virtual-core/src/scroll-classifier.ts b/packages/virtual-core/src/scroll-classifier.ts index 518a88320..96a1565ea 100644 --- a/packages/virtual-core/src/scroll-classifier.ts +++ b/packages/virtual-core/src/scroll-classifier.ts @@ -8,7 +8,8 @@ import { approxEqual } from './utils' // readBack our last write reporting back (within 1.5px of its // target) // clampedReadBack our last non-smooth write, clamped at the element's -// current max +// current max or at a smaller max the layout passed +// through since the write // clampedEcho a compensation write the browser clamped (#1258) // reporting at the max seen at write time // external anything else: the user, a fling, a smooth animation @@ -32,8 +33,14 @@ export interface ScrollEventInput { holdOffset: number // The layout position the virtualizer holds. scrollOffset: number | null - // Our last write to the element, until a scroll event reports it. - pendingWrite: { target: number; smooth: boolean } | null + // Our last write to the element, until a scroll event reports it, with + // the scroll maxima below its target the layout took since (see + // `lowMaxes` on `pendingWrite` in the virtualizer). + pendingWrite: { + target: number + smooth: boolean + lowMaxes?: Array + } | null // The element's max when a clamped compensation write was made (#1258), // or null when none is pending. clampedMaxAtWrite: number | null @@ -74,6 +81,18 @@ export function classifyScrollEvent({ ) { return 'clampedReadBack' } + // The same, clamped to a smaller max than the element's current one: a row + // measured smaller shrank the sizer below the target and the browser + // clamped there, then a row measured larger grew it back, all before the + // report. The clamp lands on one of those maxima; a position between them + // is not one. + if ( + pendingWrite !== null && + !pendingWrite.smooth && + pendingWrite.lowMaxes?.some((max) => Math.abs(offset - max) < 1.5) + ) { + return 'clampedReadBack' + } if ( clampedMaxAtWrite !== null && Math.abs(offset - clampedMaxAtWrite) < 1.5 diff --git a/packages/virtual-core/tests/scroll-classifier.test.ts b/packages/virtual-core/tests/scroll-classifier.test.ts index 2267440cc..d859a2ba4 100644 --- a/packages/virtual-core/tests/scroll-classifier.test.ts +++ b/packages/virtual-core/tests/scroll-classifier.test.ts @@ -167,6 +167,34 @@ describe('classifyScrollEvent', () => { }), ).toBe('clampedReadBack') }) + + describe('at a lower max the layout passed through', () => { + const shrunk = { target: 1200, smooth: false, lowMaxes: [1000, 1100] } + + test('at one of those maxima', () => { + expect( + at({ offset: 1000, elementMax: 1300, pendingWrite: shrunk }), + ).toBe('clampedReadBack') + expect(at({ offset: 998.6, pendingWrite: shrunk })).toBe( + 'clampedReadBack', + ) + expect(at({ offset: 1101.4, pendingWrite: shrunk })).toBe( + 'clampedReadBack', + ) + }) + + test('not between or beside them', () => { + expect(at({ offset: 1050, pendingWrite: shrunk })).toBe('external') + expect(at({ offset: 998.5, pendingWrite: shrunk })).toBe('external') + expect(at({ offset: 1101.5, pendingWrite: shrunk })).toBe('external') + }) + + test('not for a smooth write', () => { + expect( + at({ offset: 1000, pendingWrite: { ...shrunk, smooth: true } }), + ).toBe('external') + }) + }) }) describe('clampedEcho', () => { diff --git a/packages/virtual-core/tests/scroll-commands.test.ts b/packages/virtual-core/tests/scroll-commands.test.ts index f84b3294a..57e4d6914 100644 --- a/packages/virtual-core/tests/scroll-commands.test.ts +++ b/packages/virtual-core/tests/scroll-commands.test.ts @@ -372,6 +372,175 @@ test('a browser clamp to the new max does not cancel a reached scrollToEnd', () expect(virtualizer['scrollState']?.lastTargetOffset).toBe(4410) }) +test('a clamp the sizer grew back from does not cancel an instant scrollToEnd', () => { + 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 () => {} + }, + }) + Object.defineProperty(mockScrollElement, 'scrollHeight', { + value: 5000, + configurable: true, + }) + virtualizer._willUpdate() + virtualizer.getVirtualItems() + + virtualizer.scrollToEnd() + expect(scrollToFn).toHaveBeenLastCalledWith( + 4400, + expect.anything(), + virtualizer, + ) + + // Before the read-back, rows measure in one task: one smaller than its + // estimate, so the sizer shrinks and the browser clamps scrollTop to the + // max of that moment (4370), then one larger, so the sizer grows past the + // target again. The event reports the clamped position. + virtualizer.resizeItem(90, 20) + virtualizer.resizeItem(91, 150) + Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 5070 }) + onScroll(4370, true) + + expect(virtualizer['scrollState']).not.toBeNull() + rafCallbacks.shift()!(0) + expect(scrollToFn).toHaveBeenLastCalledWith( + 4470, + expect.anything(), + virtualizer, + ) +}) + +test('a sizer that shrank before the first resize after an instant scrollToEnd does not cancel it', () => { + 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 () => {} + }, + }) + Object.defineProperty(mockScrollElement, 'scrollHeight', { + value: 5000, + configurable: true, + }) + virtualizer._willUpdate() + virtualizer.getVirtualItems() + + // A row measured smaller before the command; the sizer has not shrunk yet. + virtualizer.resizeItem(90, 20) + virtualizer.scrollToEnd() + expect(scrollToFn).toHaveBeenLastCalledWith( + 4400, + expect.anything(), + virtualizer, + ) + + // The sizer commits smaller (the browser clamps to 4370) before the next + // row measures larger and grows it past the target again. + Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 4970 }) + virtualizer.resizeItem(91, 150) + Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 5070 }) + onScroll(4370, true) + + expect(virtualizer['scrollState']).not.toBeNull() + rafCallbacks.shift()!(0) + expect(scrollToFn).toHaveBeenLastCalledWith( + 4470, + expect.anything(), + virtualizer, + ) +}) + +test('an instant scrollToEnd clamped by a shrink that grew back to the same total writes the end again', () => { + 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 () => {} + }, + }) + Object.defineProperty(mockScrollElement, 'scrollHeight', { + value: 5000, + configurable: true, + }) + virtualizer._willUpdate() + virtualizer.getVirtualItems() + + virtualizer.scrollToEnd() + // -30 then +30: the end is where it was, but the browser clamped to 4370. + virtualizer.resizeItem(90, 20) + virtualizer.resizeItem(91, 80) + onScroll(4370, true) + scrollToFn.mockClear() + + rafCallbacks.shift()!(0) + expect(scrollToFn).toHaveBeenLastCalledWith( + 4400, + expect.anything(), + virtualizer, + ) +}) + +test('a transient max below zero clamps an instant scrollToEnd at 0 without cancelling it', () => { + const { rafCallbacks, mockScrollElement, scrollToFn } = + createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + let onScroll!: (offset: number, isScrolling: boolean) => void + virtualizer.setOptions({ + ...virtualizer.options, + count: 13, + observeElementOffset: (_instance, callback) => { + onScroll = callback + callback(0, false) + return () => {} + }, + }) + Object.defineProperty(mockScrollElement, 'scrollHeight', { + value: 630, + configurable: true, + }) + virtualizer._willUpdate() + virtualizer.getVirtualItems() + + virtualizer.scrollToEnd() + expect(scrollToFn).toHaveBeenLastCalledWith( + 30, + expect.anything(), + virtualizer, + ) + + // A row shrinks by 50: the max would be -20, so the browser clamps to 0. + // Then a row grows and the sizer passes the target again. + virtualizer.resizeItem(10, 0) + virtualizer.resizeItem(11, 150) + Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 680 }) + onScroll(0, true) + + expect(virtualizer['scrollState']).not.toBeNull() + rafCallbacks.shift()!(0) + expect(scrollToFn).toHaveBeenLastCalledWith( + 80, + expect.anything(), + virtualizer, + ) +}) + test('a smooth retarget is not cancelled by its own intermediate scroll events', () => { const { rafCallbacks, mockScrollElement, scrollToFn } = createMockEnvironment() From 56b2a63d2336907659d87125b072fa11e4ac9046 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Thu, 15 Oct 2026 13:07:49 +0200 Subject: [PATCH 21/46] fix(virtual-core): record the committed max as a clamp candidate (#1330) --- .changeset/clamped-commit-survives.md | 7 + packages/virtual-core/src/index.ts | 63 +++-- .../virtual-core/src/scroll-classifier.ts | 12 +- .../__snapshots__/scroll-trace.test.ts.snap | 33 +++ .../tests/scroll-commands.test.ts | 251 +++++++++++++++++- .../virtual-core/tests/scroll-trace.test.ts | 51 +++- 6 files changed, 367 insertions(+), 50 deletions(-) create mode 100644 .changeset/clamped-commit-survives.md diff --git a/.changeset/clamped-commit-survives.md b/.changeset/clamped-commit-survives.md new file mode 100644 index 000000000..988490c79 --- /dev/null +++ b/.changeset/clamped-commit-survives.md @@ -0,0 +1,7 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): keep an instant scroll command clamped at a max the sizer committed outside a row resize + +Before an instant scroll command's write is reported, the browser can clamp it at a smaller scroll max that the sizer moves to without a row resize: an append or prepend, `measure()`, or a resize the sizer had not committed when the command was written. The virtualizer now also records the max at each commit, so such an event is read as its own clamped write and the command lands. A clamped write reported with `isScrolling` false is now written again too. diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 69037c660..bbcdbc935 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -610,15 +610,16 @@ export class Virtualizer< // further by the time it reports. // - `smooth`: the write animates, so until it lands the element is // somewhere between the old offset and `target`. - // - `max`, `lowMaxes`: for a command's instant write, the scroll max as - // rows measured since the write moved it (`resizeItem`), and each value - // it took below `target`. The browser clamps the write to one of them - // at a layout, and the sizer can grow back before the report, so the - // read-back is that max rather than the element's current one. + // - `lowMaxes`: for a command's instant write, each scroll max below + // `target` the element had since the write. A command's target is + // clamped to the max at the write, but the sizer can shrink under it + // before the report (rows measured smaller, a count change) and grow + // back, so the read-back is the max the browser clamped to then rather + // than the element's current one. Read wherever the sizer can have + // been committed (`_noteClampMax`). private pendingWrite: { target: number smooth: boolean - max?: number lowMaxes?: Array } | null = null // A compensation write from `applyScrollAdjustment` whose target exceeded @@ -673,6 +674,7 @@ export class Virtualizer< this._resizeBatch = null if (batch.notify) { this.notify(batch.sync) + this._noteClampMax() this._flushHoldIfReady(true) this._retryClampedAdjustment() } @@ -1094,8 +1096,8 @@ export class Virtualizer< } else if ( kind === 'clampedReadBack' && this.scrollState?.index != null && - elementMax !== null && - offset < elementMax + this._holdOffset - 1.5 + // Not `elementMax`: an adapter can report the read-back at rest. + offset < this.getMaxScrollOffset() - 1.5 ) { // Our write, clamped at a max the sizer has since grown back // from: reconcile writes the target again, even when the layout @@ -1212,6 +1214,9 @@ export class Virtualizer< this._scrollToOffset(this.getScrollOffset()) } + // The consumer has committed the sizer. + this._noteClampMax() + const anchor = this.pendingScrollAnchor this.pendingScrollAnchor = null @@ -2048,30 +2053,6 @@ export class Virtualizer< this.itemSizeCache.set(key, size) this.itemSizeCacheVersion++ - // Follow the scroll max through the sizes measured before a command's - // instant write reports back (see `pendingWrite`). The element's max - // is read once, at the first resize after the write, before that - // resize reaches the sizer; it may already be below the target. With - // lanes, a row's delta is not the total's, so nothing is followed. - const write = - this.options.lanes === 1 && this._awaitingCommandReadBack() - ? this.pendingWrite! - : null - const followMax = (max: number) => { - write!.max = max - // The element cannot clamp below its own 0, which is the hold in - // layout coordinates; the raw max stays for the next delta. - const reachable = Math.max(max, this._holdOffset) - if (reachable < write!.target) { - ;(write!.lowMaxes ??= []).push(reachable) - } - } - if (write !== null && write.max === undefined) { - const elementMax = this._elementMaxScrollOffset() - if (elementMax !== null) followMax(elementMax + this._holdOffset) - } - if (write?.max !== undefined) followMax(write.max + delta) - // Before a command's read-back, an index or end target is retargeted // from fresh measurements by reconcileScroll next frame; an offset // target lands where it was written and the delta is not applied. @@ -2104,6 +2085,7 @@ export class Virtualizer< return } this.notify(adjustedSync) + this._noteClampMax() // A consumer that grows the sizer synchronously inside `onChange` // (direct DOM updates) may never re-render when the range is // unchanged, so retry a clamped write here rather than only in @@ -2164,6 +2146,23 @@ export class Virtualizer< return notUndefined(measurements[idx]) } + // Record the element's max, in layout coordinates, as one a command's + // pending instant write may be clamped to (see `lowMaxes` on + // `pendingWrite`). Called where the sizer can have been committed: after + // a notify, for a consumer that sizes it in `onChange`, and in + // `_willUpdate`. The element cannot clamp below its own 0, which is the + // hold in layout coordinates. + private _noteClampMax = () => { + if (!this._awaitingCommandReadBack()) return + const elementMax = this._elementMaxScrollOffset() + if (elementMax === null) return + const write = this.pendingWrite! + const max = Math.max(elementMax, 0) + this._holdOffset + if (max >= write.target) return + const lowMaxes = (write.lowMaxes ??= []) + if (lowMaxes[lowMaxes.length - 1] !== max) lowMaxes.push(max) + } + // The element's scroll range in layout coordinates: while a correction is // held the sizer is short by the hold, so the element's own max is too. private getMaxScrollOffset = () => diff --git a/packages/virtual-core/src/scroll-classifier.ts b/packages/virtual-core/src/scroll-classifier.ts index 96a1565ea..00130f784 100644 --- a/packages/virtual-core/src/scroll-classifier.ts +++ b/packages/virtual-core/src/scroll-classifier.ts @@ -34,7 +34,7 @@ export interface ScrollEventInput { // The layout position the virtualizer holds. scrollOffset: number | null // Our last write to the element, until a scroll event reports it, with - // the scroll maxima below its target the layout took since (see + // the scroll maxima below its target the element had since (see // `lowMaxes` on `pendingWrite` in the virtualizer). pendingWrite: { target: number @@ -81,11 +81,11 @@ export function classifyScrollEvent({ ) { return 'clampedReadBack' } - // The same, clamped to a smaller max than the element's current one: a row - // measured smaller shrank the sizer below the target and the browser - // clamped there, then a row measured larger grew it back, all before the - // report. The clamp lands on one of those maxima; a position between them - // is not one. + // The same, clamped to a smaller max than the element's current one: the + // sizer shrank below the target after the write (rows measured smaller, + // a count change) and the browser clamped there, then it grew back, all + // before the report. The clamp lands on one of those maxima; a position + // between them is not one. if ( pendingWrite !== null && !pendingWrite.smooth && diff --git a/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap b/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap index 4e3f679c8..b390002b3 100644 --- a/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap +++ b/packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap @@ -66,6 +66,39 @@ exports[`scroll behaviour snapshot > base web 1`] = ` ] `; +exports[`scroll behaviour snapshot > commitPerRow ios 1`] = ` +[ + "0: 188b7eb6 cbb399d1 0c68f8d8 52d44e85 030fdf66 f89bae5e e33eb3b7 4b11cbb6 bc3f4dfa 0d8fe7ec", + "10: 06bda0c7 fbdcf4b6 92b9b7d7 ac880e66 9880cb46 5ff7a292 10a7d3a0 78eed177 e0b1c0ac e25c37ec", + "20: 7d64b6f6 8ddb843c 01a561c0 4f8fccf5 06aa4f85 0c141569 cfbd97d3 aa353506 4634aede 5052c204", + "30: b6a919af 84dc4560 7e492fad 1852617f b06693e3 124a7157 f88d78a2 a89b2b4f 2d610d8a 155761e2", + "40: 0d274a15 5b099780 9dcf3736 54322109 bf57934a 6041f019 b28f19d9 e5e20e3b cfb551ab 2c1bbec2", + "50: ad0cbc1d 1f82fb9b 8ab02f8b 23c2e2fb 8cb40f92 ec29f0fd 91528992 14e26564 884a1fff 622e658e", +] +`; + +exports[`scroll behaviour snapshot > commitPerRow ios27 1`] = ` +[ + "0: e9f2ce2c cbb399d1 0c68f8d8 2c24151f 030fdf66 43163cec e33eb3b7 0c7f462d bc3f4dfa 0d8fe7ec", + "10: 9ee9150b fbdcf4b6 92b9b7d7 ec0a38f6 9880cb46 60d448ce 10a7d3a0 78eed177 e0b1c0ac a5e63aaf", + "20: 7d64b6f6 8ddb843c 01a561c0 6c999c46 3dcaee6a b2e63a21 cfbd97d3 b6a51eb6 e50f28ad 5052c204", + "30: b6a919af 84dc4560 42933fed 1852617f b06693e3 124a7157 13948bf0 a89b2b4f 2d610d8a 155761e2", + "40: 019380ed 5b099780 9dcf3736 c80d856e bf57934a 6041f019 c20f0271 e5e20e3b cfb551ab 2c1bbec2", + "50: ad0cbc1d 1f82fb9b 8ab02f8b 23c2e2fb 8cb40f92 d54527a4 7989082d 14e26564 884a1fff 98ba9a64", +] +`; + +exports[`scroll behaviour snapshot > commitPerRow web 1`] = ` +[ + "0: ceb7f0e7 cbb399d1 f4a63add a132747c 030fdf66 643d2755 e33eb3b7 4b11cbb6 bc3f4dfa 0d8fe7ec", + "10: 06bda0c7 fbdcf4b6 166319fd ac880e66 9880cb46 5ff7a292 31061954 dae8df99 e2cc19b7 e25c37ec", + "20: 7e86e2b6 fe1ff262 01a561c0 f91f4bae 6e38d09b 4ee9d5dd 7135be90 4ae76f75 b13f2c93 5052c204", + "30: b6a919af 2109d992 7e492fad 1852617f b06693e3 124a7157 117f3ed1 a89b2b4f 2d610d8a 155761e2", + "40: 0d274a15 5b099780 2c1ceef6 f813aca3 bf57934a 8e53c5c4 de439e3e e5e20e3b 5e08526b ef4b633c", + "50: 26d62359 e308f594 a0ad4db6 9d7ab115 509fe1e1 53afc076 83a11448 14e26564 884a1fff 622e658e", +] +`; + exports[`scroll behaviour snapshot > horizontal ios 1`] = ` [ "0: a9fcb9d4 0d2cec68 ed35961e 0a69fa03 0be21da6 3ec8eb85 869fa2d1 27716a81 f885c716 44f33e92", diff --git a/packages/virtual-core/tests/scroll-commands.test.ts b/packages/virtual-core/tests/scroll-commands.test.ts index 57e4d6914..35ca5ab6c 100644 --- a/packages/virtual-core/tests/scroll-commands.test.ts +++ b/packages/virtual-core/tests/scroll-commands.test.ts @@ -379,6 +379,13 @@ test('a clamp the sizer grew back from does not cancel an instant scrollToEnd', let onScroll!: (offset: number, isScrolling: boolean) => void virtualizer.setOptions({ ...virtualizer.options, + // The container is sized in `onChange`, as with `directDomUpdates`. + onChange: (instance) => { + Object.defineProperty(mockScrollElement, 'scrollHeight', { + value: instance.getTotalSize(), + configurable: true, + }) + }, observeElementOffset: (_instance, callback) => { onScroll = callback callback(0, false) @@ -405,7 +412,6 @@ test('a clamp the sizer grew back from does not cancel an instant scrollToEnd', // target again. The event reports the clamped position. virtualizer.resizeItem(90, 20) virtualizer.resizeItem(91, 150) - Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 5070 }) onScroll(4370, true) expect(virtualizer['scrollState']).not.toBeNull() @@ -449,8 +455,10 @@ test('a sizer that shrank before the first resize after an instant scrollToEnd d // The sizer commits smaller (the browser clamps to 4370) before the next // row measures larger and grows it past the target again. Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 4970 }) + virtualizer._willUpdate() virtualizer.resizeItem(91, 150) Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 5070 }) + virtualizer._willUpdate() onScroll(4370, true) expect(virtualizer['scrollState']).not.toBeNull() @@ -469,6 +477,13 @@ test('an instant scrollToEnd clamped by a shrink that grew back to the same tota let onScroll!: (offset: number, isScrolling: boolean) => void virtualizer.setOptions({ ...virtualizer.options, + // The container is sized in `onChange`, as with `directDomUpdates`. + onChange: (instance) => { + Object.defineProperty(mockScrollElement, 'scrollHeight', { + value: instance.getTotalSize(), + configurable: true, + }) + }, observeElementOffset: (_instance, callback) => { onScroll = callback callback(0, false) @@ -528,8 +543,11 @@ test('a transient max below zero clamps an instant scrollToEnd at 0 without canc // A row shrinks by 50: the max would be -20, so the browser clamps to 0. // Then a row grows and the sizer passes the target again. virtualizer.resizeItem(10, 0) + Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 580 }) + virtualizer._willUpdate() virtualizer.resizeItem(11, 150) Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 680 }) + virtualizer._willUpdate() onScroll(0, true) expect(virtualizer['scrollState']).not.toBeNull() @@ -541,6 +559,237 @@ test('a transient max below zero clamps an instant scrollToEnd at 0 without canc ) }) +// A virtualizer whose scroll events the test reports, over a sizer the test +// commits: `commit` sets the element's scroll height and runs the adapter's +// commit hook, as a re-render does. +function createCommittedSizer() { + const env = createMockEnvironment() + const virtualizer = createVirtualizer(env.mockScrollElement, env.scrollToFn) + let onScroll!: (offset: number, isScrolling: boolean) => void + virtualizer.setOptions({ + ...virtualizer.options, + observeElementOffset: (_instance, callback) => { + onScroll = callback + callback(0, false) + return () => {} + }, + }) + const commit = (scrollHeight: number) => { + Object.defineProperty(env.mockScrollElement, 'scrollHeight', { + value: scrollHeight, + configurable: true, + }) + virtualizer._willUpdate() + } + commit(5000) + virtualizer.getVirtualItems() + return { + ...env, + virtualizer, + commit, + scroll: (offset: number, isScrolling = true) => + onScroll(offset, isScrolling), + } +} + +test('a clamp at a max an append committed does not cancel an instant scrollToEnd', () => { + const { rafCallbacks, scrollToFn, virtualizer, commit, scroll } = + createCommittedSizer() + + virtualizer.scrollToEnd() + expect(scrollToFn).toHaveBeenLastCalledWith( + 4400, + expect.anything(), + virtualizer, + ) + + // A row shrinks, a row is appended, a second row shrinks: the sizer + // commits at a max of 4390, which no row's delta alone leads to. The + // browser clamps there before a row grows the sizer past the target. + virtualizer.resizeItem(90, 20) + commit(4970) + virtualizer.setOptions({ ...virtualizer.options, count: 101 }) + commit(5020) + virtualizer.resizeItem(91, 20) + commit(4990) + virtualizer.resizeItem(92, 150) + commit(5120) + scroll(4390) + + expect(virtualizer['scrollState']).not.toBeNull() + rafCallbacks.shift()!(0) + expect(scrollToFn).toHaveBeenLastCalledWith( + 4520, + expect.anything(), + virtualizer, + ) +}) + +test('a clamp below a max read before the sizer committed does not cancel an instant scrollToEnd', () => { + const { rafCallbacks, scrollToFn, virtualizer, commit, scroll } = + createCommittedSizer() + + // A row measured smaller before the command; the sizer has not committed + // it, so the command and its first resize read a max 30px too large. + virtualizer.resizeItem(90, 20) + virtualizer.scrollToEnd() + expect(scrollToFn).toHaveBeenLastCalledWith( + 4400, + expect.anything(), + virtualizer, + ) + + virtualizer.resizeItem(91, 10) + commit(4930) + virtualizer.resizeItem(92, 150) + commit(5070) + scroll(4330) + + expect(virtualizer['scrollState']).not.toBeNull() + rafCallbacks.shift()!(0) + expect(scrollToFn).toHaveBeenLastCalledWith( + 4470, + expect.anything(), + virtualizer, + ) +}) + +test('a clamp at a max an append committed does not cancel an instant scrollToIndex', () => { + const { rafCallbacks, scrollToFn, virtualizer, commit, scroll } = + createCommittedSizer() + + virtualizer.scrollToIndex(99, { align: 'end' }) + expect(scrollToFn).toHaveBeenLastCalledWith( + 4400, + expect.anything(), + virtualizer, + ) + + virtualizer.resizeItem(90, 20) + commit(4970) + virtualizer.setOptions({ ...virtualizer.options, count: 101 }) + commit(5020) + virtualizer.resizeItem(91, 20) + commit(4990) + virtualizer.resizeItem(92, 150) + commit(5120) + scroll(4390) + + expect(virtualizer['scrollState']).not.toBeNull() + rafCallbacks.splice(0).forEach((cb) => cb(0)) + // Row 99 ends at 5040 after -30, -30 and +100. + expect(scrollToFn).toHaveBeenLastCalledWith( + 4440, + expect.anything(), + virtualizer, + ) +}) + +test('a committed max is read in layout coordinates while a correction is held', () => { + const { rafCallbacks, scrollToFn, virtualizer, commit, scroll } = + createCommittedSizer() + + // A held correction of 100px: the sizer is 100px short of the layout, and + // an overscroll report keeps the hold from being written. + virtualizer['_holdOffset'] = 100 + virtualizer['_reportedOverscroll'] = true + commit(4900) + + virtualizer.resizeItem(90, 20) + virtualizer.scrollToEnd() + // The element's max (4300) plus the hold, written 100px short. + expect(scrollToFn).toHaveBeenLastCalledWith( + 4300, + expect.anything(), + virtualizer, + ) + + virtualizer.resizeItem(91, 10) + commit(4830) + virtualizer.resizeItem(92, 150) + commit(4970) + scroll(4230) + + expect(virtualizer['scrollState']).not.toBeNull() + // The event is not past the max, so the hold is written, and the end is + // the element's own max again. + expect(virtualizer['_holdOffset']).toBe(0) + scrollToFn.mockClear() + rafCallbacks.splice(0).forEach((cb) => cb(0)) + expect(scrollToFn).toHaveBeenLastCalledWith( + 4370, + expect.anything(), + virtualizer, + ) +}) + +test('a clamp at the lowest of several committed maxima does not cancel an instant scrollToEnd', () => { + const { rafCallbacks, scrollToFn, virtualizer, commit, scroll } = + createCommittedSizer() + + // Not committed before the command, so every max that follows from the + // first resize's read is 30px too large. + virtualizer.resizeItem(80, 20) + virtualizer.scrollToEnd() + + // Three shrinks commit one by one; the browser clamps at each, last at + // 4280. + virtualizer.resizeItem(90, 20) + commit(4940) + virtualizer.resizeItem(91, 20) + commit(4910) + virtualizer.resizeItem(92, 20) + commit(4880) + virtualizer.resizeItem(93, 200) + commit(5030) + scroll(4280) + + expect(virtualizer['scrollState']).not.toBeNull() + rafCallbacks.shift()!(0) + expect(scrollToFn).toHaveBeenLastCalledWith( + 4430, + expect.anything(), + virtualizer, + ) +}) + +test('a scroll to a position no committed max reached still cancels an instant scrollToEnd', () => { + const { virtualizer, commit, scroll } = createCommittedSizer() + + virtualizer.resizeItem(80, 20) + virtualizer.scrollToEnd() + virtualizer.resizeItem(90, 20) + commit(4940) + virtualizer.resizeItem(91, 150) + commit(5070) + // The user, between the committed max (4340) and the target. + scroll(4355) + + expect(virtualizer['scrollState']).toBeNull() +}) + +test('a clamp reported at rest still writes the end again', () => { + const { rafCallbacks, scrollToFn, virtualizer, commit, scroll } = + createCommittedSizer() + + // As in the net-zero case above, from an adapter that reports its + // read-back with `isScrolling` false. + virtualizer.scrollToEnd() + virtualizer.resizeItem(90, 20) + commit(4970) + virtualizer.resizeItem(91, 80) + commit(5000) + scroll(4370, false) + scrollToFn.mockClear() + + rafCallbacks.shift()!(0) + expect(scrollToFn).toHaveBeenLastCalledWith( + 4400, + expect.anything(), + virtualizer, + ) +}) + test('a smooth retarget is not cancelled by its own intermediate scroll events', () => { const { rafCallbacks, mockScrollElement, scrollToFn } = createMockEnvironment() diff --git a/packages/virtual-core/tests/scroll-trace.test.ts b/packages/virtual-core/tests/scroll-trace.test.ts index 1001c915d..2f23b9981 100644 --- a/packages/virtual-core/tests/scroll-trace.test.ts +++ b/packages/virtual-core/tests/scroll-trace.test.ts @@ -68,6 +68,12 @@ type Config = { setup?: (v: Virtualizer) => void // Share of scroll commands issued with `behavior: 'smooth'`. smooth?: number + // The adapter sizes the container from `onChange` (react-virtual's + // `directDomUpdates`), and the ResizeObserver reports each resized row in + // its own callback, so the sizer commits, and can clamp, per row. + commitPerRow?: boolean + // Step weights in place of the defaults in `makeSteps`. + weights?: { end?: number; resizeMany?: number } } const IOS_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)' @@ -102,6 +108,14 @@ const configs: Array = [ }, { name: 'horizontal', options: { horizontal: true } }, { name: 'smooth', smooth: 0.8 }, + // Instant scrolls to the end and resizes of several rows, so rows shrink + // and grow back under a write that has not been reported yet: the browser + // clamps it at a max the sizer has left by the read-back. + { + name: 'commitPerRow', + commitPerRow: true, + weights: { end: 12, resizeMany: 14 }, + }, ] // mulberry32 @@ -133,7 +147,11 @@ const naturalSize = (key: number) => const ALIGNS: Array = ['start', 'center', 'end', 'auto'] -function makeSteps(seed: number, smooth: number): Array { +function makeSteps( + seed: number, + smooth: number, + weights: Config['weights'] = {}, +): Array { const r = rng(seed) const int = (n: number) => Math.floor(r() * n) const behavior = (): Behavior => (r() < smooth ? 'smooth' : 'auto') @@ -155,10 +173,10 @@ function makeSteps(seed: number, smooth: number): Array { ], [2, () => ({ t: 'offset', to: int(5200), behavior: behavior() })], [3, () => ({ t: 'by', d: int(1200) - 600, behavior: behavior() })], - [2, () => ({ t: 'end', behavior: behavior() })], + [weights.end ?? 2, () => ({ t: 'end', behavior: behavior() })], [1, () => ({ t: 'cancel' })], [10, () => resize(1)], - [4, () => resize(2 + int(3))], + [weights.resizeMany ?? 4, () => resize(2 + int(3))], [1, () => ({ t: 'prepend', n: 1 + int(5) })], [1, () => ({ t: 'append', n: 1 + int(5) })], [10, () => ({ t: 'render' })], @@ -207,7 +225,11 @@ function runTrace( } _resetIOSDetectionForTests() try { - return simulate(config, platform, makeSteps(seed, config.smooth ?? 0.3)) + return simulate( + config, + platform, + makeSteps(seed, config.smooth ?? 0.3, config.weights), + ) } finally { delete (navigator as any).userAgent delete (globalThis as any).CSS @@ -329,6 +351,13 @@ function simulate( let lateFrame: number | null = null let writes: Array = [] let rendered: Array = [] + // Size the container to the total. One that shrinks under the position + // clamps it; the scroll event for that comes with the next event step. A + // bounce past the top is left alone: the content cannot clamp it. + const commitSizer = () => { + el.size = v.getTotalSize() + (v.options.scrollMargin ?? 0) + el.pos = Math.min(el.pos, max()) + } const v = new Virtualizer({ count: ids.length, @@ -369,15 +398,14 @@ function simulate( cb(el.pos, false) return () => {} }, + ...(config.commitPerRow && { onChange: () => commitSizer() }), ...options, }) config.setup?.(v) const unmount = v._didMount() // What a framework adapter does on render: read the items, mount and - // measure their rows, run the update hook, and size the container. A - // container that shrinks under the position clamps it; the scroll event - // for that comes with the next event step. + // measure their rows, run the update hook, and size the container. const render = () => { const items = v.getVirtualItems() rendered = items.map((item) => item.index) @@ -409,10 +437,10 @@ function simulate( } } if (unmounted) v.measureElement(null) + // react-virtual sizes the container before its update hook runs. + if (config.commitPerRow) commitSizer() v._willUpdate() - el.size = v.getTotalSize() + (v.options.scrollMargin ?? 0) - // A bounce past the top is left alone: the content cannot clamp it. - el.pos = Math.min(el.pos, max()) + commitSizer() // The first row's exposed start as well as its index: a correction held // while a write is impossible shifts what the rows render at, not which // rows render, and this is where that shows. @@ -484,7 +512,8 @@ function simulate( const row = rows.get(ids[index]!) if (row && !changed.includes(row)) changed.push(row) }) - if (changed.length > 0) ro.deliver?.(changed) + if (config.commitPerRow) changed.forEach((row) => ro.deliver?.([row])) + else if (changed.length > 0) ro.deliver?.(changed) extra = changed.map((row) => `#${row.index}`).join('') break } From 6789f7affc32c3fe77d8ed5b3b9d7c274d158be9 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Thu, 15 Oct 2026 14:33:11 +0200 Subject: [PATCH 22/46] fix(virtual-core): stop an iOS fling for a command issued during it (#1332) --- .changeset/ios-fling-stop.md | 7 + packages/virtual-core/src/index.ts | 88 ++++++++++- .../virtual-core/tests/ios-fling-wait.test.ts | 143 ++++++++++++++++++ 3 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 .changeset/ios-fling-stop.md diff --git a/.changeset/ios-fling-stop.md b/.changeset/ios-fling-stop.md new file mode 100644 index 000000000..43d1ae045 --- /dev/null +++ b/.changeset/ios-fling-stop.md @@ -0,0 +1,7 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): on iOS 27 and later, a scroll command issued during a fling takes effect at once + +A `scrollToIndex`, `scrollToOffset` or `scrollToEnd` issued during a fling waited for the fling to end, so a button outside the list (such as "jump to latest") did nothing until the list stopped. The command now stops the fling by hiding the scroll element's overflow for two frames, then restores the element's own inline value and writes. The user taking over before the write still cancels the command. diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index bbcdbc935..b66fbe1ce 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1004,6 +1004,10 @@ export class Virtualizer< this.rafId = null } this.scrollState = null + // The element is let go of: give it back its overflow now. A stop the + // next element needs starts afresh. + this._momentumStop?.restore() + this._momentumStop = null // The debounce cancelled above is the only thing that writes `isScrolling` // back to false, so a cleanup inside the reset window would strand it, and // the direction derived from it, as true. That matters because `cleanup` @@ -2203,6 +2207,60 @@ export class Virtualizer< return null } + // Stop a running iOS fling, then call `done`: the momentum does not + // survive the element losing its overflow for a frame. Hidden and + // restored within one task, iOS never sees the change and the fling + // carries on (27.0.1 iPhone); hidden for two frames, it stops at once. + // Nothing moves on screen. A second call while one is in progress waits + // for the same frames. The app's inline value comes back, unless the app + // set a new one meanwhile; `done` is then not called. + private _momentumStop: { restore: () => boolean; done: () => void } | null = + null + private _stopMomentum = (done: () => void) => { + const el = this.scrollElement + const win = this.targetWindow + if (el === null || win === null) return + const scroller = ( + 'document' in el ? el.document.scrollingElement : el + ) as HTMLElement | null + // A test double may have no style to change. + if (!scroller?.style) return + if (this._momentumStop) { + this._momentumStop.done = done + return + } + const { style } = scroller + const property = this.options.horizontal ? 'overflow-x' : 'overflow-y' + const value = style.getPropertyValue(property) + const priority = style.getPropertyPriority(property) + style.setProperty(property, 'hidden', 'important') + const stop = { + // False when the app replaced the hidden value: the fling may not + // have stopped. + restore: () => { + if ( + style.getPropertyValue(property) !== 'hidden' || + style.getPropertyPriority(property) !== 'important' + ) { + return false + } + style.setProperty(property, value, priority) + return true + }, + done, + } + this._momentumStop = stop + win.requestAnimationFrame(() => + win.requestAnimationFrame(() => { + // Already restored by `cleanup`, which also drops its command. + if (this._momentumStop !== stop) return + this._momentumStop = null + // The command waits for the tail, as without the stop. + if (stop.restore()) stop.done() + }), + ) + } + // A compensation write past the element's current max (the sizer has not // grown yet) is clamped by the browser; record it for the retry (#1258). private _clampedBy = (target: number) => { @@ -2384,12 +2442,32 @@ export class Virtualizer< if (tail === 'fling' && iosMomentumOverridesWrites()) { // iOS 27: a write now shows for one frame and the fling carries on // along its own path, so the command would be lost (#1307, step 3). - // Leave the tail open and write when it closes, ~150ms after the - // last fling frame; the user taking over before then (a drag, a + // Stop the fling and write two frames later. Otherwise (no style to + // change, a finger down, a bounce) the tail stays open and + // the command is written when it closes, ~150ms after the last + // fling frame. The user taking over before the write (a drag, a // second flick, a finger stopping the fling) cancels the command - // (see the scroll handler and `onTailClosed`). A relative command (`scrollBy`) - // is not postponed: its target is taken from the position at the - // call, which the fling leaves behind either way. + // (see the scroll handler and `onTailClosed`). A relative command + // (`scrollBy`) is not postponed: its target is taken from the + // position at the call, which the fling leaves behind either way. + this._stopMomentum(() => { + const state = this.scrollState + if ( + state?.phase !== 'queued' || + // A finger came down: it owns the scroll until it lifts. + this._iosGesture.tail() !== 'fling' || + // In a bounce WebKit can drop the write (#1287): the tail + // decides, as without the stop. (Not `flingCaught`: our stop + // also ends the fling at speed, so a slow frame would read as + // a catch and the tail would drop the command.) + this._reportedOverscroll + ) { + return + } + this._iosGesture.end() + this._flushHoldIfReady(true) + this._writeAwaitingCommand() + }) this.scrollState = { ...target, startedAt: this.now(), diff --git a/packages/virtual-core/tests/ios-fling-wait.test.ts b/packages/virtual-core/tests/ios-fling-wait.test.ts index e90c75075..4254a6ce7 100644 --- a/packages/virtual-core/tests/ios-fling-wait.test.ts +++ b/packages/virtual-core/tests/ios-fling-wait.test.ts @@ -47,6 +47,149 @@ test('iOS 27: a scrollToIndex mid-fling waits for the tail, and the fling does n expect(h.v.scrollState).toBeNull() }) +// A fling whose element records its inline overflow-y, set by the app to +// `auto !important`. +function flingWithStyle() { + const h = flingOnIOS27() + const props = new Map([['overflow-y', ['auto', 'important']]]) + Object.assign(h.el, { + style: { + getPropertyValue: (name: string) => props.get(name)?.[0] ?? '', + getPropertyPriority: (name: string) => props.get(name)?.[1] ?? '', + setProperty: (name: string, value: string, priority = '') => { + props.set(name, [value, priority]) + }, + }, + }) + return { ...h, overflow: () => props.get('overflow-y')!.join(' ') } +} + +test('iOS 27: a command mid-fling stops the fling and writes two frames later', () => { + const h = flingWithStyle() + h.run(() => { + h.v.scrollToEnd({ behavior: 'smooth' }) + // Hidden through a frame: the fling stops. Restored and written after + // the next one, without waiting for the tail. + expect(h.overflow()).toBe('hidden important') + h.frame() + expect(h.overflow()).toBe('hidden important') + expect(h.writes).toEqual([]) + h.frame() + }) + expect(h.overflow()).toBe('auto important') + expect(h.writes).toEqual([4800]) +}) + +test('iOS 27: a second command while the fling stops leaves the overflow as the app set it', () => { + const h = flingWithStyle() + h.run(() => { + h.v.scrollToIndex(10, { align: 'start' }) + h.frame() + h.v.scrollToEnd() + h.frame() + h.frame() + }) + expect(h.overflow()).toBe('auto important') + // The later command wins. + expect(h.writes).toEqual([4800]) +}) + +test('iOS 27: a fling caught and dragged back while it stops cancels the command', () => { + const h = flingWithStyle() + h.run(() => { + h.v.scrollToEnd() + h.scroll(true, 1300) + h.scroll(true, 1350) + h.frame() + h.frame() + }) + expect(h.overflow()).toBe('auto important') + expect(h.writes).toEqual([]) + expect(h.v.scrollState).toBeNull() +}) + +test('iOS 27: an overflow the app sets while the fling stops is kept', () => { + const h = flingWithStyle() + h.run(() => { + h.v.scrollToEnd() + h.el.style.setProperty('overflow-y', 'scroll', '') + h.frame() + h.frame() + }) + expect(h.overflow()).toBe('scroll ') + // The fling may still run: the tail writes when it closes. + expect(h.writes).toEqual([]) + h.run(() => h.scroll(false)) + h.expireTail() + expect(h.writes).toEqual([4800]) +}) + +test('iOS 27: an element let go of while the fling stops gets its overflow back at once', () => { + const h = flingWithStyle() + h.run(() => { + h.v.scrollToEnd() + h.v['cleanup']() + expect(h.overflow()).toBe('auto important') + h.frame() + h.frame() + }) + // Its command went with it. + expect(h.writes).toEqual([]) +}) + +test('iOS 27: a finger down while the fling stops keeps its gesture, and the command waits for it to lift', () => { + const h = flingWithStyle() + h.run(() => { + h.v.scrollToEnd() + h.frame() + }) + h.touch('touchstart') + h.run(() => h.frame()) + expect(h.overflow()).toBe('auto important') + expect(h.writes).toEqual([]) + // Still dragging: corrections stay held under it. + expect(h.v['_iosGesture'].touch?.phase).toBe('down') + h.touch('touchend') + h.expireTail() + expect(h.writes).toEqual([4800]) +}) + +test('iOS 27: a fling in a bounce when it stops is written when the tail closes', () => { + const h = flingWithStyle() + h.run(() => { + h.v.scrollToEnd() + h.scroll(true, -40) + h.frame() + h.frame() + }) + // WebKit can drop a write mid-bounce: wait for the tail. + expect(h.writes).toEqual([]) + h.run(() => { + h.scroll(true, 0) + h.scroll(false) + }) + h.expireTail() + expect(h.writes).toEqual([4800]) +}) + +test('iOS 27: a fling the stop ends at speed over a slow frame is still written', () => { + const h = flingWithStyle() + h.run(() => { + h.v.scrollToEnd() + // Fling frames 45ms apart on a busy main thread, then the stop holds: + // the events end at speed, which is not a finger's catch. + h.advance(5) + h.scroll(true, 1480) + h.frame() + h.advance(45) + h.scroll(true, 1390) + h.frame() + }) + expect(h.writes).toEqual([4800]) + h.expireTail() + expect(h.writes).toEqual([4800]) +}) + 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' })) From bdaf32bb7cea32fb185fadf498d4d1e5ef4552f8 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Thu, 15 Oct 2026 15:15:08 +0200 Subject: [PATCH 23/46] docs(examples): rebuild the React chat example as a ChatGPT-style app (#1331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(examples): rebuild the React chat example as a ChatGPT-style layout Replace the debug toolbar with a sidebar (new chat, chat history) and a message pane with a composer. Data comes from an in-memory mock API through TanStack Query: chats via useQuery, messages via useInfiniteQuery with older pages prepended, and sends via useMutation. Assistant replies are generated and streamed word by word into the query cache, so the example needs no server or API key. Co-Authored-By: Claude Opus 5.5 * docs(examples): fix clipped sidebar rows and small icons in the chat example The chat list rows shrank below their text height once the list overflowed, which cut off the text and the selected-row highlight on phones. Keep them at their natural height, and replace the text-glyph icons with 20px SVG icons in 40px buttons. Co-Authored-By: Claude Opus 5.5 * docs(examples): jump to the end after a send in the chat example The empty assistant reply is added in the same render as the user message, so the last message was never the user's and scrollToEnd() never ran. Watch the latest user message instead, and skip the first render. Co-Authored-By: Claude Opus 5.5 * docs(examples): hide the jump button when the chat opens at the end React's onScroll runs before the virtualizer reads the scroll event, so isAtEnd() there saw the previous position and the button stayed visible after the list landed at the end. Update the at-end state in the virtualizer's onChange instead. Co-Authored-By: Claude Opus 5.5 * docs(examples): simplify the chat example - Seed 5 chats instead of 25, and generate replies from one sentence bank (no lists or code blocks), so the mock API and rendering are shorter. - Add the streamed reply with its first words, which removes the typing indicator and fixes an empty reply left behind by an early Stop. - Scroll to the end in one layout effect keyed on the latest user message, replacing the initial-scroll state, the seen-message ref and the history-load timer. - Let the composer grow with field-sizing: content instead of JavaScript. Co-Authored-By: Claude Opus 5.5 * docs(examples): add prompt hints to the empty chat Show three prompts under the composer when no chat is open. A tap sends the prompt as the first message, so a new chat and a streamed reply start in one step. Co-Authored-By: Claude Opus 5.5 * docs(examples): link the chat blog post and add icons to the prompt hints - Answer "How do I virtualize a chat list?" with a fixed reply that links Tanner Linsley's "Chat UIs Are Lists Until They Aren't", and render links in assistant messages. - Add a colored Feather icon to each prompt hint. - Rename the seeded "Plan a weekend in Kraków" chat to Rzeszów. Co-Authored-By: Claude Opus 5.5 * docs(examples): keep the chat opened while a send is in flight The send selects its chat when it finishes, about 100 ms later. Opening another chat in that time was undone. Select the sent-to chat only when the selection has not changed since the send started. Co-Authored-By: Claude Opus 5.5 * docs(examples): load older messages after a direct jump to the top The scroll handler returned early when isAtEnd() was true. React's onScroll runs before the virtualizer reads the event, so after one jump from the end to the top it still saw the old position, skipped the load and did not record scrollTop. Drop the check: scrolling up near the top already gates the load. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- examples/react/chat/README.md | 5 + examples/react/chat/package.json | 1 + examples/react/chat/src/api.ts | 224 ++++++++++++ examples/react/chat/src/index.css | 369 ++++++++++++++++--- examples/react/chat/src/main.tsx | 561 +++++++++++++++++++---------- examples/react/chat/src/queries.ts | 113 ++++++ pnpm-lock.yaml | 3 + 7 files changed, 1039 insertions(+), 237 deletions(-) create mode 100644 examples/react/chat/src/api.ts create mode 100644 examples/react/chat/src/queries.ts diff --git a/examples/react/chat/README.md b/examples/react/chat/README.md index fc23edc9a..94cc86a96 100644 --- a/examples/react/chat/README.md +++ b/examples/react/chat/README.md @@ -7,6 +7,11 @@ Demonstrates end-anchored virtualization for chat-style UIs: - follows appended output only when the viewport is already at the latest message - keeps streaming bottom output pinned as the last row grows +The UI is a simple ChatGPT-style layout: a sidebar with chat history and a +message pane with a composer. Data comes from an in-memory mock API +(`src/api.ts`) through TanStack Query, and assistant replies are generated and +streamed word by word, so the example needs no server or API key. + ```bash npm install npm run dev diff --git a/examples/react/chat/package.json b/examples/react/chat/package.json index 10a281769..f1959bcc3 100644 --- a/examples/react/chat/package.json +++ b/examples/react/chat/package.json @@ -8,6 +8,7 @@ "serve": "vite preview" }, "dependencies": { + "@tanstack/react-query": "^5.80.7", "@tanstack/react-virtual": "^3.14.19", "react": "^19.2.7", "react-dom": "^19.2.7" diff --git a/examples/react/chat/src/api.ts b/examples/react/chat/src/api.ts new file mode 100644 index 000000000..9d592d442 --- /dev/null +++ b/examples/react/chat/src/api.ts @@ -0,0 +1,224 @@ +// An in-memory stand-in for a chat backend. Seeded chats are generated on +// first read from a fixed seed, so the history is the same after a reload. + +export type Author = 'user' | 'assistant' + +export type Message = { + id: string + author: Author + text: string +} + +export type Chat = { + id: string + title: string + updatedAt: number +} + +export type MessagePage = { + messages: Message[] + // Index of the first message of this page in the full chat history. + start: number +} + +const PAGE_SIZE = 30 +const READ_LATENCY = 300 +const WRITE_LATENCY = 50 + +type Random = () => number + +function seededRandom(seed: number): Random { + return () => { + seed = (seed + 0x6d2b79f5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +const pick = (random: Random, items: ReadonlyArray): T => + items[Math.floor(random() * items.length)]! + +const between = (random: Random, min: number, max: number) => + min + Math.floor(random() * (max - min + 1)) + +const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) + +const titles = [ + 'Debounce a search input', + 'Virtualize a 10k row table', + 'Explain React Suspense', + 'Plan a weekend in Rzeszów', + 'Beginner running plan', +] + +const followUps = [ + 'Can you explain that in more detail?', + 'Show me an example.', + 'What are the trade-offs?', + 'How would I test this?', + 'Can you make it shorter?', + 'Why does that work?', + 'What if the list has 10,000 items?', + 'Is there a simpler way?', + 'What would you do differently in production?', + 'Ok, and what is the next step?', + 'Can you put that in a table or a list?', + 'Thanks! One more question about the last point.', +] + +const sentences = [ + 'Start with the smallest version that works and measure it before you optimize.', + 'The main cost is usually not the code itself but the coordination around it.', + 'If the input changes often, keep the derived values out of state and compute them on render.', + 'A good default is to keep the data close to where it is used.', + 'You can always add a cache later, but removing one is much harder.', + 'Most of the time the simple solution is fast enough.', + 'Try to make the common case fast and the rare case correct.', + 'Write down the assumptions first, because they tend to change.', + 'This also makes the behavior easier to test in isolation.', + 'Watch out for edge cases at the boundaries, such as empty lists and very long values.', + 'Keep each step small enough that you can undo it without losing much.', + 'In practice, people tend to overestimate how much flexibility they need.', + 'The trade-off is a bit more setup in exchange for fewer surprises later.', + 'When in doubt, pick the option that is easier to change.', + 'It helps to look at a real example before you generalize.', + 'Consistency matters more than the exact choice.', +] + +// One to three paragraphs of random sentences, so rows have different heights. +function generateReply(random: Random, prompt: string): string { + const paragraphs = Array.from({ length: between(random, 1, 3) }, () => + Array.from({ length: between(random, 1, 4) }, () => + pick(random, sentences), + ).join(' '), + ) + return [`You asked: “${prompt}”`, ...paragraphs].join('\n\n') +} + +// Fixed replies for prompts that deserve a real answer. +const cannedReplies: Record = { + 'How do I virtualize a chat list?': [ + 'You asked: “How do I virtualize a chat list?”', + 'A chat is a virtual list anchored to the end: new messages arrive at the bottom, older history loads at the top, and replies grow while they stream. This example uses anchorTo: "end" with stable keys from getItemKey, followOnAppend with scrollEndThreshold, and scrollToEnd() with isAtEnd() for the jump button.', + "Tanner Linsley explains it in “Chat UIs Are Lists Until They Aren't”: https://tanstack.com/blog/tanstack-virtual-chat", + ].join('\n\n'), +} + +const now = Date.now() +const chats: Chat[] = titles.map((title, index) => ({ + id: `chat-${index}`, + title, + updatedAt: now - index * 5 * 60 * 60 * 1000, +})) +const messagesByChat = new Map() +let nextId = 0 + +function seedMessages(chatId: string): Message[] { + const chatIndex = chats.findIndex((chat) => chat.id === chatId) + const random = seededRandom(chatIndex + 1) + const title = chats[chatIndex]!.title + const turns = between(random, 20, 150) + const messages: Message[] = [] + for (let turn = 0; turn < turns; turn++) { + const prompt = + turn === 0 ? `${title}. Where should I start?` : pick(random, followUps) + messages.push({ id: `${chatId}-${turn}-q`, author: 'user', text: prompt }) + messages.push({ + id: `${chatId}-${turn}-a`, + author: 'assistant', + text: generateReply(random, prompt), + }) + } + return messages +} + +function getMessages(chatId: string): Message[] { + let messages = messagesByChat.get(chatId) + if (!messages) { + messages = seedMessages(chatId) + messagesByChat.set(chatId, messages) + } + return messages +} + +function touchChat(chatId: string) { + const chat = chats.find((item) => item.id === chatId)! + chat.updatedAt = Date.now() +} + +export async function fetchChats(): Promise { + await delay(READ_LATENCY) + return [...chats].sort((a, b) => b.updatedAt - a.updatedAt) +} + +// Returns the page of messages that ends right before `before`, or the +// latest page when `before` is null. +export async function fetchMessages( + chatId: string, + before: number | null, +): Promise { + await delay(READ_LATENCY) + const messages = getMessages(chatId) + const end = before ?? messages.length + const start = Math.max(0, end - PAGE_SIZE) + return { messages: messages.slice(start, end), start } +} + +export async function createChat(firstMessage: string): Promise { + await delay(WRITE_LATENCY) + const title = + firstMessage.length > 40 + ? `${firstMessage.slice(0, 40).trim()}…` + : firstMessage + const chat: Chat = { id: `chat-new-${nextId++}`, title, updatedAt: 0 } + chats.push(chat) + messagesByChat.set(chat.id, []) + return chat +} + +export async function postMessage( + chatId: string, + text: string, +): Promise { + await delay(WRITE_LATENCY) + const message: Message = { id: `m-${nextId++}`, author: 'user', text } + getMessages(chatId).push(message) + touchChat(chatId) + return message +} + +// Streams a generated reply word by word. `onUpdate` gets the whole message +// so far on every chunk. Returns a stop function that keeps the partial reply. +export function streamReply( + chatId: string, + prompt: string, + onUpdate: (message: Message) => void, + onDone: () => void, +): () => void { + const messages = getMessages(chatId) + const index = messages.length + const reply = cannedReplies[prompt] ?? generateReply(Math.random, prompt) + const words = reply.match(/\s*\S+/g) ?? [] + let message: Message = { id: `m-${nextId++}`, author: 'assistant', text: '' } + let position = 0 + + const intervalId = setInterval(() => { + const count = 1 + Math.floor(Math.random() * 3) + message = { + ...message, + text: message.text + words.slice(position, position + count).join(''), + } + position += count + messages[index] = message + onUpdate(message) + if (position >= words.length) { + clearInterval(intervalId) + touchChat(chatId) + onDone() + } + }, 40) + + return () => clearInterval(intervalId) +} diff --git a/examples/react/chat/src/index.css b/examples/react/chat/src/index.css index e8e379d47..6b4b61ca7 100644 --- a/examples/react/chat/src/index.css +++ b/examples/react/chat/src/index.css @@ -2,6 +2,33 @@ box-sizing: border-box; } +:root { + --bg: #ffffff; + --sidebar-bg: #f9f9f9; + --hover: #ececec; + --text: #0d0d0d; + --muted: #6b6b6b; + --border: #e5e5e5; + --bubble: #f1f1f1; + --accent: #0d0d0d; + --accent-text: #ffffff; + --sidebar-width: 260px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #212121; + --sidebar-bg: #171717; + --hover: #2f2f2f; + --text: #ececec; + --muted: #9b9b9b; + --border: #3a3a3a; + --bubble: #303030; + --accent: #ececec; + --accent-text: #0d0d0d; + } +} + html { font-family: Inter, @@ -11,8 +38,8 @@ html { BlinkMacSystemFont, 'Segoe UI', sans-serif; - color: #171717; - background: #f6f8fa; + color: var(--text); + background: var(--bg); } body { @@ -20,85 +47,335 @@ body { } button { - border: 1px solid #c6d0da; - border-radius: 6px; - background: #ffffff; - color: #171717; - cursor: pointer; font: inherit; - font-size: 13px; - padding: 7px 10px; -} - -button:hover { - background: #eef3f7; + color: inherit; + cursor: pointer; } .App { - height: 100vh; + height: 100dvh; display: grid; - grid-template-rows: auto 1fr; + grid-template-columns: var(--sidebar-width) minmax(0, 1fr); +} + +.App[data-sidebar-open='false'] { + grid-template-columns: 0 minmax(0, 1fr); } -.Toolbar { +/* Sidebar */ + +.Sidebar { + background: var(--sidebar-bg); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + padding: 8px; +} + +.App[data-sidebar-open='false'] .Sidebar { + visibility: hidden; +} + +.SidebarHeader { align-items: center; - background: #ffffff; - border-bottom: 1px solid #d9e0e6; display: flex; - gap: 8px; justify-content: space-between; - padding: 10px 12px; + padding: 4px 4px 8px 8px; +} + +.Brand { + font-weight: 600; } -.ToolbarGroup { +.IconButton { + background: none; + border: 0; + border-radius: 8px; + display: grid; + height: 40px; + place-items: center; + width: 40px; +} + +.IconButton:hover { + background: var(--hover); +} + +.NewChat { + align-items: center; display: flex; - gap: 8px; + gap: 10px; +} + +.NewChat, +.ChatLink { + background: none; + border: 0; + border-radius: 8px; + flex: none; + overflow: hidden; + padding: 8px 10px; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +} + +.NewChat:hover, +.ChatLink:hover, +.NewChat[aria-current='page'], +.ChatLink[aria-current='page'] { + background: var(--hover); } -.Status { - color: #5c6670; +.SidebarLabel { + color: var(--muted); font-size: 13px; + padding: 20px 10px 6px; } -.Shell { - display: grid; +.ChatList { + display: flex; + flex-direction: column; + font-size: 14px; + min-height: 0; + overflow-y: auto; +} + +.SidebarHint { + color: var(--muted); + font-size: 14px; + padding: 8px 10px; +} + +.Backdrop { + display: none; +} + +/* Main */ + +.Main { + display: flex; + flex-direction: column; min-height: 0; + min-width: 0; +} + +.MainHeader { + align-items: center; + display: flex; + gap: 8px; + height: 52px; + padding: 0 12px; +} + +.MainTitle { + font-weight: 500; overflow: hidden; - place-items: stretch; + text-overflow: ellipsis; + white-space: nowrap; } -.Messages { +.Pane { + flex: 1; min-height: 0; + position: relative; +} + +.Messages { + height: 100%; overflow: auto; overflow-anchor: none; - width: 100%; } +.PaneHint { + color: var(--muted); + flex: 1; + padding: 24px; + text-align: center; +} + +.HistoryLoader { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + font-size: 13px; + left: 50%; + padding: 4px 12px; + position: absolute; + top: 8px; + transform: translateX(-50%); +} + +.JumpToLatest { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 999px; + bottom: 12px; + box-shadow: 0 2px 8px rgb(0 0 0 / 0.12); + display: grid; + height: 40px; + left: 50%; + place-items: center; + position: absolute; + transform: translateX(-50%); + width: 40px; +} + +/* Messages */ + .MessageRow { - padding: 6px 12px; + padding: 0 16px; } -.Bubble { - border: 1px solid #d7dee5; - border-radius: 8px; - line-height: 1.45; - max-width: min(720px, 88vw); - padding: 10px 12px; +.Message { + line-height: 1.6; + margin: 0 auto; + max-width: 768px; + padding: 12px 0; +} + +.Message-user { + display: flex; + justify-content: flex-end; +} + +.UserBubble { + background: var(--bubble); + border-radius: 18px; + max-width: 70%; + padding: 8px 16px; white-space: pre-wrap; } -.Bubble-user { - background: #e6f3ff; - margin-left: auto; +.Message-assistant { + white-space: pre-wrap; +} + +.Message-assistant a { + color: inherit; + overflow-wrap: anywhere; +} + +/* Composer */ + +.ComposerDock { + padding: 0 16px 16px; } -.Bubble-assistant { - background: #ffffff; - margin-right: auto; +.Empty { + align-items: center; + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + padding: 0 16px 15vh; } -.Meta { - color: #637081; - font-size: 12px; - margin-bottom: 4px; +.Empty h1 { + font-size: 28px; + font-weight: 500; + margin: 0 0 24px; +} + +.Suggestions { + display: flex; + flex-direction: column; + margin-top: 16px; + max-width: 768px; + width: 100%; +} + +.Suggestion { + align-items: center; + background: none; + border: 0; + border-radius: 12px; + color: var(--muted); + display: flex; + gap: 12px; + padding: 10px 20px; + text-align: left; +} + +.Suggestion span { + display: flex; +} + +.Suggestion:hover:not(:disabled) { + background: var(--hover); + color: var(--text); +} + +.Composer { + align-items: flex-end; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 28px; + box-shadow: 0 2px 12px rgb(0 0 0 / 0.06); + display: flex; + gap: 8px; + margin: 0 auto; + max-width: 768px; + padding: 10px 10px 10px 20px; + width: 100%; +} + +.Composer textarea { + background: none; + border: 0; + color: inherit; + field-sizing: content; + flex: 1; + font: inherit; + line-height: 1.5; + max-height: 200px; + outline: none; + padding: 6px 0; + resize: none; +} + +.SendButton { + background: var(--accent); + border: 0; + border-radius: 50%; + color: var(--accent-text); + display: grid; + flex: none; + height: 40px; + place-items: center; + width: 40px; +} + +.SendButton:disabled { + cursor: default; + opacity: 0.3; +} + +/* Narrow screens: the sidebar slides over the chat. */ + +@media (max-width: 768px) { + .App, + .App[data-sidebar-open='false'] { + grid-template-columns: minmax(0, 1fr); + } + + .Sidebar { + bottom: 0; + left: 0; + position: fixed; + top: 0; + width: var(--sidebar-width); + z-index: 2; + } + + .App[data-sidebar-open='true'] .Backdrop { + background: rgb(0 0 0 / 0.4); + display: block; + inset: 0; + position: fixed; + z-index: 1; + } } diff --git a/examples/react/chat/src/main.tsx b/examples/react/chat/src/main.tsx index f37cc3d6f..0881665bf 100644 --- a/examples/react/chat/src/main.tsx +++ b/examples/react/chat/src/main.tsx @@ -1,49 +1,230 @@ -import React from 'react' +import * as React from 'react' import { createRoot } from 'react-dom/client' +import { QueryClientProvider } from '@tanstack/react-query' import { useVirtualizer } from '@tanstack/react-virtual' +import { queryClient, useChats, useMessages, useSendMessage } from './queries' +import type { Message } from './api' import './index.css' -type Message = { - id: string - author: 'user' | 'assistant' - text: string +// Feather icon paths (MIT, feathericons.com), 24×24 strokes. +const icons = { + menu: 'M3 6h18M3 12h18M3 18h18', + close: 'M15 18l-6-6 6-6', + newChat: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z', + up: 'M12 19V5M5 12l7-7 7 7', + down: 'M12 5v14M19 12l-7 7-7-7', + stop: 'M7 7h10v10H7Z', } -const replies = [ - 'I can break that into the smallest next step and keep the current viewport pinned while this answer grows.', - 'Older messages are loaded above the viewport. The visible row keeps the same screen position after the prepend.', - 'When the thread is not at the bottom, new output waits below without pulling the reader away from history.', +function Icon({ path }: { path: string }) { + return ( + + ) +} + +const suggestions = [ + { + text: 'How do I virtualize a chat list?', + icon: 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z', + color: '#3b82f6', + }, + { + text: 'Explain scroll anchoring in one paragraph.', + icon: 'M12 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM12 22V8M5 12H2a10 10 0 0 0 20 0h-3', + color: '#f59e0b', + }, + { + text: 'What should I test in an infinite list?', + icon: 'M22 11.08V12a10 10 0 1 1-5.93-9.14M22 4 12 14.01l-3-3', + color: '#10b981', + }, ] -const makeMessage = (index: number): Message => ({ - id: `message-${index}`, - author: index % 4 === 0 ? 'user' : 'assistant', - text: - index % 4 === 0 - ? `Can you check item ${index}?` - : `Message ${index}: ${replies[Math.abs(index) % replies.length]}`, -}) - -const initialMessages = Array.from({ length: 45 }, (_, index) => - makeMessage(index), -) +const isNarrow = () => window.matchMedia('(max-width: 768px)').matches function App() { + const [activeChatId, setActiveChatId] = React.useState(null) + const [sidebarOpen, setSidebarOpen] = React.useState(() => !isNarrow()) + const chats = useChats() + const { send, sending, streaming, stop } = useSendMessage() + const activeChat = chats.data?.find((chat) => chat.id === activeChatId) + + const openChat = (chatId: string | null) => { + setActiveChatId(chatId) + if (isNarrow()) setSidebarOpen(false) + } + + const onSend = (text: string) => { + send( + { chatId: activeChatId, text }, + { + // Keep a chat the user opened while the send was in flight. + onSuccess: ({ chatId }) => + setActiveChatId((current) => + current === activeChatId ? chatId : current, + ), + }, + ) + } + + const composer = ( + + ) + + return ( +
+ + + ) +} + +function ChatPane({ chatId }: { chatId: string }) { + const query = useMessages(chatId) + + if (query.isPending) { + return
Loading messages…
+ } + if (query.isError) { + return
Could not load messages.
+ } + + return ( + page.messages)} + loadingOlder={query.isFetchingPreviousPage} + onLoadOlder={() => { + if (query.hasPreviousPage) { + query.fetchPreviousPage({ cancelRefetch: false }) + } + }} + /> + ) +} + +function MessageList({ + messages, + loadingOlder, + onLoadOlder, +}: { + messages: Message[] + loadingOlder: boolean + onLoadOlder: () => void +}) { const parentRef = React.useRef(null) - const firstMessageIndexRef = React.useRef(0) - const nextMessageIndexRef = React.useRef(initialMessages.length) - const streamTimerRef = React.useRef(null) - const loadingHistoryRef = React.useRef(false) const lastScrollTopRef = React.useRef(0) - const [messages, setMessages] = React.useState(initialMessages) - const [loadingHistory, setLoadingHistory] = React.useState(false) - const [didInitialScroll, setDidInitialScroll] = React.useState(false) - const [autoHistoryEnabled, setAutoHistoryEnabled] = React.useState(false) + const [atEnd, setAtEnd] = React.useState(true) const virtualizer = useVirtualizer({ count: messages.length, getScrollElement: () => parentRef.current, - estimateSize: () => 74, + estimateSize: () => 120, getItemKey: React.useCallback( (index: number) => messages[index]!.id, [messages], @@ -53,179 +234,177 @@ function App() { scrollEndThreshold: 80, overscan: 6, directDomUpdates: true, + // Runs after the virtualizer reads each scroll event. React's onScroll + // runs before it, so isAtEnd() there still sees the previous position. + onChange: (instance) => setAtEnd(instance.isAtEnd()), }) - const virtualItems = virtualizer.getVirtualItems() - - const prependHistory = React.useCallback(() => { - if (loadingHistoryRef.current || firstMessageIndexRef.current <= -90) { - return - } - - loadingHistoryRef.current = true - setLoadingHistory(true) - window.setTimeout(() => { - const start = firstMessageIndexRef.current - 12 - firstMessageIndexRef.current = start - setMessages((current) => [ - ...Array.from({ length: 12 }, (_, offset) => - makeMessage(start + offset), - ), - ...current, - ]) - loadingHistoryRef.current = false - setLoadingHistory(false) - }, 180) - }, []) - - const appendMessage = React.useCallback(() => { - const next = nextMessageIndexRef.current - nextMessageIndexRef.current += 1 - setMessages((current) => [...current, makeMessage(next)]) - }, []) - - const streamReply = React.useCallback(() => { - if (streamTimerRef.current !== null) return - - const id = `stream-${Date.now()}` - const chunks = [ - 'Thinking through the failure mode.', - ' The list should follow only when it was already pinned.', - ' Prepends should keep the reader anchored to the same message.', - ' Streaming output should grow without drifting off the bottom.', - ] - let chunkIndex = 0 - - setMessages((current) => [ - ...current, - { - id, - author: 'assistant', - text: '', - }, - ]) - - streamTimerRef.current = window.setInterval(() => { - setMessages((current) => - current.map((message) => - message.id === id - ? { - ...message, - text: chunks.slice(0, chunkIndex + 1).join(''), - } - : message, - ), - ) - - chunkIndex += 1 - if (chunkIndex === chunks.length && streamTimerRef.current !== null) { - window.clearInterval(streamTimerRef.current) - streamTimerRef.current = null - } - }, 280) - }, []) - + // Start at the latest message, and jump to it again after each send, like + // most chat apps. Assistant output follows only when the reader is already + // at the latest message (followOnAppend). + const lastUserMessageId = [...messages] + .reverse() + .find((message) => message.author === 'user')?.id React.useLayoutEffect(() => { - if (didInitialScroll) return virtualizer.scrollToEnd() - setDidInitialScroll(true) - }, [didInitialScroll, virtualizer]) - - React.useEffect(() => { - const id = window.setTimeout(() => { - setAutoHistoryEnabled(true) - }, 250) - - return () => window.clearTimeout(id) - }, []) - - React.useEffect(() => { - return () => { - if (streamTimerRef.current !== null) { - window.clearInterval(streamTimerRef.current) - } - } - }, []) + }, [lastUserMessageId, virtualizer]) return ( -
-
-
- - - - -
-
- {loadingHistory - ? 'Loading history' - : virtualizer.isAtEnd(80) - ? 'At latest' - : 'Reading history'} -
-
- -
+
+
{ + const { scrollTop } = event.currentTarget + const movingUp = scrollTop < lastScrollTopRef.current + lastScrollTopRef.current = scrollTop + // Load only while scrolling up. Safari's rubber band reports + // negative positions past the top; ignore them. + if (movingUp && scrollTop >= 0 && scrollTop < 400) { + onLoadOlder() + } + }} + >
{ - if (!autoHistoryEnabled || virtualizer.isAtEnd(80)) return - const { scrollTop } = event.currentTarget - const movingUp = scrollTop < lastScrollTopRef.current - lastScrollTopRef.current = scrollTop - // Load only while the reader scrolls up into the top. Safari's - // rubber band reports negative positions past the top and then - // settles back down to 0; neither is the reader asking for more - // history, and loading on the settle would load twice per bounce. - if (movingUp && scrollTop >= 0 && scrollTop < 120) { - prependHistory() - } + ref={virtualizer.containerRef} + style={{ + position: 'relative', + width: '100%', }} > -
- {virtualItems.map((virtualItem) => { - const message = messages[virtualItem.index]! - - return ( -
-
-
{message.author}
- {message.text || '...'} -
-
- ) - })} -
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const message = messages[virtualItem.index]! + + return ( +
+ +
+ ) + })}
+ + {loadingOlder &&
Loading older…
} + {!atEnd && ( + + )}
) } -createRoot(document.getElementById('root')!).render() +function MessageView({ message }: { message: Message }) { + if (message.author === 'user') { + return ( +
+
{message.text}
+
+ ) + } + + return ( +
+ {message.text.split(/(https?:\/\/\S+)/).map((part, index) => + index % 2 ? ( + + {part} + + ) : ( + part + ), + )} +
+ ) +} + +function Composer({ + disabled, + streaming, + onSend, + onStop, +}: { + disabled: boolean + streaming: boolean + onSend: (text: string) => void + onStop: () => void +}) { + const [text, setText] = React.useState('') + + const submit = () => { + const value = text.trim() + if (!value || disabled || streaming) return + onSend(value) + setText('') + } + + return ( +
{ + event.preventDefault() + submit() + }} + > +