Skip to content

Commit 2a631f4

Browse files
authored
fix(virtual-core): reconcile index scrolls after pending measurements (#1294)
1 parent ce57b78 commit 2a631f4

5 files changed

Lines changed: 337 additions & 18 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/virtual-core': patch
3+
---
4+
5+
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.

‎packages/react-virtual/e2e/app/cached-measurements/main.tsx‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react'
22
import ReactDOM from 'react-dom/client'
3+
import { flushSync } from 'react-dom'
34
import { useVirtualizer } from '@tanstack/react-virtual'
45

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

1517
const rowVirtualizer = useVirtualizer({
1618
count: items.length,
1719
getScrollElement: () => parentRef.current,
18-
estimateSize: (i) => items[i].height,
20+
estimateSize: (i) => items[i].height - 10,
1921
getItemKey: (i) => items[i].id,
2022
useCachedMeasurements: hidden,
2123
directDomUpdates: true,
24+
overscan: items.length,
25+
useAnimationFrameWithResizeObserver: new URLSearchParams(
26+
location.search,
27+
).has('raf'),
2228
})
2329

2430
return (
2531
<div>
2632
<button data-testid="toggle" onClick={() => setHidden((h) => !h)}>
2733
{hidden ? 'Show' : 'Hide'}
2834
</button>
35+
<button
36+
onClick={() =>
37+
rowVirtualizer.scrollToIndex(items.length - 1, { align: 'end' })
38+
}
39+
>
40+
Scroll to end
41+
</button>
42+
<button
43+
onClick={() => {
44+
flushSync(() => setExpanded(true))
45+
rowVirtualizer.scrollToIndex(items.length - 1, { align: 'end' })
46+
}}
47+
>
48+
Grow and pin
49+
</button>
50+
<div data-testid="scrolling">
51+
{rowVirtualizer.isScrolling ? 'scrolling' : 'idle'}
52+
</div>
2953
<div
3054
data-testid="list-wrapper"
3155
style={{ display: hidden ? 'none' : 'block' }}
@@ -52,7 +76,7 @@ const App = () => {
5276
top: 0,
5377
left: 0,
5478
width: '100%',
55-
height: item.height,
79+
height: item.height + (expanded && v.index === 18 ? 30 : 0),
5680
}}
5781
>
5882
{item.label}

‎packages/react-virtual/e2e/app/test/cached-measurements.spec.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
11
import { expect, test } from '@playwright/test'
22

3+
for (const deferred of [false, true]) {
4+
test(`re-pins after a cached row grows (deferred observer: ${deferred})`, async ({
5+
page,
6+
}) => {
7+
await page.goto(`/cached-measurements/${deferred ? '?raf' : ''}`)
8+
const scroller = page.locator('#scroll-container')
9+
await expect
10+
.poll(() => scroller.evaluate((el) => el.scrollHeight))
11+
.toBe(980)
12+
await page.getByRole('button', { name: 'Scroll to end' }).click()
13+
await expect.poll(() => scroller.evaluate((el) => el.scrollTop)).toBe(780)
14+
await expect(page.getByTestId('scrolling')).toHaveText('idle')
15+
16+
await page.getByRole('button', { name: 'Grow and pin' }).click()
17+
18+
await expect
19+
.poll(() => scroller.evaluate((el) => el.scrollHeight))
20+
.toBe(1010)
21+
await expect
22+
.poll(() =>
23+
scroller.evaluate(
24+
(el) => el.scrollHeight - el.scrollTop - el.clientHeight,
25+
),
26+
)
27+
.toBe(0)
28+
})
29+
}
30+
331
test('preserves item sizes when list is hidden with useCachedMeasurements', async ({
432
page,
533
}) => {

‎packages/virtual-core/src/index.ts‎

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,8 @@ type ScrollState = {
394394

395395
// settling
396396
stableFrames: number
397+
// Keep arrival across measurement-driven retargets so external scrolls can cancel.
398+
hasReachedTarget: boolean
397399
}
398400

399401
type PendingScrollAnchor = [
@@ -926,6 +928,18 @@ export class Virtualizer<
926928
Math.abs(offset - this._intendedScrollOffset) < 1.5
927929
) {
928930
offset = this._intendedScrollOffset
931+
} else if (
932+
this.scrollState?.index != null &&
933+
this.scrollState.hasReachedTarget &&
934+
!approxEqual(offset, this.scrollState.lastTargetOffset) &&
935+
!approxEqual(offset, this.getScrollStateTarget(this.scrollState)) &&
936+
(this._clampedAdjustment === null ||
937+
Math.abs(offset - this._clampedAdjustment.maxAtWrite) >= 1.5)
938+
) {
939+
// The index scroll reached its target, but an external scroll moved
940+
// away while we waited for measurements. Our own measurement updates
941+
// (including clamped writes) still need to reconcile.
942+
this.scrollState = null
929943
}
930944
this._intendedScrollOffset = null
931945

@@ -1157,6 +1171,14 @@ export class Virtualizer<
11571171
this.reconcileScroll()
11581172
})
11591173
}
1174+
private getScrollStateTarget(state: ScrollState) {
1175+
const offsetInfo = state.toEnd
1176+
? ([Math.max(this.getMaxScrollOffset(), 0)] as const)
1177+
: state.index != null
1178+
? this.getOffsetForIndex(state.index, state.align)
1179+
: undefined
1180+
return offsetInfo ? offsetInfo[0] : state.lastTargetOffset
1181+
}
11601182
private reconcileScroll() {
11611183
if (!this.scrollState) return
11621184

@@ -1170,23 +1192,23 @@ export class Virtualizer<
11701192
return
11711193
}
11721194

