Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/soft-tigers-sleep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/virtual-core': patch
---

Ignore connected measurement nodes whose indexes are outside the current item count.
9 changes: 8 additions & 1 deletion packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,8 @@ export class Virtualizer<
return
}

if (!this.isIndexInRange(index)) return

if (this.shouldMeasureDuringScroll(index)) {
this.resizeItem(
index,
Expand Down Expand Up @@ -1171,6 +1173,9 @@ export class Virtualizer<
},
)

private isIndexInRange = (index: number): boolean =>
index >= 0 && index < this.options.count

private getMeasurements = memo(
() => [this.getMeasurementOptions(), this.itemSizeCacheVersion],
(
Expand Down Expand Up @@ -1511,6 +1516,8 @@ export class Virtualizer<
}

const index = this.indexFromElement(node)
if (!this.isIndexInRange(index)) return

const key = this.options.getItemKey(index)
const prevNode = this.elementsCache.get(key)

Expand All @@ -1534,7 +1541,7 @@ export class Virtualizer<
}

resizeItem = (index: number, size: number) => {
if (index < 0 || index >= this.options.count) return
if (!this.isIndexInRange(index)) return

// Fast field reads. For lanes===1 we read raw start/size from the flat
// typed array, avoiding a Proxy.get + VirtualItem allocation per call.
Expand Down
84 changes: 84 additions & 0 deletions packages/virtual-core/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,90 @@ test('RO callback should not delete cache entry if node was replaced by React',
expect(virtualizer.elementsCache.get(3)).toBe(nodeB)
})

test('ignores connected stale ref and ResizeObserver measurements after count shrinks', () => {
let roCallback: ResizeObserverCallback | null = null
const MockResizeObserver = vi.fn(function (cb: ResizeObserverCallback) {
roCallback = cb
return {
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}
})
const mockWindow = {
requestAnimationFrame: vi.fn(),
cancelAnimationFrame: vi.fn(),
performance: { now: () => Date.now() },
ResizeObserver: MockResizeObserver,
}
const mockScrollElement = {
scrollTop: 0,
scrollLeft: 0,
scrollWidth: 1000,
scrollHeight: 5000,
offsetWidth: 400,
offsetHeight: 600,
ownerDocument: { defaultView: mockWindow },
} as unknown as HTMLDivElement

let virtualizer: Virtualizer<HTMLDivElement, HTMLElement>
const getItemKey = vi.fn((index: number) => {
if (index < 0 || index >= virtualizer.options.count) {
throw new Error(`getItemKey received stale index ${index}`)
}
return index
})
virtualizer = new Virtualizer({
count: 22,
estimateSize: () => 50,
getItemKey,
useCachedMeasurements: true,
getScrollElement: () => mockScrollElement,
scrollToFn: vi.fn(),
observeElementRect: (_instance, cb) => {
cb({ width: 400, height: 600 })
return () => {}
},
observeElementOffset: (_instance, cb) => {
cb(0, false)
return () => {}
},
})
virtualizer._willUpdate()

const staleNode = {
getAttribute: () => '21',
getBoundingClientRect: () => ({ height: 50, width: 400 }),
isConnected: true,
setAttribute: vi.fn(),
} as unknown as HTMLElement
virtualizer.measureElement(staleNode)

virtualizer.setOptions({ ...virtualizer.options, count: 1 })
getItemKey.mockClear()

expect(() => virtualizer.measureElement(staleNode)).not.toThrow()
expect(getItemKey).not.toHaveBeenCalled()

getItemKey.mockClear()
expect(roCallback).not.toBeNull()
expect(() => {
roCallback!(
[
{
target: staleNode,
contentRect: { height: 50, width: 400 } as DOMRectReadOnly,
borderBoxSize: [{ blockSize: 50, inlineSize: 400 }],
contentBoxSize: [{ blockSize: 50, inlineSize: 400 }],
devicePixelContentBoxSize: [{ blockSize: 50, inlineSize: 400 }],
} as ResizeObserverEntry,
],
{} as ResizeObserver,
)
}).not.toThrow()
expect(getItemKey).not.toHaveBeenCalled()
})

// ─── setOptions behavioral contract ──────────────────────────────────────────
// These tests pin down how setOptions merges defaults with user-supplied opts.
// They guard against regressions when changing the merge mechanism
Expand Down
Loading