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 (
+
+ )
+}
+
+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(