1173-
const offsetInfo = this.scrollState.toEnd
1174-
? ([Math.max(this.getMaxScrollOffset(), 0)] as const)
1175-
: this.scrollState.index != null
1176-
? this.getOffsetForIndex(this.scrollState.index, this.scrollState.align)
1177-
: undefined
1178-
const targetOffset = offsetInfo
1179-
? offsetInfo[0]
1180-
: this.scrollState.lastTargetOffset
1195+
const targetOffset = this.getScrollStateTarget(this.scrollState)
11811196

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

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

11891210
if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) {
1211+
this.scrollState.hasReachedTarget = true
11901212
this.scrollState.stableFrames++
11911213
if (this.scrollState.stableFrames >= STABLE_FRAMES) {
11921214
// Final-pass exact landing. The reconcile-stable check uses a 1.01px
@@ -1221,7 +1243,11 @@ export class Virtualizer<
12211243
this.scrollState.behavior === 'smooth' && distance > viewport
12221244

12231245
this.scrollState.lastTargetOffset = targetOffset
1224-
if (!keepSmooth) {
1246+
if (keepSmooth) {
1247+
// A smooth retarget travels again, so its intermediate scroll
1248+
// events must not read as an external scroll away from the target.
1249+
this.scrollState.hasReachedTarget = false
1250+
} else {
12251251
this.scrollState.behavior = 'auto'
12261252
}
12271253

@@ -1955,6 +1981,7 @@ export class Virtualizer<
19551981
startedAt: now,
19561982
lastTargetOffset: offset,
19571983
stableFrames: 0,
1984+
hasReachedTarget: false,
19581985
}
19591986

19601987
this._scrollToOffset(offset, { adjustments: undefined, behavior })
@@ -1989,6 +2016,7 @@ export class Virtualizer<
19892016
startedAt: now,
19902017
lastTargetOffset: offset,
19912018
stableFrames: 0,
2019+
hasReachedTarget: approxEqual(offset, this.getScrollOffset()),
19922020
}
19932021

19942022
this._scrollToOffset(offset, { adjustments: undefined, behavior })
@@ -2010,6 +2038,7 @@ export class Virtualizer<
20102038
startedAt: now,
20112039
lastTargetOffset: offset,
20122040
stableFrames: 0,
2041+
hasReachedTarget: false,
20132042
}
20142043

20152044
this._scrollToOffset(offset, { adjustments: undefined, behavior })
@@ -2033,6 +2062,7 @@ export class Virtualizer<
20332062
startedAt: this.now(),
20342063
lastTargetOffset: offset,
20352064
stableFrames: 0,
2065+
hasReachedTarget: approxEqual(offset, this.getScrollOffset()),
20362066
}
20372067

20382068
this._scrollToOffset(offset, { adjustments: undefined, behavior })

0 commit comments

Comments
 (0)