From 326e6c513f082995e38d52da9d4cd4db3d2b0bb1 Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:34:35 -0400 Subject: [PATCH 1/2] fix(core): defer observation of rows mounted during resize delivery --- .changeset/quiet-row-observers.md | 5 + .../e2e/app/resize-delivery/index.html | 26 ++++++ .../e2e/app/resize-delivery/main.tsx | 39 ++++++++ .../e2e/app/test/resize-delivery.spec.ts | 91 ++++++++++++++++++ packages/react-virtual/e2e/app/vite.config.ts | 4 + packages/virtual-core/src/index.ts | 51 ++++++++-- .../virtual-core/tests/measurement.test.ts | 93 ++++++++++++++++++- 7 files changed, 299 insertions(+), 10 deletions(-) create mode 100644 .changeset/quiet-row-observers.md create mode 100644 packages/react-virtual/e2e/app/resize-delivery/index.html create mode 100644 packages/react-virtual/e2e/app/resize-delivery/main.tsx create mode 100644 packages/react-virtual/e2e/app/test/resize-delivery.spec.ts diff --git a/.changeset/quiet-row-observers.md b/.changeset/quiet-row-observers.md new file mode 100644 index 000000000..ce439bf86 --- /dev/null +++ b/.changeset/quiet-row-observers.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +Avoid ResizeObserver loop errors when a synchronous resize render mounts or unmounts rows. Measure new rows immediately, defer their observation until the next frame, and stop observing detached rows before delivery ends. diff --git a/packages/react-virtual/e2e/app/resize-delivery/index.html b/packages/react-virtual/e2e/app/resize-delivery/index.html new file mode 100644 index 000000000..935d2619b --- /dev/null +++ b/packages/react-virtual/e2e/app/resize-delivery/index.html @@ -0,0 +1,26 @@ + + + + + + + +
+ + + diff --git a/packages/react-virtual/e2e/app/resize-delivery/main.tsx b/packages/react-virtual/e2e/app/resize-delivery/main.tsx new file mode 100644 index 000000000..b1b42f685 --- /dev/null +++ b/packages/react-virtual/e2e/app/resize-delivery/main.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { useVirtualizer } from '@tanstack/react-virtual' + +function App() { + const parentRef = React.useRef(null) + const virtualizer = useVirtualizer({ + count: 2000, + getScrollElement: () => parentRef.current, + estimateSize: () => 33, + overscan: 8, + }) + + return ( +
+
+ {virtualizer.getVirtualItems().map((item) => ( +
+ Row {item.index} +
+ ))} +
+
+ ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/packages/react-virtual/e2e/app/test/resize-delivery.spec.ts b/packages/react-virtual/e2e/app/test/resize-delivery.spec.ts new file mode 100644 index 000000000..3bb470e06 --- /dev/null +++ b/packages/react-virtual/e2e/app/test/resize-delivery.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test' +import type { Page } from '@playwright/test' + +// Let ResizeObserver delivery and its deferred registrations finish before +// checking errors. No timer or external resource determines the result. +const settleFrames = (page: Page) => + page.evaluate( + () => + new Promise((resolve) => { + let remaining = 5 + const frame = () => { + if (--remaining === 0) resolve() + else requestAnimationFrame(frame) + } + requestAnimationFrame(frame) + }), + ) + +for (const direction of ['grow', 'shrink'] as const) { + test(`resizing rows can ${direction} the rendered range without observer loop errors`, async ({ + page, + }) => { + await page.addInitScript(() => { + window.addEventListener('error', (event) => { + if (event.message.includes('ResizeObserver loop')) { + const root = document.documentElement + root.dataset.resizeLoops = String( + Number(root.dataset.resizeLoops ?? 0) + 1, + ) + } + }) + }) + await page.goto('/resize-delivery/') + const scroll = page.locator('#scroll') + await expect(page.locator('.row').first()).toBeVisible() + await scroll.evaluate((element) => { + element.scrollTop = element.scrollHeight / 2 + }) + await expect + .poll(() => page.locator('.row').first().getAttribute('data-index')) + .not.toBe('0') + await settleFrames(page) + + if (direction === 'grow') { + // Growing the rendered range requires shrinking rows. Set up taller + // rows first so both paths start at the same scrolled viewport. + await page.evaluate(() => + document.documentElement.style.setProperty('--scale', '1.25'), + ) + await settleFrames(page) + } + + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + await page.evaluate(() => { + document.documentElement.dataset.resizeLoops = '0' + }) + const countBefore = await page.locator('.row').count() + const anchor = await scroll.evaluate((element) => { + const top = element.getBoundingClientRect().top + const row = [...element.querySelectorAll('.row')].find( + (item) => { + const rect = item.getBoundingClientRect() + return rect.top <= top && rect.bottom > top + }, + )! + return row.dataset.index! + }) + await page.evaluate( + (scale) => document.documentElement.style.setProperty('--scale', scale), + direction === 'grow' ? '1' : '1.25', + ) + await expect.poll(() => page.locator('.row').count()).not.toBe(countBefore) + await settleFrames(page) + const countAfter = await page.locator('.row').count() + if (direction === 'grow') expect(countAfter).toBeGreaterThan(countBefore) + else expect(countAfter).toBeLessThan(countBefore) + + const anchorBox = await page + .locator(`.row[data-index="${anchor}"]`) + .boundingBox() + const scrollBox = await scroll.boundingBox() + expect(anchorBox).not.toBeNull() + expect(anchorBox!.y).toBeLessThanOrEqual(scrollBox!.y + 1) + expect(anchorBox!.y + anchorBox!.height).toBeGreaterThan(scrollBox!.y) + expect(await page.locator('html').getAttribute('data-resize-loops')).toBe( + '0', + ) + expect(errors).toEqual([]) + }) +} diff --git a/packages/react-virtual/e2e/app/vite.config.ts b/packages/react-virtual/e2e/app/vite.config.ts index 7b8cd7ddf..788359cfb 100644 --- a/packages/react-virtual/e2e/app/vite.config.ts +++ b/packages/react-virtual/e2e/app/vite.config.ts @@ -16,6 +16,10 @@ export default defineConfig({ __dirname, 'measure-element/index.html', ), + 'resize-delivery': path.resolve( + __dirname, + 'resize-delivery/index.html', + ), 'smooth-scroll': path.resolve(__dirname, 'smooth-scroll/index.html'), 'smooth-prepend': path.resolve(__dirname, 'smooth-prepend/index.html'), 'stale-index': path.resolve(__dirname, 'stale-index/index.html'), diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 83386f31e..697ec3eaa 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -642,6 +642,9 @@ export class Virtualizer< private observer = (() => { let _ro: ResizeObserver | null = null let _frames: ReturnType | null = null + let delivering = false + let observeQueued = false + const pendingObservations = new Set() const get = () => { if (_ro) { @@ -672,12 +675,23 @@ export class Virtualizer< this._noteClampMax() this._flushHoldIfReady(true) this._retryClampedAdjustment() + // Ref-null callbacks run before the host nodes are detached. + // Drop them after the sync renders, before the browser gathers + // another round of ResizeObserver entries at their new depth. + this.measureElement(null) } } } - this.options.useAnimationFrameWithResizeObserver - ? frames.schedule(run) - : run() + if (this.options.useAnimationFrameWithResizeObserver) { + frames.schedule(run) + } else { + delivering = true + try { + run() + } finally { + delivering = false + } + } })) } @@ -715,12 +729,37 @@ export class Virtualizer< disconnect: () => { get()?.disconnect() _frames?.cancel() + pendingObservations.clear() + observeQueued = false _ro = null _frames = null }, - observe: (target: Element) => - get()?.observe(target, { box: 'border-box' }), - unobserve: (target: Element) => get()?.unobserve(target), + observe: (target: Element) => { + const observer = get() + if (!observer) return + if (!delivering) { + observer.observe(target, { box: 'border-box' }) + return + } + // A sync render can also mount rows at the depth being delivered. + // A microtask is still inside that delivery: register next frame, + // while measureElement continues to measure the new row immediately. + pendingObservations.add(target) + if (!observeQueued) { + observeQueued = true + _frames?.schedule(() => { + observeQueued = false + pendingObservations.forEach((node) => { + if (node.isConnected) _ro?.observe(node, { box: 'border-box' }) + }) + pendingObservations.clear() + }) + } + }, + unobserve: (target: Element) => { + pendingObservations.delete(target) + get()?.unobserve(target) + }, } })() range: { startIndex: number; endIndex: number } | null = null diff --git a/packages/virtual-core/tests/measurement.test.ts b/packages/virtual-core/tests/measurement.test.ts index f29604613..b5fbf891e 100644 --- a/packages/virtual-core/tests/measurement.test.ts +++ b/packages/virtual-core/tests/measurement.test.ts @@ -736,13 +736,20 @@ test('changing gap invalidates cached measurements (multi-lane)', () => { function setupResizeBatch(onChange: (instance: any, sync: boolean) => void) { let roCallback: ResizeObserverCallback | null = null + const observe = vi.fn() + const unobserve = vi.fn() + const frames = new Map() + let frameId = 0 const mockWindow = { - requestAnimationFrame: vi.fn(), - cancelAnimationFrame: vi.fn(), + requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { + frames.set(++frameId, callback) + return frameId + }), + cancelAnimationFrame: vi.fn((id: number) => frames.delete(id)), performance: { now: () => Date.now() }, ResizeObserver: vi.fn(function (cb: ResizeObserverCallback) { roCallback = cb - return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() } + return { observe, unobserve, disconnect: vi.fn() } }), } const el = { @@ -811,7 +818,21 @@ function setupResizeBatch(onChange: (instance: any, sync: boolean) => void) { ), {} as ResizeObserver, ) - return { virtualizer, sizes, node, deliver } + const flush = () => { + const queued = [...frames.values()] + frames.clear() + queued.forEach((callback) => callback(0)) + } + return { + virtualizer, + sizes, + node, + deliver, + observe, + unobserve, + frames, + flush, + } } test('ResizeObserver batch: later entries see starts updated by earlier ones (#1218)', () => { @@ -850,6 +871,70 @@ test('ResizeObserver batch: a sync re-render does not drop queued entries (#1036 expect(notifies).toEqual([true]) }) +test('unobserves rows detached by a synchronous resize render before delivery ends', () => { + const { virtualizer, sizes, node, deliver, unobserve } = setupResizeBatch( + (instance, sync) => { + if (!sync) return + // React calls the ref with null before removing the host element. + instance.measureElement(null) + node(5).isConnected = false + }, + ) + sizes.set(3, 200) + deliver([3]) + expect(unobserve).toHaveBeenCalledWith(node(5)) + expect(virtualizer.elementsCache.has(5)).toBe(false) +}) + +test('measures new rows synchronously but observes them after resize delivery', () => { + const { virtualizer, sizes, node, deliver, observe, frames, flush } = + setupResizeBatch((instance, sync) => { + if (sync) { + instance.measureElement(node(10)) + instance.measureElement(node(11)) + } + }) + sizes.set(10, 120) + sizes.set(11, 130) + sizes.set(3, 200) + deliver([3]) + expect(virtualizer.getMeasurements()[10]!.size).toBe(120) + expect(virtualizer.getMeasurements()[11]!.size).toBe(130) + expect(observe.mock.calls.map(([target]) => target)).not.toContain(node(10)) + expect(observe.mock.calls.map(([target]) => target)).not.toContain(node(11)) + expect(frames.size).toBe(1) + flush() + expect(observe).toHaveBeenCalledWith(node(10), { box: 'border-box' }) + expect(observe).toHaveBeenCalledWith(node(11), { box: 'border-box' }) +}) + +test.each(['removed', 'replaced', 'disconnected'] as const)( + 'does not register a deferred row after it is %s', + (action) => { + const { virtualizer, sizes, node, deliver, observe, flush } = + setupResizeBatch((instance, sync) => { + if (sync) instance.measureElement(node(10)) + }) + sizes.set(10, 120) + sizes.set(3, 200) + deliver([3]) + expect(observe.mock.calls.map(([target]) => target)).not.toContain(node(10)) + + if (action === 'removed') { + node(10).isConnected = false + virtualizer.measureElement(null) + } else if (action === 'replaced') { + const replacement = { ...node(10) } + virtualizer.measureElement(replacement) + expect(observe).toHaveBeenCalledWith(replacement, { box: 'border-box' }) + } else { + virtualizer._didMount()() + } + flush() + expect(observe.mock.calls.map(([target]) => target)).not.toContain(node(10)) + }, +) + // ─── useAnimationFrameWithResizeObserver: frames of the scroll element's window // A window other than the global one, as for a list rendered into a From e3b6ce6d83db313d2007592609cd3d45d93af594 Mon Sep 17 00:00:00 2001 From: Minwook Shin <163576506+minwookshin@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:45:14 -0400 Subject: [PATCH 2/2] test: assert resize anchor retains its viewport offset --- .../e2e/app/test/resize-delivery.spec.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/react-virtual/e2e/app/test/resize-delivery.spec.ts b/packages/react-virtual/e2e/app/test/resize-delivery.spec.ts index 3bb470e06..387b1789b 100644 --- a/packages/react-virtual/e2e/app/test/resize-delivery.spec.ts +++ b/packages/react-virtual/e2e/app/test/resize-delivery.spec.ts @@ -50,7 +50,7 @@ for (const direction of ['grow', 'shrink'] as const) { await settleFrames(page) } - const errors: string[] = [] + const errors: Array = [] page.on('pageerror', (error) => errors.push(error.message)) await page.evaluate(() => { document.documentElement.dataset.resizeLoops = '0' @@ -64,7 +64,10 @@ for (const direction of ['grow', 'shrink'] as const) { return rect.top <= top && rect.bottom > top }, )! - return row.dataset.index! + return { + index: row.dataset.index!, + offset: row.getBoundingClientRect().top - top, + } }) await page.evaluate( (scale) => document.documentElement.style.setProperty('--scale', scale), @@ -77,10 +80,13 @@ for (const direction of ['grow', 'shrink'] as const) { else expect(countAfter).toBeLessThan(countBefore) const anchorBox = await page - .locator(`.row[data-index="${anchor}"]`) + .locator(`.row[data-index="${anchor.index}"]`) .boundingBox() const scrollBox = await scroll.boundingBox() expect(anchorBox).not.toBeNull() + expect( + Math.abs(anchorBox!.y - scrollBox!.y - anchor.offset), + ).toBeLessThanOrEqual(1) expect(anchorBox!.y).toBeLessThanOrEqual(scrollBox!.y + 1) expect(anchorBox!.y + anchorBox!.height).toBeGreaterThan(scrollBox!.y) expect(await page.locator('html').getAttribute('data-resize-loops')).toBe(