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
6 changes: 5 additions & 1 deletion examples/react/dynamic/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ function RowVirtualizerDynamic() {
enabled,
})

React.useEffect(() => {
virtualizer.scrollToIndex(count - 1, { align: 'end' })
}, [])

const items = virtualizer.getVirtualItems()

return (
Expand All @@ -40,7 +44,7 @@ function RowVirtualizerDynamic() {
<span style={{ padding: '0 4px' }} />
<button
onClick={() => {
virtualizer.scrollToIndex(count / 2)
virtualizer.scrollToIndex(count / 2, { behavior: 'smooth' })
}}
>
scroll to the middle
Expand Down
258 changes: 165 additions & 93 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,22 @@ export interface VirtualizerOptions<
useAnimationFrameWithResizeObserver?: boolean
}

type ScrollState = {
// what we want
index: number | null
align: ScrollAlignment
behavior: ScrollBehavior

// lifecycle
startedAt: number

// target tracking
lastTargetOffset: number

// settling
stableFrames: number
}

export class Virtualizer<
TScrollElement extends Element | Window,
TItemElement extends Element,
Expand All @@ -357,7 +373,7 @@ export class Virtualizer<
scrollElement: TScrollElement | null = null
targetWindow: (Window & typeof globalThis) | null = null
isScrolling = false
private currentScrollToIndex: number | null = null
private scrollState: ScrollState | null = null
measurementsCache: Array<VirtualItem> = []
private itemSizeCache = new Map<Key, number>()
private laneAssignments = new Map<number, number>() // index → lane cache
Expand Down Expand Up @@ -535,6 +551,9 @@ export class Virtualizer<
this.scrollOffset = offset
this.isScrolling = isScrolling

if (this.scrollState) {
this.scheduleScrollReconcile()
}
this.maybeNotify()
}),
)
Expand All @@ -546,6 +565,63 @@ export class Virtualizer<
}
}

private rafId: number | null = null
private scheduleScrollReconcile() {
if (!this.targetWindow) return
if (this.rafId != null) return
this.rafId = this.targetWindow.requestAnimationFrame(() => {
this.rafId = null
this.reconcileScroll()
})
}
private reconcileScroll() {
if (!this.scrollState) return

const el = this.scrollElement
if (!el) return

const targetOffset = this.scrollState.index
? this.getOffsetForIndex(
this.scrollState.index,
this.scrollState.align,
)[0]
: this.getOffsetForAlignment(
this.scrollState.lastTargetOffset,
this.scrollState.align,
)

// 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

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

if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) {
this.scrollState.stableFrames++
if (this.scrollState.stableFrames >= STABLE_FRAMES) {
this.scrollState = null
} else {
this.scheduleScrollReconcile()
}
return
}

this.scrollState.stableFrames = 0

if (targetChanged) {
this.scrollState.lastTargetOffset = targetOffset
// Switch to 'auto' behavior once measurements cause target to change
// We want to jump directly to the correct position, not smoothly animate to it
this.scrollState.behavior = 'auto'

this._scrollToOffset(targetOffset, {
adjustments: undefined,
behavior: 'auto',
})
}
}

