TanStack / TanStack/virtual

`scrollToIndex(last, { align: 'end' })` stays short of the end after a measured row grows (virtual-core ≥ 3.17.0)

Open
#1,290 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
7.1k
Forks
466
Avg merge
2d 31m
Merged PRs (30d)
13

Description

Describe the bug

Since @tanstack/virtual-core 3.17.0 (#1183), the default measureElement returns the cached size on the synchronous path (called without a ResizeObserverEntry) and leaves the real size to the ResizeObserver.

This breaks a common pattern: a row that has already been measured changes height on a re-render, and the app re-pins to the end in the same task. A chat list does this when the last messages change height.

  1. The ref-callback measureElement(node) keeps the old size.
  2. scrollToIndex(last, { align: 'end' }) computes its target from that stale size.
  3. reconcileScroll sees the target equal to the current offset and retires after one stable frame.
  4. When the ResizeObserver then delivers the real size, nothing re-targets. The row is inside the viewport, so it is not compensated, and the default is anchorTo: 'start'.

The list stays short of the end for good.

On 3.16.1 the synchronous path read offsetHeight, so the same code landed exactly at the end.

This looks like the same root cause as #1262, which its reporter closed without a linked fix.

Your minimal, reproducible example

A single HTML file, with no framework. It loads @tanstack/virtual-core from esm.sh; pick the version with ?v= and add &sync=1 to pass a measureElement that reads offsetHeight on the synchronous path. Serve it over HTTP (for example python3 -m http.server) and open repro.html?v=3.17.11.

<!doctype html>
<meta charset="utf-8">
<title>virtual-core: scrollToIndex end lands short after a measured row grows</title>
<body style="margin:0;font:14px system-ui">
<pre id="out">running…</pre>
<script type="module">
// Open as repro.html?v=3.16.1, ?v=3.17.0 or ?v=3.17.11. Add &sync=1 to pass a measureElement
// that reads offsetHeight on the synchronous (no ResizeObserverEntry) path.
const params = new URLSearchParams(location.search)
const version = params.get('v') ?? '3.17.11'
const syncRead = params.get('sync') === '1'
const VC = await import(`https://esm.sh/@tanstack/virtual-core@${version}`)

const frame = () => new Promise(r => requestAnimationFrame(() => r()))
const scroller = document.createElement('div')
scroller.style.cssText = 'height:300px;width:300px;overflow:auto;position:relative;border:1px solid #888'
const sizer = document.createElement('div')
sizer.style.cssText = 'position:relative;width:100%'
scroller.appendChild(sizer)
document.body.prepend(scroller)

const COUNT = 20
const heights = Array.from({ length: COUNT }, () => 50)
const nodes = new Map()
const v = new VC.Virtualizer({
  count: COUNT,
  getScrollElement: () => scroller,
  estimateSize: () => 40, // differs from the rendered 50px, so each first measurement is cached
  overscan: 20,
  observeElementRect: VC.observeElementRect,
  observeElementOffset: VC.observeElementOffset,
  scrollToFn: VC.elementScroll,
  onChange: () => render(),
  ...(syncRead
    ? { measureElement: (el, entry, inst) => (entry ? VC.measureElement(el, entry, inst) : el.offsetHeight) }
    : {}),
})

// What a framework adapter does after each commit: position the rows, then measure through the
// ref callback.
function render() {
  sizer.style.height = `${v.getTotalSize()}px`
  for (const item of v.getVirtualItems()) {
    let node = nodes.get(item.index)
    if (!node) {
      node = document.createElement('div')
      node.dataset.index = String(item.index)
      node.style.cssText = 'position:absolute;left:0;width:100%;box-sizing:border-box;border-bottom:1px solid #ddd'
      node.textContent = `row ${item.index}`
      sizer.appendChild(node)
      nodes.set(item.index, node)
    }
    node.style.height = `${heights[item.index]}px`
    node.style.transform = `translateY(${item.start}px)`
  }
}

v._didMount()
v._willUpdate()
render()
for (const node of nodes.values()) v.measureElement(node)
await frame(); await frame(); await frame()

v.scrollToIndex(COUNT - 1, { align: 'end' })
await frame(); await frame(); await frame(); await frame()
await new Promise(r => setTimeout(r, 400)) // let isScrolling reset: the reader is idle

// A visible row that was already measured grows on a re-render, and the app re-pins to the end
// in the same task, as a chat list does when the last messages change height.
heights[18] = 80
render()
v.measureElement(nodes.get(18))
const sizeSeenInSameTask = v.getMeasurements()[18].size
v.scrollToIndex(COUNT - 1, { align: 'end' })

for (let i = 0; i < 10; i++) await frame()
const result = {
  version,
  syncMeasureElement: syncRead,
  domHeightOfRow18: nodes.get(18).offsetHeight,
  sizeSeenInSameTask,
  settledSize: v.getMeasurements()[18].size,
  distanceFromEnd: scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight,
}
document.getElementById('out').textContent = JSON.stringify(result, null, 2)
window.__result = result
</script>
Steps to reproduce
  1. Open repro.html?v=3.16.1, then ?v=3.17.0, then ?v=3.17.11.
  2. Repeat each with &sync=1.
  3. Read distanceFromEnd in the output.
Expected behavior

scrollToIndex(COUNT - 1, { align: 'end' }) ends with the last row flush with the bottom: distanceFromEnd is 0, as on 3.16.1.

Actual behavior

Measured with Playwright on Chromium 153 and WebKit 26.6, with identical results on both engines:

version measureElement size seen in the same task (DOM 80) distanceFromEnd after 10 frames
3.16.1 default 80 0
3.17.0 default 50 30
3.17.11 default 50 30
3.16.1, 3.17.0, 3.17.11 custom, synchronous offsetHeight 80 0

The row's settled size is 80 on every version. Only the scroll position is left behind.

Two conditions are needed:

  • The row must already have a cache entry. resizeItem writes itemSizeCache only when the measured size differs from the estimate. Rows measured at exactly their estimate still read the DOM on the synchronous path, which is why the example estimates 40 for rows rendered at 50.
  • The reader must be idle. While isScrolling is true, every version skips the synchronous measurement.

The same happens with initialMeasurementsCache: a seeded row whose rendered height differs from its seed keeps the seed until the ResizeObserver fires.

How often does this bug happen?

Every time

Platform
  • @tanstack/virtual-core 3.17.0 through 3.17.11 (checked: 3.17.0, 3.17.11; 3.16.1 is unaffected)
  • Chromium 153, WebKit 26.6 (Playwright 1.63), macOS
Possible directions
  • Keep scrollToIndex's reconciliation alive until pending ResizeObserver measurements for mounted items have been delivered, or re-target when a mounted item's size changes after reconciliation retired; or
  • Document on the measureElement option and method that the ref-callback path returns the cached size since 3.17.0, and that a custom measureElement reading offsetHeight restores the synchronous behaviour. The docs still say the method "Measures the element using your configured measureElement virtualizer option", and useCachedMeasurements is documented only for hidden lists.
Additional context

The synchronous-read measureElement in the example (entry ? measureElement(el, entry, inst) : el.offsetHeight) is the workaround #1183 suggests. It fixes the example on every version.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the repro.html example and run it across the listed virtual-core versions, comparing the default and synchronous offsetHeight measureElement paths. Read the virtual-core measureElement, scrollToIndex, reconcileScroll, and ResizeObserver flow to determine why reconciliation retires before the updated size is applied. Done means the reproduced case ends with distanceFromEnd 0 without regressing the documented measurement behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, typescript
Domain
frontend, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.