Skip to content

Commit a7382bb

Browse files
committed
fix(virtual-core): smooth scrolling for dynamic item sizes
1 parent c48b2ac commit a7382bb

File tree

2 files changed

+170
-94
lines changed

2 files changed

+170
-94
lines changed

examples/react/dynamic/src/main.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ function RowVirtualizerDynamic() {
2626
enabled,
2727
})
2828

29+
React.useEffect(() => {
30+
virtualizer.scrollToIndex(count - 1, { align: 'end' })
31+
}, [])
32+
2933
const items = virtualizer.getVirtualItems()
3034

3135
return (
@@ -40,7 +44,7 @@ function RowVirtualizerDynamic() {
4044
<span style={{ padding: '0 4px' }} />
4145
<button
4246
onClick={() => {
43-
virtualizer.scrollToIndex(count / 2)
47+
virtualizer.scrollToIndex(count / 2, { behavior: 'smooth' })
4448
}}
4549
>
4650
scroll to the middle

packages/virtual-core/src/index.ts

Lines changed: 165 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,22 @@ export interface VirtualizerOptions<
348348
useAnimationFrameWithResizeObserver?: boolean
349349
}
350350

351+
type ScrollState = {
352+
// what we want
353+
index: number | null
354+
align: ScrollAlignment
355+
behavior: ScrollBehavior
356+
357+
// lifecycle
358+
startedAt: number
359+
360+
// target tracking
361+
lastTargetOffset: number
362+
363+
// settling
364+
stableFrames: number
365+
}
366+
351367
export class Virtualizer<
352368
TScrollElement extends Element | Window,
353369
TItemElement extends Element,
@@ -357,7 +373,7 @@ export class Virtualizer<
357373
scrollElement: TScrollElement | null = null
358374
targetWindow: (Window & typeof globalThis) | null = null
359375
isScrolling = false
360-
private currentScrollToIndex: number | null = null
376+
private scrollState: ScrollState | null = null
361377
measurementsCache: Array<VirtualItem> = []
362378
private itemSizeCache = new Map<Key, number>()
363379
private laneAssignments = new Map<number, number>() // index → lane cache
@@ -535,6 +551,9 @@ export class Virtualizer<
535551
this.scrollOffset = offset
536552
this.isScrolling = isScrolling
537553

554+
if (this.scrollState) {
555+
this.scheduleScrollReconcile()
556+
}
538557
this.maybeNotify()
539558
}),
540559
)
@@ -546,6 +565,63 @@ export class Virtualizer<
546565
}
547566
}
548567