private getSize = () => {
if (!this.options.enabled) {
this.scrollRect = null
Expand Down Expand Up @@ -859,6 +935,38 @@ export class Virtualizer<
return parseInt(indexStr, 10)
}

/**
* Determines if an item at the given index should be measured during smooth scroll.
* During smooth scroll, only items within a buffer range around the target are measured
* to prevent items far from the target from pushing it away.
*/
private shouldMeasureDuringScroll = (index: number): boolean => {
// No scroll state or not smooth scroll - always allow measurements
if (!this.scrollState || this.scrollState.behavior !== 'smooth') {
return true
}

const scrollIndex =
this.scrollState.index ??
this.getVirtualItemForOffset(this.scrollState.lastTargetOffset)?.index

if (scrollIndex !== undefined && this.range) {
// Allow measurements within a buffer range around the scroll target
const bufferSize = Math.max(
this.options.overscan,
Math.ceil((this.range.endIndex - this.range.startIndex) / 2),
)
const minIndex = Math.max(0, scrollIndex - bufferSize)
const maxIndex = Math.min(
this.options.count - 1,
scrollIndex + bufferSize,
)
return index >= minIndex && index <= maxIndex
}

return true
}

private _measureElement = (
node: TItemElement,
entry: ResizeObserverEntry | undefined,
Expand All @@ -879,7 +987,7 @@ export class Virtualizer<
this.elementsCache.set(key, node)
}

if (node.isConnected) {
if (node.isConnected && this.shouldMeasureDuringScroll(index)) {
this.resizeItem(index, this.options.measureElement(node, entry, this))
}
}
Expand All @@ -894,14 +1002,14 @@ export class Virtualizer<

if (delta !== 0) {
if (
this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
this.scrollState?.behavior !== 'smooth' &&
(this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this)
: item.start < this.getScrollOffset() + this.scrollAdjustments
: item.start < this.getScrollOffset() + this.scrollAdjustments)
) {
if (process.env.NODE_ENV !== 'production' && this.options.debug) {
console.info('correction', delta)
}

this._scrollToOffset(this.getScrollOffset(), {
adjustments: (this.scrollAdjustments += delta),
behavior: undefined,
Expand Down Expand Up @@ -1013,14 +1121,15 @@ export class Virtualizer<
getOffsetForIndex = (index: number, align: ScrollAlignment = 'auto') => {
index = Math.max(0, Math.min(index, this.options.count - 1))

const size = this.getSize()
const scrollOffset = this.getScrollOffset()

const item = this.measurementsCache[index]
if (!item) {
return undefined
console.warn('No measurement found for index:', index)
return [scrollOffset, align] as const
}

const size = this.getSize()
const scrollOffset = this.getScrollOffset()

if (align === 'auto') {
if (item.end >= scrollOffset + size - this.options.scrollPaddingEnd) {
align = 'end'
Expand Down Expand Up @@ -1048,110 +1157,73 @@ export class Virtualizer<
] as const
}

private isDynamicMode = () => this.elementsCache.size > 0

scrollToOffset = (
toOffset: number,
{ align = 'start', behavior }: ScrollToOffsetOptions = {},
{ align = 'start', behavior = 'auto' }: ScrollToOffsetOptions = {},
) => {
if (behavior === 'smooth' && this.isDynamicMode()) {
console.warn(
'The `smooth` scroll behavior is not fully supported with dynamic size.',
)
}
const offset = this.getOffsetForAlignment(toOffset, align)

this._scrollToOffset(this.getOffsetForAlignment(toOffset, align), {
adjustments: undefined,
const now = performance.now()
this.scrollState = {
index: null,
align,
behavior,
})
startedAt: now,
lastTargetOffset: offset,
stableFrames: 0,
}

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

this.scheduleScrollReconcile()
}

scrollToIndex = (
index: number,
{ align: initialAlign = 'auto', behavior }: ScrollToIndexOptions = {},
{
align: initialAlign = 'auto',
behavior = 'auto',
}: ScrollToIndexOptions = {},
) => {
if (behavior === 'smooth' && this.isDynamicMode()) {
console.warn(
'The `smooth` scroll behavior is not fully supported with dynamic size.',
)
}

index = Math.max(0, Math.min(index, this.options.count - 1))
this.currentScrollToIndex = index

let attempts = 0
const maxAttempts = 10

const tryScroll = (currentAlign: ScrollAlignment) => {
if (!this.targetWindow) return

const offsetInfo = this.getOffsetForIndex(index, currentAlign)
if (!offsetInfo) {
console.warn('Failed to get offset for index:', index)
return
}
const [offset, align] = offsetInfo
this._scrollToOffset(offset, { adjustments: undefined, behavior })

this.targetWindow.requestAnimationFrame(() => {
const verify = () => {
// Abort if a new scrollToIndex was called with a different index
if (this.currentScrollToIndex !== index) return

const currentOffset = this.getScrollOffset()
const afterInfo = this.getOffsetForIndex(index, align)
if (!afterInfo) {
console.warn('Failed to get offset for index:', index)
return
}

if (!approxEqual(afterInfo[0], currentOffset)) {
scheduleRetry(align)
}
}
const offsetInfo = this.getOffsetForIndex(index, initialAlign)
const [offset, align] = offsetInfo

// In dynamic mode, wait an extra frame for ResizeObserver to measure newly visible elements
if (this.isDynamicMode()) {
this.targetWindow!.requestAnimationFrame(verify)
} else {
verify()
}
})
const now = performance.now()
this.scrollState = {
index,
align,
behavior,
startedAt: now,
lastTargetOffset: offset,
stableFrames: 0,
}

const scheduleRetry = (align: ScrollAlignment) => {
if (!this.targetWindow) return

// Abort if a new scrollToIndex was called with a different index
if (this.currentScrollToIndex !== index) return
this._scrollToOffset(offset, { adjustments: undefined, behavior })

attempts++
if (attempts < maxAttempts) {
if (process.env.NODE_ENV !== 'production' && this.options.debug) {
console.info('Schedule retry', attempts, maxAttempts)
}
this.targetWindow.requestAnimationFrame(() => tryScroll(align))
} else {
console.warn(
`Failed to scroll to index ${index} after ${maxAttempts} attempts.`,
)
}
}

tryScroll(initialAlign)
this.scheduleScrollReconcile()
}

scrollBy = (delta: number, { behavior }: ScrollToOffsetOptions = {}) => {
if (behavior === 'smooth' && this.isDynamicMode()) {
console.warn(
'The `smooth` scroll behavior is not fully supported with dynamic size.',
)
}
scrollBy = (
delta: number,
{ behavior = 'auto' }: ScrollToOffsetOptions = {},
) => {
const offset = this.getScrollOffset() + delta
const now = performance.now()

this._scrollToOffset(this.getScrollOffset() + delta, {
adjustments: undefined,
this.scrollState = {
index: null,
align: 'start',
behavior,
})
startedAt: now,
lastTargetOffset: offset,
stableFrames: 0,
}

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

this.scheduleScrollReconcile()
}

getTotalSize = () => {
Expand Down
Loading