Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

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

Aperta
#1,290 1 commento 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
4/5
Tempo stimato
3-5 giorni
Idoneità per principianti
55/100
Tipo di issue
Bug
Chiarezza
Abbastanza chiara
Stato di attività
Attiva
Stack tecnologico
javascript, typescript

Direzione di ricerca

Inizia con l’esempio repro.html ed eseguilo sulle versioni di virtual-core elencate, confrontando i percorsi measureElement con offsetHeight predefinito e sincrono. Leggi il flusso di virtual-core measureElement, scrollToIndex, reconcileScroll e ResizeObserver per determinare perché la riconciliazione termina prima che venga applicata la dimensione aggiornata. Il lavoro è completato quando il caso riprodotto termina con distanceFromEnd 0 senza regressioni nel comportamento di misurazione documentato.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

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.

Lingua principale
TypeScript
Stelle
7.1k
Fork
468
Merge medio
1g 2h
PR unite (30g)
12

Preparare l'ambiente

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di TanStack/virtual

Tutte le issue di TanStack/virtual

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.