568+
private rafId: number | null = null
569+
private scheduleScrollReconcile() {
570+
if (!this.targetWindow) return
571+
if (this.rafId != null) return
572+
this.rafId = this.targetWindow.requestAnimationFrame(() => {
573+
this.rafId = null
574+
this.reconcileScroll()
575+
})
576+
}
577+
private reconcileScroll() {
578+
if (!this.scrollState) return
579+
580+
const el = this.scrollElement
581+
if (!el) return
582+
583+
const targetOffset = this.scrollState.index
584+
? this.getOffsetForIndex(
585+
this.scrollState.index,
586+
this.scrollState.align,
587+
)[0]
588+
: this.getOffsetForAlignment(
589+
this.scrollState.lastTargetOffset,
590+
this.scrollState.align,
591+
)
592+
593+
// Require one stable frame where target matches scroll offset.
594+
// approxEqual() already tolerates minor fluctuations, so one frame is sufficient
595+
// to confirm scroll has reached its target without premature cleanup.
596+
const STABLE_FRAMES = 1
597+
598+
const targetChanged = targetOffset !== this.scrollState.lastTargetOffset
599+
600+
if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) {
601+
this.scrollState.stableFrames++
602+
if (this.scrollState.stableFrames >= STABLE_FRAMES) {
603+
this.scrollState = null
604+
} else {
605+
this.scheduleScrollReconcile()
606+
}
607+
return
608+
}
609+
610+
this.scrollState.stableFrames = 0
611+
612+
if (targetChanged) {
613+
this.scrollState.lastTargetOffset = targetOffset
614+
// Switch to 'auto' behavior once measurements cause target to change
615+
// We want to jump directly to the correct position, not smoothly animate to it
616+
this.scrollState.behavior = 'auto'
617+
618+
this._scrollToOffset(targetOffset, {
619+
adjustments: undefined,
620+
behavior: 'auto',
621+
})
622+
}
623+
}
624+
549625
private getSize = () => {
550626
if (!this.options.enabled) {
551627
this.scrollRect = null
@@ -864,6 +940,38 @@ export class Virtualizer<
864940
return parseInt(indexStr, 10)
865941
}
866942

943+
/**
944+
* Determines if an item at the given index should be measured during smooth scroll.
945+
* During smooth scroll, only items within a buffer range around the target are measured
946+
* to prevent items far from the target from pushing it away.
947+
*/
948+
private shouldMeasureDuringScroll = (index: number): boolean => {
949+
// No scroll state or not smooth scroll - always allow measurements
950+
if (!this.scrollState || this.scrollState.behavior !== 'smooth') {
951+
return true
952+
}
953+
954+
const scrollIndex =
955+
this.scrollState.index ??
956+
this.getVirtualItemForOffset(this.scrollState.lastTargetOffset)?.index
957+
958+
if (scrollIndex !== undefined && this.range) {
959+
// Allow measurements within a buffer range around the scroll target
960+
const bufferSize = Math.max(
961+
this.options.overscan,
962+
Math.ceil((this.range.endIndex - this.range.startIndex) / 2),
963+
)
964+
const minIndex = Math.max(0, scrollIndex - bufferSize)
965+
const maxIndex = Math.min(
966+
this.options.count - 1,
967+
scrollIndex + bufferSize,
968+
)
969+
return index >= minIndex && index <= maxIndex
970+
}
971+
972+
return true
973+
}
974+
867975
private _measureElement = (
868976
node: TItemElement,
869977
entry: ResizeObserverEntry | undefined,
@@ -884,7 +992,7 @@ export class Virtualizer<
884992
this.elementsCache.set(key, node)
885993
}
886994

887-
if (node.isConnected) {
995+
if (node.isConnected && this.shouldMeasureDuringScroll(index)) {
888996
this.resizeItem(index, this.options.measureElement(node, entry, this))
889997
}
890998
}
@@ -899,14 +1007,14 @@ export class Virtualizer<
8991007

9001008
if (delta !== 0) {
9011009
if (
902-
this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
1010+
this.scrollState?.behavior !== 'smooth' &&
1011+
(this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
9031012
? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this)
904-
: item.start < this.getScrollOffset() + this.scrollAdjustments
1013+
: item.start < this.getScrollOffset() + this.scrollAdjustments)
9051014
) {
9061015
if (process.env.NODE_ENV !== 'production' && this.options.debug) {
9071016
console.info('correction', delta)
9081017
}
909-
9101018
this._scrollToOffset(this.getScrollOffset(), {
9111019
adjustments: (this.scrollAdjustments += delta),
9121020
behavior: undefined,
@@ -1018,14 +1126,15 @@ export class Virtualizer<
10181126
getOffsetForIndex = (index: number, align: ScrollAlignment = 'auto') => {
10191127
index = Math.max(0, Math.min(index, this.options.count - 1))
10201128

1129+
const size = this.getSize()
1130+
const scrollOffset = this.getScrollOffset()
1131+
10211132
const item = this.measurementsCache[index]
10221133
if (!item) {
1023-
return undefined
1134+
console.warn('No measurement found for index:', index)
1135+
return [scrollOffset, align] as const
10241136
}
10251137

1026-
const size = this.getSize()
1027-
const scrollOffset = this.getScrollOffset()
1028-
10291138
if (align === 'auto') {
10301139
if (item.end >= scrollOffset + size - this.options.scrollPaddingEnd) {
10311140
align = 'end'
@@ -1053,110 +1162,73 @@ export class Virtualizer<
10531162
] as const
10541163
}
10551164

1056-
private isDynamicMode = () => this.elementsCache.size > 0
1057-
10581165
scrollToOffset = (
10591166
toOffset: number,
1060-
{ align = 'start', behavior }: ScrollToOffsetOptions = {},
1167+
{ align = 'start', behavior = 'auto' }: ScrollToOffsetOptions = {},
10611168
) => {
1062-
if (behavior === 'smooth' && this.isDynamicMode()) {
1063-
console.warn(
1064-
'The `smooth` scroll behavior is not fully supported with dynamic size.',
1065-
)
1066-
}
1169+
const offset = this.getOffsetForAlignment(toOffset, align)
10671170

1068-
this._scrollToOffset(this.getOffsetForAlignment(toOffset, align), {
1069-
adjustments: undefined,
1171+
const now = performance.now()
1172+
this.scrollState = {
1173+
index: null,
1174+
align,
10701175
behavior,
1071-
})
1176+
startedAt: now,
1177+
lastTargetOffset: offset,
1178+
stableFrames: 0,
1179+
}
1180+
1181+
this._scrollToOffset(offset, { adjustments: undefined, behavior })
1182+
1183+
this.scheduleScrollReconcile()
10721184
}
10731185

10741186
scrollToIndex = (
10751187
index: number,
1076-
{ align: initialAlign = 'auto', behavior }: ScrollToIndexOptions = {},
1188+
{
1189+
align: initialAlign = 'auto',
1190+
behavior = 'auto',
1191+
}: ScrollToIndexOptions = {},
10771192
) => {
1078-
if (behavior === 'smooth' && this.isDynamicMode()) {
1079-
console.warn(
1080-
'The `smooth` scroll behavior is not fully supported with dynamic size.',
1081-
)
1082-
}
1083-
10841193
index = Math.max(0, Math.min(index, this.options.count - 1))
1085-
this.currentScrollToIndex = index
1086-
1087-
let attempts = 0
1088-
const maxAttempts = 10
1089-
1090-
const tryScroll = (currentAlign: ScrollAlignment) => {
1091-
if (!this.targetWindow) return
1092-
1093-
const offsetInfo = this.getOffsetForIndex(index, currentAlign)
1094-
if (!offsetInfo) {
1095-
console.warn('Failed to get offset for index:', index)
1096-
return
1097-
}
1098-
const [offset, align] = offsetInfo
1099-
this._scrollToOffset(offset, { adjustments: undefined, behavior })
1100-
1101-
this.targetWindow.requestAnimationFrame(() => {
1102-
const verify = () => {
1103-
// Abort if a new scrollToIndex was called with a different index
1104-
if (this.currentScrollToIndex !== index) return
1105-
1106-
const currentOffset = this.getScrollOffset()
1107-
const afterInfo = this.getOffsetForIndex(index, align)
1108-
if (!afterInfo) {
1109-
console.warn('Failed to get offset for index:', index)
1110-
return
1111-
}
11121194

1113-
if (!approxEqual(afterInfo[0], currentOffset)) {
1114-
scheduleRetry(align)
1115-
}
1116-
}
1195+
const offsetInfo = this.getOffsetForIndex(index, initialAlign)
1196+
const [offset, align] = offsetInfo
11171197

1118-
// In dynamic mode, wait an extra frame for ResizeObserver to measure newly visible elements
1119-
if (this.isDynamicMode()) {
1120-
this.targetWindow!.requestAnimationFrame(verify)
1121-
} else {
1122-
verify()
1123-
}
1124-
})
1198+
const now = performance.now()
1199+
this.scrollState = {
1200+
index,
1201+
align,
1202+
behavior,
1203+
startedAt: now,
1204+
lastTargetOffset: offset,
1205+
stableFrames: 0,
11251206
}
11261207

1127-
const scheduleRetry = (align: ScrollAlignment) => {
1128-
if (!this.targetWindow) return
1129-
1130-
// Abort if a new scrollToIndex was called with a different index
1131-
if (this.currentScrollToIndex !== index) return
1208+
this._scrollToOffset(offset, { adjustments: undefined, behavior })
11321209

1133-
attempts++
1134-
if (attempts < maxAttempts) {
1135-
if (process.env.NODE_ENV !== 'production' && this.options.debug) {
1136-
console.info('Schedule retry', attempts, maxAttempts)
1137-
}
1138-
this.targetWindow.requestAnimationFrame(() => tryScroll(align))
1139-
} else {
1140-
console.warn(
1141-
`Failed to scroll to index ${index} after ${maxAttempts} attempts.`,
1142-
)
1143-
}
1144-
}
1145-
1146-
tryScroll(initialAlign)
1210+
this.scheduleScrollReconcile()
11471211
}
11481212

1149-
scrollBy = (delta: number, { behavior }: ScrollToOffsetOptions = {}) => {
1150-
if (behavior === 'smooth' && this.isDynamicMode()) {
1151-
console.warn(
1152-
'The `smooth` scroll behavior is not fully supported with dynamic size.',
1153-
)
1154-
}
1213+
scrollBy = (
1214+
delta: number,
1215+
{ behavior = 'auto' }: ScrollToOffsetOptions = {},
1216+
) => {
1217+
const offset = this.getScrollOffset() + delta
1218+
const now = performance.now()
11551219

1156-
this._scrollToOffset(this.getScrollOffset() + delta, {
1157-
adjustments: undefined,
1220+
this.scrollState = {
1221+
index: null,
1222+
align: 'start',
11581223
behavior,
1159-
})
1224+
startedAt: now,
1225+
lastTargetOffset: offset,
1226+
stableFrames: 0,
1227+
}
1228+
1229+
this._scrollToOffset(offset, { adjustments: undefined, behavior })
1230+
1231+
this.scheduleScrollReconcile()
11601232
}
11611233

11621234
getTotalSize = () => {

0 commit comments

Comments
 (0)