Skip to content

Commit 979c1b4

Browse files
authored
fix(core): avoid observer loop errors when resize renders change rows (#1348)
1 parent 18d0835 commit 979c1b4

7 files changed

Lines changed: 305 additions & 10 deletions

File tree

‎.changeset/quiet-row-observers.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/virtual-core': patch
3+
---
4+
5+
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.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<style>
6+
:root {
7+
--scale: 1;
8+
}
9+
body {
10+
margin: 0;
11+
font-family: system-ui, sans-serif;
12+
}
13+
.row {
14+
box-sizing: border-box;
15+
padding: 4px 8px;
16+
border-top: 1px solid #ddd;
17+
font-size: calc(16px * var(--scale));
18+
line-height: 1.5;
19+
}
20+
</style>
21+
</head>
22+
<body>
23+
<div id="root"></div>
24+
<script type="module" src="./main.tsx"></script>
25+
</body>
26+
</html>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import React from 'react'
2+
import ReactDOM from 'react-dom/client'
3+
import { useVirtualizer } from '@tanstack/react-virtual'
4+
5+
function App() {
6+
const parentRef = React.useRef<HTMLDivElement>(null)
7+
const virtualizer = useVirtualizer({
8+
count: 2000,
9+
getScrollElement: () => parentRef.current,
10+
estimateSize: () => 33,
11+
overscan: 8,
12+
})
13+
14+
return (
15+
<div id="scroll" ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
16+
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
17+
{virtualizer.getVirtualItems().map((item) => (
18+
<div
19+
key={item.key}
20+
data-index={item.index}
21+
ref={virtualizer.measureElement}
22+
className="row"
23+
style={{
24+
position: 'absolute',
25+
top: 0,
26+
left: 0,
27+
width: '100%',
28+
transform: `translateY(${item.start}px)`,
29+
}}
30+
>
31+
Row {item.index}
32+
</div>
33+
))}
34+
</div>
35+
</div>
36+
)
37+
}
38+
39+
ReactDOM.createRoot(document.getElementById('root')!).render(<App />)
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { expect, test } from '@playwright/test'
2+
import type { Page } from '@playwright/test'
3+
4+
// Let ResizeObserver delivery and its deferred registrations finish before
5+
// checking errors. No timer or external resource determines the result.
6+
const settleFrames = (page: Page) =>
7+
page.evaluate(
8+
() =>
9+
new Promise<void>((resolve) => {
10+
let remaining = 5
11+
const frame = () => {
12+
if (--remaining === 0) resolve()
13+
else requestAnimationFrame(frame)
14+
}
15+
requestAnimationFrame(frame)
16+
}),
17+
)
18+
19+
for (const direction of ['grow', 'shrink'] as const) {
20+
test(`resizing rows can ${direction} the rendered range without observer loop errors`, async ({
21+
page,
22+
}) => {
23+
await page.addInitScript(() => {
24+
window.addEventListener('error', (event) => {
25+
if (event.message.includes('ResizeObserver loop')) {
26+
const root = document.documentElement
27+
root.dataset.resizeLoops = String(
28+
Number(root.dataset.resizeLoops ?? 0) + 1,
29+
)
30+
}
31+
})
32+
})
33+
await page.goto('/resize-delivery/')
34+
const scroll = page.locator('#scroll')
35+
await expect(page.locator('.row').first()).toBeVisible()
36+
await scroll.evaluate((element) => {
37+
element.scrollTop = element.scrollHeight / 2
38+
})
39+
await expect
40+
.poll(() => page.locator('.row').first().getAttribute('data-index'))
41+
.not.toBe('0')
42+
await settleFrames(page)
43+
44+
if (direction === 'grow') {
45+
// Growing the rendered range requires shrinking rows. Set up taller
46+
// rows first so both paths start at the same scrolled viewport.
47+
await page.evaluate(() =>
48+
document.documentElement.style.setProperty('--scale', '1.25'),
49+
)
50+
await settleFrames(page)
51+
}
52+
53+
const errors: Array<string> = []
54+
page.on('pageerror', (error) => errors.push(error.message))
55+
await page.evaluate(() => {
56+
document.documentElement.dataset.resizeLoops = '0'
57+
})
58+
const countBefore = await page.locator('.row').count()
59+
const anchor = await scroll.evaluate((element) => {
60+
const top = element.getBoundingClientRect().top
61+
const row = [...element.querySelectorAll<HTMLElement>('.row')].find(
62+
(item) => {
63+
const rect = item.getBoundingClientRect()
64+
return rect.top <= top && rect.bottom > top
65+
},
66+
)!
67+
return {
68+
index: row.dataset.index!,
69+
offset: row.getBoundingClientRect().top - top,
70+
}
71+
})
72+
await page.evaluate(
73+
(scale) => document.documentElement.style.setProperty('--scale', scale),
74+
direction === 'grow' ? '1' : '1.25',
75+
)
76+
await expect.poll(() => page.locator('.row').count()).not.toBe(countBefore)
77+
await settleFrames(page)
78+
const countAfter = await page.locator('.row').count()
79+
if (direction === 'grow') expect(countAfter).toBeGreaterThan(countBefore)
80+
else expect(countAfter).toBeLessThan(countBefore)
81+
82+
const anchorBox = await page
83+
.locator(`.row[data-index="${anchor.index}"]`)
84+
.boundingBox()
85+
const scrollBox = await scroll.boundingBox()
86+
expect(anchorBox).not.toBeNull()
87+
expect(
88+
Math.abs(anchorBox!.y - scrollBox!.y - anchor.offset),
89+
).toBeLessThanOrEqual(1)
90+
expect(anchorBox!.y).toBeLessThanOrEqual(scrollBox!.y + 1)
91+
expect(anchorBox!.y + anchorBox!.height).toBeGreaterThan(scrollBox!.y)
92+
expect(await page.locator('html').getAttribute('data-resize-loops')).toBe(
93+
'0',
94+
)
95+
expect(errors).toEqual([])
96+
})
97+
}

‎packages/react-virtual/e2e/app/vite.config.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ export default defineConfig({
1616
__dirname,
1717
'measure-element/index.html',
1818
),
19+
'resize-delivery': path.resolve(
20+
__dirname,
21+
'resize-delivery/index.html',
22+
),
1923
'smooth-scroll': path.resolve(__dirname, 'smooth-scroll/index.html'),
2024
'smooth-prepend': path.resolve(__dirname, 'smooth-prepend/index.html'),
2125
'stale-index': path.resolve(__dirname, 'stale-index/index.html'),

‎packages/virtual-core/src/index.ts‎

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,9 @@ export class Virtualizer<
642642
private observer = (() => {
643643
let _ro: ResizeObserver | null = null
644644
let _frames: ReturnType<typeof createFrameQueue> | null = null
645+
let delivering = false
646+
let observeQueued = false
647+
const pendingObservations = new Set<Element>()
645648

646649
const get = () => {
647650
if (_ro) {
@@ -672,12 +675,23 @@ export class Virtualizer<
672675
this._noteClampMax()
673676
this._flushHoldIfReady(true)
674677
this._retryClampedAdjustment()
678+
// Ref-null callbacks run before the host nodes are detached.
679+
// Drop them after the sync renders, before the browser gathers
680+
// another round of ResizeObserver entries at their new depth.
681+
this.measureElement(null)
675682
}
676683
}
677684
}
678-
this.options.useAnimationFrameWithResizeObserver
679-
? frames.schedule(run)
680-
: run()
685+
if (this.options.useAnimationFrameWithResizeObserver) {
686+
frames.schedule(run)
687+
} else {
688+
delivering = true
689+
try {
690+
run()
691+
} finally {
692+
delivering = false
693+
}
694+
}
681695
}))
682696
}
683697

