Skip to content
Open
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/fix-null-targetwindow-scrolltoindex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/virtual-core': patch
---

Fix crash when component unmounts during `scrollToIndex` by adding a null guard for `targetWindow` inside the `requestAnimationFrame` callback
2 changes: 2 additions & 0 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,8 @@ export class Virtualizer<
this._scrollToOffset(offset, { adjustments: undefined, behavior })

this.targetWindow.requestAnimationFrame(() => {
if (!this.targetWindow) return

const verify = () => {
// Abort if a new scrollToIndex was called with a different index
if (this.currentScrollToIndex !== index) return
Expand Down
73 changes: 73 additions & 0 deletions packages/virtual-core/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,76 @@ test('should update getTotalSize() when count option changes (filtering/search)'

expect(virtualizer.getTotalSize()).toBe(5000) // 100 × 50
})

test('should not throw when component unmounts during scrollToIndex rAF loop', () => {
// Collect rAF callbacks so we can flush them manually
const rafCallbacks: Array<FrameRequestCallback> = []
const mockRaf = vi.fn((cb: FrameRequestCallback) => {
rafCallbacks.push(cb)
return rafCallbacks.length
})

const mockWindow = {
requestAnimationFrame: mockRaf,
ResizeObserver: vi.fn(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
})),
}

const mockScrollElement = {
scrollTop: 0,
scrollLeft: 0,
scrollWidth: 1000,
scrollHeight: 5000,
offsetWidth: 400,
offsetHeight: 600,
ownerDocument: {
defaultView: mockWindow,
},
} as unknown as HTMLDivElement

const virtualizer = new Virtualizer({
count: 100,
estimateSize: () => 50,
measureElement: (el) => el.getBoundingClientRect().height,
getScrollElement: () => mockScrollElement,
scrollToFn: vi.fn(),
observeElementRect: (instance, cb) => {
cb({ width: 400, height: 600 })
return () => {}
},
observeElementOffset: (instance, cb) => {
cb(0, false)
return () => {}
},
})

// Initialize the virtualizer so targetWindow is set
virtualizer._willUpdate()

// Populate elementsCache so isDynamicMode() returns true.
// This triggers the code path where the rAF callback calls
// this.targetWindow!.requestAnimationFrame(verify)
const mockElement = {
getBoundingClientRect: () => ({ height: 50 }),
isConnected: true,
setAttribute: vi.fn(),
} as unknown as HTMLElement
virtualizer.elementsCache.set(0, mockElement)

// Trigger scrollToIndex which schedules a rAF callback
virtualizer.scrollToIndex(50)

// Simulate component unmount — cleanup sets targetWindow to null
const unmount = virtualizer._didMount()
unmount()

// Flush all pending rAF callbacks — this should not throw
// Without the fix, this crashes with:
// "Cannot read properties of null (reading 'requestAnimationFrame')"
expect(() => {
rafCallbacks.forEach((cb) => cb(0))
}).not.toThrow()
})