`scrollToIndex(last, { align: 'end' })` stays short of the end after a measured row grows (virtual-core ≥ 3.17.0)
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.
- The ref-callback
measureElement(node)keeps the old size. scrollToIndex(last, { align: 'end' })computes its target from that stale size.reconcileScrollsees the target equal to the current offset and retires after one stable frame.- 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
- Open
repro.html?v=3.16.1, then?v=3.17.0, then?v=3.17.11. - Repeat each with
&sync=1. - Read
distanceFromEndin 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.
resizeItemwritesitemSizeCacheonly 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
isScrollingis 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
measureElementoption and method that the ref-callback path returns the cached size since 3.17.0, and that a custommeasureElementreadingoffsetHeightrestores the synchronous behaviour. The docs still say the method "Measures the element using your configured measureElement virtualizer option", anduseCachedMeasurementsis 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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