@@ -715,12 +729,37 @@ export class Virtualizer<
715729
disconnect: () => {
716730
get()?.disconnect()
717731
_frames?.cancel()
732+
pendingObservations.clear()
733+
observeQueued = false
718734
_ro = null
719735
_frames = null
720736
},
721-
observe: (target: Element) =>
722-
get()?.observe(target, { box: 'border-box' }),
723-
unobserve: (target: Element) => get()?.unobserve(target),
737+
observe: (target: Element) => {
738+
const observer = get()
739+
if (!observer) return
740+
if (!delivering) {
741+
observer.observe(target, { box: 'border-box' })
742+
return
743+
}
744+
// A sync render can also mount rows at the depth being delivered.
745+
// A microtask is still inside that delivery: register next frame,
746+
// while measureElement continues to measure the new row immediately.
747+
pendingObservations.add(target)
748+
if (!observeQueued) {
749+
observeQueued = true
750+
_frames?.schedule(() => {
751+
observeQueued = false
752+
pendingObservations.forEach((node) => {
753+
if (node.isConnected) _ro?.observe(node, { box: 'border-box' })
754+
})
755+
pendingObservations.clear()
756+
})
757+
}
758+
},
759+
unobserve: (target: Element) => {
760+
pendingObservations.delete(target)
761+
get()?.unobserve(target)
762+
},
724763
}
725764
})()
726765
range: { startIndex: number; endIndex: number } | null = null

