Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/twenty-maps-fry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@tanstack/virtual-core': patch
---

fix(virtual-core): improve scrollToIndex reliability in dynamic mode

- Wait extra frame for ResizeObserver measurements before verifying position
- Abort pending scroll operations when new scrollToIndex is called
43 changes: 29 additions & 14 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,6 @@ export const observeElementOffset = <T extends Element>(
}
const handler = createHandler(true)
const endHandler = createHandler(false)
endHandler()

element.addEventListener('scroll', handler, addEventListenerOptions)
const registerScrollendEvent =
Expand Down Expand Up @@ -226,7 +225,6 @@ export const observeWindowOffset = (
}
const handler = createHandler(true)
const endHandler = createHandler(false)
endHandler()

element.addEventListener('scroll', handler, addEventListenerOptions)
const registerScrollendEvent =
Expand Down Expand Up @@ -359,6 +357,7 @@ export class Virtualizer<
scrollElement: TScrollElement | null = null
targetWindow: (Window & typeof globalThis) | null = null
isScrolling = false
private currentScrollToIndex: number | null = null
measurementsCache: Array<VirtualItem> = []
private itemSizeCache = new Map<Key, number>()
private laneAssignments = new Map<number, number>() // index → lane cache
Expand Down Expand Up @@ -518,11 +517,6 @@ export class Virtualizer<
this.observer.observe(cached)
})

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

this.unsubs.push(
this.options.observeElementRect(this, (rect) => {
this.scrollRect = rect
Expand All @@ -544,6 +538,11 @@ export class Virtualizer<
this.maybeNotify()
}),
)

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

Expand Down Expand Up @@ -1085,6 +1084,7 @@ export class Virtualizer<
}

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

let attempts = 0
const maxAttempts = 10
Expand All @@ -1101,22 +1101,37 @@ export class Virtualizer<
this._scrollToOffset(offset, { adjustments: undefined, behavior })

this.targetWindow.requestAnimationFrame(() => {
const currentOffset = this.getScrollOffset()
const afterInfo = this.getOffsetForIndex(index, align)
if (!afterInfo) {
console.warn('Failed to get offset for index:', index)
return
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)
}
}

if (!approxEqual(afterInfo[0], currentOffset)) {
scheduleRetry(align)
// In dynamic mode, wait an extra frame for ResizeObserver to measure newly visible elements
if (this.isDynamicMode()) {
this.targetWindow!.requestAnimationFrame(verify)
} else {
verify()
}
})
}

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

// Abort if a new scrollToIndex was called with a different index
if (this.currentScrollToIndex !== index) return

attempts++
if (attempts < maxAttempts) {
if (process.env.NODE_ENV !== 'production' && this.options.debug) {
Expand Down
Loading