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

Đang mở
#1,290 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

Chưa có ai nhận issue này.

Đánh giá

Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức phù hợp với người mới
55/100
Loại issue
Lỗi
Độ rõ ràng
Khá rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
javascript, typescript
Lĩnh vực
frontend, performance

Hướng nghiên cứu

Bắt đầu với ví dụ repro.html và chạy nó trên các phiên bản virtual-core được liệt kê, so sánh các đường đi measureElement với offsetHeight mặc định và đồng bộ. Đọc luồng của virtual-core measureElement, scrollToIndex, reconcileScroll và ResizeObserver để xác định tại sao quá trình reconciliation kết thúc trước khi kích thước đã cập nhật được áp dụng. Được xem là hoàn tất khi trường hợp tái hiện kết thúc với distanceFromEnd 0 mà không làm hồi quy hành vi đo lường đã được tài liệu hóa.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

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.

Ngôn ngữ chính
TypeScript
Star
7.1k
Fork
466
Merge trung bình
2 ngày 31 phút
Pull request đã merge (30 ngày)
13

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của TanStack/virtual

Tất cả issue của TanStack/virtual

Issue tương tự

Thêm issue về TypeScript

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.