diff --git a/.changeset/raf-scroll-element-window.md b/.changeset/raf-scroll-element-window.md new file mode 100644 index 000000000..c2278cb4d --- /dev/null +++ b/.changeset/raf-scroll-element-window.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): with `useAnimationFrameWithResizeObserver`, request frames from the scroll element's window instead of the global one, and cancel frames still queued on cleanup, so a list rendered into another window does not stall while the opener is minimized or occluded diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index cb5011392..6d7d8ce91 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -86,6 +86,25 @@ const getRect = (element: HTMLElement): Rect => { return { width: offsetWidth, height: offsetHeight } } +// Animation frames for `useAnimationFrameWithResizeObserver`, requested from +// the scroll element's own window rather than the global one. +const createFrameQueue = (targetWindow: Window) => { + const ids = new Set() + return { + schedule: (fn: () => void) => { + const id = targetWindow.requestAnimationFrame(() => { + ids.delete(id) + fn() + }) + ids.add(id) + }, + cancel: () => { + ids.forEach((id) => targetWindow.cancelAnimationFrame(id)) + ids.clear() + }, + } +} + export const defaultKeyExtractor = (index: number) => index export const defaultRangeExtractor = (range: Range) => { @@ -129,6 +148,7 @@ export const observeElementRect = ( return () => {} } + const frames = createFrameQueue(targetWindow) const observer = new targetWindow.ResizeObserver((entries) => { const run = () => { const entry = entries[0] @@ -143,7 +163,7 @@ export const observeElementRect = ( } instance.options.useAnimationFrameWithResizeObserver - ? requestAnimationFrame(run) + ? frames.schedule(run) : run() }) @@ -151,6 +171,7 @@ export const observeElementRect = ( return () => { observer.unobserve(element) + frames.cancel() } } @@ -566,17 +587,20 @@ export class Virtualizer< private now = () => this.targetWindow?.performance?.now?.() ?? Date.now() private observer = (() => { let _ro: ResizeObserver | null = null + let _frames: ReturnType | null = null const get = () => { if (_ro) { return _ro } - if (!this.targetWindow || !this.targetWindow.ResizeObserver) { + const targetWindow = this.targetWindow + if (!targetWindow || !targetWindow.ResizeObserver) { return null } - return (_ro = new this.targetWindow.ResizeObserver((entries) => { + const frames = (_frames = createFrameQueue(targetWindow)) + return (_ro = new targetWindow.ResizeObserver((entries) => { const run = () => { // Measure every entry before notifying. A notify that adjusted // scroll re-renders synchronously, and that render can unmount @@ -596,7 +620,7 @@ export class Virtualizer< } } this.options.useAnimationFrameWithResizeObserver - ? requestAnimationFrame(run) + ? frames.schedule(run) : run() })) } @@ -634,7 +658,9 @@ export class Virtualizer< return { disconnect: () => { get()?.disconnect() + _frames?.cancel() _ro = null + _frames = null }, observe: (target: Element) => get()?.observe(target, { box: 'border-box' }), diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 290d810a3..a50f6e709 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -5,6 +5,7 @@ import { defaultRangeExtractor, elementScroll, observeElementOffset, + observeElementRect, observeWindowOffset, windowScroll, } from '../src/index' @@ -5857,3 +5858,148 @@ test('a write to a scroll element that went away is not adopted by the next one' expect(v.scrollOffset).toBe(1001) }) + +// ─── useAnimationFrameWithResizeObserver: frames of the scroll element's window + +// A window other than the global one, as for a list rendered into a +// `window.open` popup of the same realm. Its frames run only on `flush`. +function createOwnWindow() { + const frames = new Map() + let nextId = 0 + let roCallback: ResizeObserverCallback | null = null + const ownWindow = { + requestAnimationFrame: vi.fn((cb: FrameRequestCallback) => { + frames.set(++nextId, cb) + return nextId + }), + 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() } + }), + } + const flush = () => { + const queued = [...frames.values()] + frames.clear() + queued.forEach((cb) => cb(0)) + } + const resize = (target: Element, blockSize: number) => + roCallback!( + [ + { + target, + borderBoxSize: [{ blockSize, inlineSize: 400 }], + } as unknown as ResizeObserverEntry, + ], + {} as ResizeObserver, + ) + const el = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 400, + scrollHeight: 5000, + offsetWidth: 400, + offsetHeight: 600, + ownerDocument: { defaultView: ownWindow }, + } as unknown as HTMLDivElement + return { ownWindow, frames, flush, resize, el } +} + +function withGlobalRaf(fn: (globalRaf: ReturnType) => void) { + const globalRaf = vi.fn() + vi.stubGlobal('requestAnimationFrame', globalRaf) + try { + fn(globalRaf) + } finally { + vi.unstubAllGlobals() + } +} + +function createDeferredVirtualizer(el: HTMLDivElement) { + const virtualizer = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => el, + scrollToFn: vi.fn(), + useAnimationFrameWithResizeObserver: true, + observeElementRect: (_instance, cb) => { + cb({ width: 400, height: 600 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + return () => {} + }, + }) + virtualizer._willUpdate() + virtualizer.getVirtualItems() + const node = { + getAttribute: (name: string) => (name === 'data-index' ? '3' : null), + getBoundingClientRect: () => ({ height: 50, width: 400 }), + isConnected: true, + } as unknown as HTMLElement + virtualizer.measureElement(node) + return { virtualizer, node } +} + +test('deferred item measurements wait for a frame of the scroll element window', () => { + withGlobalRaf((globalRaf) => { + const { ownWindow, flush, resize, el } = createOwnWindow() + const { virtualizer, node } = createDeferredVirtualizer(el) + expect(virtualizer.getTotalSize()).toBe(500) + + resize(node, 80) + expect(ownWindow.requestAnimationFrame).toHaveBeenCalledTimes(1) + expect(globalRaf).not.toHaveBeenCalled() + expect(virtualizer.getTotalSize()).toBe(500) + + flush() + expect(virtualizer.getTotalSize()).toBe(530) + }) +}) + +test('cleanup cancels a deferred item measurement still waiting for its frame', () => { + const { ownWindow, frames, flush, resize, el } = createOwnWindow() + const { virtualizer, node } = createDeferredVirtualizer(el) + const unmount = virtualizer._didMount() + + resize(node, 80) + expect(frames.size).toBe(1) + + unmount() + expect(ownWindow.cancelAnimationFrame).toHaveBeenCalledTimes(1) + expect(frames.size).toBe(0) + flush() + expect(virtualizer.getTotalSize()).toBe(500) +}) + +test('observeElementRect defers to a frame of the scroll element window and cancels it on unsubscribe', () => { + withGlobalRaf((globalRaf) => { + const { frames, flush, resize, el } = createOwnWindow() + const instance = { + scrollElement: el, + targetWindow: el.ownerDocument.defaultView, + options: { useAnimationFrameWithResizeObserver: true }, + } as unknown as Virtualizer + const cb = vi.fn() + + const unsubscribe = observeElementRect(instance, cb)! + expect(cb).toHaveBeenLastCalledWith({ width: 400, height: 600 }) + + resize(el, 700) + expect(cb).toHaveBeenCalledTimes(1) + flush() + expect(cb).toHaveBeenCalledTimes(2) + expect(cb).toHaveBeenLastCalledWith({ width: 400, height: 700 }) + + resize(el, 800) + unsubscribe() + expect(frames.size).toBe(0) + flush() + expect(cb).toHaveBeenCalledTimes(2) + expect(globalRaf).not.toHaveBeenCalled() + }) +})