diff --git a/.changeset/twenty-maps-fry.md b/.changeset/twenty-maps-fry.md new file mode 100644 index 00000000..70e50788 --- /dev/null +++ b/.changeset/twenty-maps-fry.md @@ -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 diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 7f33a2d8..efbf1e54 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -178,7 +178,6 @@ export const observeElementOffset = ( } const handler = createHandler(true) const endHandler = createHandler(false) - endHandler() element.addEventListener('scroll', handler, addEventListenerOptions) const registerScrollendEvent = @@ -226,7 +225,6 @@ export const observeWindowOffset = ( } const handler = createHandler(true) const endHandler = createHandler(false) - endHandler() element.addEventListener('scroll', handler, addEventListenerOptions) const registerScrollendEvent = @@ -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 = [] private itemSizeCache = new Map() private laneAssignments = new Map() // index → lane cache @@ -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 @@ -544,6 +538,11 @@ export class Virtualizer< this.maybeNotify() }), ) + + this._scrollToOffset(this.getScrollOffset(), { + adjustments: undefined, + behavior: undefined, + }) } } @@ -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 @@ -1101,15 +1101,27 @@ 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() } }) } @@ -1117,6 +1129,9 @@ export class Virtualizer< 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) {