‎packages/virtual-core/tests/measurement.test.ts‎

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -736,13 +736,20 @@ test('changing gap invalidates cached measurements (multi-lane)', () => {
736736

737737
function setupResizeBatch(onChange: (instance: any, sync: boolean) => void) {
738738
let roCallback: ResizeObserverCallback | null = null
739+
const observe = vi.fn()
740+
const unobserve = vi.fn()
741+
const frames = new Map<number, FrameRequestCallback>()
742+
let frameId = 0
739743
const mockWindow = {
740-
requestAnimationFrame: vi.fn(),
741-
cancelAnimationFrame: vi.fn(),
744+
requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
745+
frames.set(++frameId, callback)
746+
return frameId
747+
}),
748+
cancelAnimationFrame: vi.fn((id: number) => frames.delete(id)),
742749
performance: { now: () => Date.now() },
743750
ResizeObserver: vi.fn(function (cb: ResizeObserverCallback) {
744751
roCallback = cb
745-
return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() }
752+
return { observe, unobserve, disconnect: vi.fn() }
746753
}),
747754
}
748755
const el = {
@@ -811,7 +818,21 @@ function setupResizeBatch(onChange: (instance: any, sync: boolean) => void) {
811818
),
812819
{} as ResizeObserver,
813820
)
814-
return { virtualizer, sizes, node, deliver }
821+
const flush = () => {
822+
const queued = [...frames.values()]
823+
frames.clear()
824+
queued.forEach((callback) => callback(0))
825+
}
826+
return {
827+
virtualizer,
828+
sizes,
829+
node,
830+
deliver,
831+
observe,
832+
unobserve,
833+
frames,
834+
flush,
835+
}
815836
}
816837

817838
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
850871
expect(notifies).toEqual([true])
851872
})
852873

874+
test('unobserves rows detached by a synchronous resize render before delivery ends', () => {
875+
const { virtualizer, sizes, node, deliver, unobserve } = setupResizeBatch(
876+
(instance, sync) => {
877+
if (!sync) return
878+
// React calls the ref with null before removing the host element.
879+
instance.measureElement(null)
880+
node(5).isConnected = false
881+
},
882+
)
883+
sizes.set(3, 200)
884+
deliver([3])
885+
expect(unobserve).toHaveBeenCalledWith(node(5))
886+
expect(virtualizer.elementsCache.has(5)).toBe(false)
887+
})
888+
889+
test('measures new rows synchronously but observes them after resize delivery', () => {
890+
const { virtualizer, sizes, node, deliver, observe, frames, flush } =
891+
setupResizeBatch((instance, sync) => {
892+
if (sync) {
893+
instance.measureElement(node(10))
894+
instance.measureElement(node(11))
895+
}
896+
})
897+
sizes.set(10, 120)
898+
sizes.set(11, 130)
899+
sizes.set(3, 200)
900+
deliver([3])
901+
expect(virtualizer.getMeasurements()[10]!.size).toBe(120)
902+
expect(virtualizer.getMeasurements()[11]!.size).toBe(130)
903+
expect(observe.mock.calls.map(([target]) => target)).not.toContain(node(10))
904+
expect(observe.mock.calls.map(([target]) => target)).not.toContain(node(11))
905+
expect(frames.size).toBe(1)
906+
flush()
907+
expect(observe).toHaveBeenCalledWith(node(10), { box: 'border-box' })
908+
expect(observe).toHaveBeenCalledWith(node(11), { box: 'border-box' })
909+
})
910+
911+
test.each(['removed', 'replaced', 'disconnected'] as const)(
912+
'does not register a deferred row after it is %s',
913+
(action) => {
914+
const { virtualizer, sizes, node, deliver, observe, flush } =
915+
setupResizeBatch((instance, sync) => {
916+
if (sync) instance.measureElement(node(10))
917+
})
918+
sizes.set(10, 120)
919+
sizes.set(3, 200)
920+
deliver([3])
921+
expect(observe.mock.calls.map(([target]) => target)).not.toContain(node(10))
922+
923+
if (action === 'removed') {
924+
node(10).isConnected = false
925+
virtualizer.measureElement(null)
926+
} else if (action === 'replaced') {
927+
const replacement = { ...node(10) }
928+
virtualizer.measureElement(replacement)
929+
expect(observe).toHaveBeenCalledWith(replacement, { box: 'border-box' })
930+
} else {
931+
virtualizer._didMount()()
932+
}
933+
flush()
934+
expect(observe.mock.calls.map(([target]) => target)).not.toContain(node(10))
935+
},
936+
)
937+
853938
// ─── useAnimationFrameWithResizeObserver: frames of the scroll element's window
854939

855940
// A window other than the global one, as for a list rendered into a

0 commit comments

Comments
 (0)