Skip to content

Commit d2cf98b

Browse files
authored
fix(virtual-core): ignore stale connected measurements (#1246)
1 parent ad2e6d0 commit d2cf98b

3 files changed

Lines changed: 97 additions & 1 deletion

File tree

.changeset/soft-tigers-sleep.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+
Ignore connected measurement nodes whose indexes are outside the current item count.

packages/virtual-core/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,8 @@ export class Virtualizer<
489489
return
490490
}
491491

492+
if (!this.isIndexInRange(index)) return
493+
492494
if (this.shouldMeasureDuringScroll(index)) {
493495
this.resizeItem(
494496
index,
@@ -1171,6 +1173,9 @@ export class Virtualizer<
11711173
},
11721174
)
11731175

1176+
private isIndexInRange = (index: number): boolean =>
1177+
index >= 0 && index < this.options.count
1178+
11741179
private getMeasurements = memo(
11751180
() => [this.getMeasurementOptions(), this.itemSizeCacheVersion],
11761181
(
@@ -1511,6 +1516,8 @@ export class Virtualizer<
15111516
}
15121517

15131518
const index = this.indexFromElement(node)
1519+
if (!this.isIndexInRange(index)) return
1520+
15141521
const key = this.options.getItemKey(index)
15151522
const prevNode = this.elementsCache.get(key)
15161523

@@ -1534,7 +1541,7 @@ export class Virtualizer<
15341541
}
15351542

15361543
resizeItem = (index: number, size: number) => {
1537-
if (index < 0 || index >= this.options.count) return
1544+
if (!this.isIndexInRange(index)) return
15381545

15391546
// Fast field reads. For lanes===1 we read raw start/size from the flat
15401547
// typed array, avoiding a Proxy.get + VirtualItem allocation per call.

packages/virtual-core/tests/index.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -940,6 +940,90 @@ test('RO callback should not delete cache entry if node was replaced by React',
940940
expect(virtualizer.elementsCache.get(3)).toBe(nodeB)
941941
})
942942

943+
test('ignores connected stale ref and ResizeObserver measurements after count shrinks', () => {
944+
let roCallback: ResizeObserverCallback | null = null
945+
const MockResizeObserver = vi.fn(function (cb: ResizeObserverCallback) {
946+
roCallback = cb
947+
return {
948+
observe: vi.fn(),
949+
unobserve: vi.fn(),
950+
disconnect: vi.fn(),
951+
}
952+
})
953+
const mockWindow = {
954+
requestAnimationFrame: vi.fn(),
955+
cancelAnimationFrame: vi.fn(),
956+
performance: { now: () => Date.now() },
957+
ResizeObserver: MockResizeObserver,
958+
}
959+
const mockScrollElement = {
960+
scrollTop: 0,
961+
scrollLeft: 0,
962+
scrollWidth: 1000,
963+
scrollHeight: 5000,
964+
offsetWidth: 400,
965+
offsetHeight: 600,
966+
ownerDocument: { defaultView: mockWindow },
967+
} as unknown as HTMLDivElement
968+
969+
let virtualizer: Virtualizer<HTMLDivElement, HTMLElement>
970+
const getItemKey = vi.fn((index: number) => {
971+
if (index < 0 || index >= virtualizer.options.count) {
972+
throw new Error(`getItemKey received stale index ${index}`)
973+
}
974+
return index
975+
})
976+
virtualizer = new Virtualizer({
977+
count: 22,
978+
estimateSize: () => 50,
979+
getItemKey,
980+
useCachedMeasurements: true,
981+
getScrollElement: () => mockScrollElement,
982+
scrollToFn: vi.fn(),
983+
observeElementRect: (_instance, cb) => {
984+
cb({ width: 400, height: 600 })
985+
return () => {}
986+
},
987+
observeElementOffset: (_instance, cb) => {
988+
cb(0, false)
989+
return () => {}
990+
},
991+
})
992+
virtualizer._willUpdate()
993+
994+
const staleNode = {
995+
getAttribute: () => '21',
996+
getBoundingClientRect: () => ({ height: 50, width: 400 }),
997+
isConnected: true,
998+
setAttribute: vi.fn(),
999+
} as unknown as HTMLElement
1000+
virtualizer.measureElement(staleNode)
1001+
1002+
virtualizer.setOptions({ ...virtualizer.options, count: 1 })
1003+
getItemKey.mockClear()
1004+
1005+
expect(() => virtualizer.measureElement(staleNode)).not.toThrow()
1006+
expect(getItemKey).not.toHaveBeenCalled()
1007+
1008+
getItemKey.mockClear()
1009+
expect(roCallback).not.toBeNull()
1010+
expect(() => {
1011+
roCallback!(
1012+
[
1013+
{
1014+
target: staleNode,
1015+
contentRect: { height: 50, width: 400 } as DOMRectReadOnly,
1016+
borderBoxSize: [{ blockSize: 50, inlineSize: 400 }],
1017+
contentBoxSize: [{ blockSize: 50, inlineSize: 400 }],
1018+
devicePixelContentBoxSize: [{ blockSize: 50, inlineSize: 400 }],
1019+
} as ResizeObserverEntry,
1020+
],
1021+
{} as ResizeObserver,
1022+
)
1023+
}).not.toThrow()
1024+
expect(getItemKey).not.toHaveBeenCalled()
1025+
})
1026+
9431027
// ─── setOptions behavioral contract ──────────────────────────────────────────
9441028
// These tests pin down how setOptions merges defaults with user-supplied opts.
9451029
// They guard against regressions when changing the merge mechanism

0 commit comments

Comments
 (0)