Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-scroll-measurements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/virtual-core': patch
---

Keep scrollToIndex reconciliation active until pending ResizeObserver measurements can update its target, including when useAnimationFrameWithResizeObserver is enabled. Respect an external scroll away from a reached target during this settling period.
28 changes: 26 additions & 2 deletions packages/react-virtual/e2e/app/cached-measurements/main.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { flushSync } from 'react-dom'
import { useVirtualizer } from '@tanstack/react-virtual'

const items = Array.from({ length: 20 }, (_, i) => ({
Expand All @@ -11,21 +12,44 @@ const items = Array.from({ length: 20 }, (_, i) => ({
const App = () => {
const parentRef = React.useRef<HTMLDivElement>(null)
const [hidden, setHidden] = React.useState(false)
const [expanded, setExpanded] = React.useState(false)

const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: (i) => items[i].height,
estimateSize: (i) => items[i].height - 10,
getItemKey: (i) => items[i].id,
useCachedMeasurements: hidden,
directDomUpdates: true,
overscan: items.length,
useAnimationFrameWithResizeObserver: new URLSearchParams(
location.search,
).has('raf'),
})

return (
<div>
<button data-testid="toggle" onClick={() => setHidden((h) => !h)}>
{hidden ? 'Show' : 'Hide'}
</button>
<button
onClick={() =>
rowVirtualizer.scrollToIndex(items.length - 1, { align: 'end' })
}
>
Scroll to end
</button>
<button
onClick={() => {
flushSync(() => setExpanded(true))
rowVirtualizer.scrollToIndex(items.length - 1, { align: 'end' })
}}
>
Grow and pin
</button>
<div data-testid="scrolling">
{rowVirtualizer.isScrolling ? 'scrolling' : 'idle'}
</div>
<div
data-testid="list-wrapper"
style={{ display: hidden ? 'none' : 'block' }}
Expand All @@ -52,7 +76,7 @@ const App = () => {
top: 0,
left: 0,
width: '100%',
height: item.height,
height: item.height + (expanded && v.index === 18 ? 30 : 0),
}}
>
{item.label}
Expand Down
28 changes: 28 additions & 0 deletions packages/react-virtual/e2e/app/test/cached-measurements.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
import { expect, test } from '@playwright/test'

for (const deferred of [false, true]) {
test(`re-pins after a cached row grows (deferred observer: ${deferred})`, async ({
page,
}) => {
await page.goto(`/cached-measurements/${deferred ? '?raf' : ''}`)
const scroller = page.locator('#scroll-container')
await expect
.poll(() => scroller.evaluate((el) => el.scrollHeight))
.toBe(980)
await page.getByRole('button', { name: 'Scroll to end' }).click()
await expect.poll(() => scroller.evaluate((el) => el.scrollTop)).toBe(780)
await expect(page.getByTestId('scrolling')).toHaveText('idle')

await page.getByRole('button', { name: 'Grow and pin' }).click()

await expect
.poll(() => scroller.evaluate((el) => el.scrollHeight))
.toBe(1010)
await expect
.poll(() =>
scroller.evaluate(
(el) => el.scrollHeight - el.scrollTop - el.clientHeight,
),
)
.toBe(0)
})
}

test('preserves item sizes when list is hidden with useCachedMeasurements', async ({
page,
}) => {
Expand Down
38 changes: 34 additions & 4 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,8 @@ type ScrollState = {

// settling
stableFrames: number
// Keep arrival across measurement-driven retargets so external scrolls can cancel.
hasReachedTarget: boolean
}

type PendingScrollAnchor = [
Expand Down Expand Up @@ -924,6 +926,24 @@ export class Virtualizer<
Math.abs(offset - this._intendedScrollOffset) < 1.5
) {
offset = this._intendedScrollOffset
} else if (
this.scrollState?.index != null &&
this.scrollState.hasReachedTarget &&
!approxEqual(offset, this.scrollState.lastTargetOffset) &&
!approxEqual(
offset,
this.getOffsetForIndex(
this.scrollState.index,
this.scrollState.align,
)?.[0] ?? this.scrollState.lastTargetOffset,
) &&
(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.
this.scrollState = null
}
this._intendedScrollOffset = null

Expand Down Expand Up @@ -1176,14 +1196,21 @@ export class Virtualizer<
? offsetInfo[0]
: this.scrollState.lastTargetOffset

// Require one stable frame where target matches scroll offset.
// approxEqual() already tolerates minor fluctuations, so one frame is sufficient
// to confirm scroll has reached its target without premature cleanup.
const STABLE_FRAMES = 1
// Index targets can still depend on cached sizes during the first rAF,
// which runs before ResizeObserver delivers pending measurements. Wait
// another frame (two when measurements are themselves deferred to rAF)
// before retiring an index scroll. Absolute offsets don't need this wait.
const STABLE_FRAMES =
this.scrollState.index == null
? 1
: this.options.useAnimationFrameWithResizeObserver
? 3
: 2

const targetChanged = targetOffset !== this.scrollState.lastTargetOffset

if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) {
this.scrollState.hasReachedTarget = true
this.scrollState.stableFrames++
if (this.scrollState.stableFrames >= STABLE_FRAMES) {
// Final-pass exact landing. The reconcile-stable check uses a 1.01px
Expand Down Expand Up @@ -1942,6 +1969,7 @@ export class Virtualizer<
startedAt: now,
lastTargetOffset: offset,
stableFrames: 0,
hasReachedTarget: false,
}

this._scrollToOffset(offset, { adjustments: undefined, behavior })
Expand Down Expand Up @@ -1976,6 +2004,7 @@ export class Virtualizer<
startedAt: now,
lastTargetOffset: offset,
stableFrames: 0,
hasReachedTarget: approxEqual(offset, this.getScrollOffset()),
}

this._scrollToOffset(offset, { adjustments: undefined, behavior })
Expand All @@ -1997,6 +2026,7 @@ export class Virtualizer<
startedAt: now,
lastTargetOffset: offset,
stableFrames: 0,
hasReachedTarget: false,
}

this._scrollToOffset(offset, { adjustments: undefined, behavior })
Expand Down
163 changes: 160 additions & 3 deletions packages/virtual-core/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,14 +415,171 @@ test('scrollToIndex(0) should reconcile correctly', () => {
const calledOffset = scrollToFn.mock.calls[0]![0]
expect(calledOffset).toBe(0)

// Flush rAF — reconcileScroll should run and not bail
// It should eventually clear scrollState (settle)
rafCallbacks.forEach((cb) => cb(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 })
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('scrollToOffset should reconcile and clear scrollState', () => {
const { rafCallbacks, mockScrollElement, scrollToFn } =
createMockEnvironment()
Expand Down