Skip to content

getOffsetForIndex returns stale offsets between a size change and the next render #1361

Closed

Description

@Kebechet

Describe the bug

getOffsetForIndex reads the measurementsCache field directly (index.ts L2379). That field is only rebuilt when the private getMeasurements() memo runs. Every other public read goes through the memo: getVirtualItems, getTotalSize, getVirtualItemForOffset (L2173), takeSnapshot.

So after a size change (resizeItem, a ResizeObserver measurement, measureElement, measure(), a count change through setOptions), and before anything reads the items again, getOffsetForIndex answers from the old layout while the rest of the API answers from the new one. The same call returns two different values depending on whether an unrelated read happened in between:

virtualizer.getVirtualItems()   // the render
virtualizer.resizeItem(0, 200)  // item 0: 50 -> 200, so item 10 now starts at 650

virtualizer.getOffsetForIndex(10, 'start')[0]   // 500
virtualizer.getVirtualItemForOffset(650).index  // 10
virtualizer.getOffsetForIndex(10, 'start')[0]   // 650

The window is real in the React adapter: after a measurement, onChange re-renders asynchronously, and React attaches the rows' ref callbacks (ref={virtualizer.measureElement}) before it runs the owner's layout effects. A layout effect that reads getOffsetForIndex, or calls scrollToIndex (the usual "scroll to the selected row once the data is there"), sees the estimates the rows had before they were measured.

scrollToIndex aims its first write through getOffsetForIndex (L2435), so that write goes to the stale offset. reconcileScroll already refreshes the measurements before retargeting (L1475-L1478, added in #1305), as does _writeAwaitingCommand (L1360), so the next frame usually corrects it. It does not when another command or a gesture comes first, and an index added by setOptions since the last read has no entry at all, so scrollToIndex to it silently does nothing.

Your minimal, reproducible example

Tests that fail on main, with the fix: Kebechet/virtual@main...Kebechet:virtual:fix/offset-for-index-fresh-measurements (measurement.test.ts, scroll-commands.test.ts, and a react-virtual test).

Vanilla virtual-core, runs in Node (no DOM needed):

import { Virtualizer } from '@tanstack/virtual-core'

const scrollElement = {
  scrollTop: 0,
  scrollHeight: 10000,
  clientHeight: 200,
  ownerDocument: { defaultView: null },
}
const virtualizer = new Virtualizer({
  count: 100,
  estimateSize: () => 50,
  getScrollElement: () => scrollElement,
  scrollToFn: () => {},
  observeElementRect: (_instance, cb) => cb({ width: 400, height: 200 }),
  observeElementOffset: (_instance, cb) => cb(0, false),
})
virtualizer._willUpdate()
virtualizer.getVirtualItems()

virtualizer.resizeItem(0, 200)

console.log(virtualizer.getOffsetForIndex(10, 'start')[0]) // 500, expected 650
console.log(virtualizer.getVirtualItemForOffset(650).index) // 10
console.log(virtualizer.getOffsetForIndex(10, 'start')[0]) // 650

React (@tanstack/react-virtual):

import * as React from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'

export default function App() {
  const parentRef = React.useRef<HTMLDivElement>(null)
  const reported = React.useRef(false)
  const virtualizer = useVirtualizer({
    count: 1000,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,
  })
  const items = virtualizer.getVirtualItems()

  // The rows' refs have measured them (100px each) by the time this runs.
  React.useLayoutEffect(() => {
    if (items.length === 0 || reported.current) return
    reported.current = true
    console.log(virtualizer.getOffsetForIndex(3, 'start')?.[0]) // 150, expected 300
  })

  return (
    <div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {items.map((item) => (
          <div
            key={item.key}
            data-index={item.index}
            ref={virtualizer.measureElement}
            style={{
              position: 'absolute',
              top: 0,
              width: '100%',
              height: 100,
              transform: `translateY(${item.start}px)`,
            }}
          >
            Row {item.index}
          </div>
        ))}
      </div>
    </div>
  )
}

Steps to reproduce

  1. Run the vanilla snippet: the first getOffsetForIndex(10) prints 500, the second 650.
  2. Or render the React component: the layout effect logs 150 for row 3, although rows 0-2 have been measured at 100px.
  3. For scrollToIndex: replace the log in step 2 with virtualizer.scrollToIndex(3, { align: 'start' }); the first write goes to 150. With vanilla virtual-core, setOptions({ ...virtualizer.options, count: 120 }) followed by scrollToIndex(110) writes nothing.

Expected behavior

getOffsetForIndex agrees with getVirtualItems, getTotalSize and getVirtualItemForOffset at any time, and scrollToIndex writes the current target the first time.

How often does this bug happen?

Every time

Platform

Any. The code path is platform independent; reproduced in Node and jsdom.

tanstack-virtual version

@tanstack/virtual-core 3.19.10 (also 3.19.8, and main at 0a368c7)

TypeScript version

No response

Additional context

Proposed fix: read the memo instead of the field in getOffsetForIndex:

-    const item = this.measurementsCache[index]
+    const item = this.getMeasurements()[index]

getMeasurements is memoized, so this costs nothing when nothing changed, and it makes the explicit refreshes in reconcileScroll and _writeAwaitingCommand redundant. I have the fix with tests and will open a PR.

Terms & Code of Conduct

  • I agree to follow this project's Code of Conduct
  • I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions