diff --git a/.changeset/scroll-rect-with-scroll-event.md b/.changeset/scroll-rect-with-scroll-event.md new file mode 100644 index 00000000..69eb0496 --- /dev/null +++ b/.changeset/scroll-rect-with-scroll-event.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +Read the scroll element's size along with each scroll event. After a resize the browser fires the scroll event (from the clamped position) before the ResizeObserver runs, so an update could pair the new scroll position with the old size. diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index bd6e7cb2..f60188bd 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -69,11 +69,18 @@ export interface Rect { // +// How the default rect observers measure the scroll element. The scroll +// handler measures with the same functions (`_readDefaultRect`). const getRect = (element: HTMLElement): Rect => { const { clientWidth, clientHeight } = element - return { width: clientWidth, height: clientHeight } + return { width: Math.round(clientWidth), height: Math.round(clientHeight) } } +const getWindowRect = (win: Window): Rect => ({ + width: win.innerWidth, + height: win.innerHeight, +}) + // Animation frames for `useAnimationFrameWithResizeObserver`, requested from // the scroll element's own window rather than the global one. const createFrameQueue = (targetWindow: Window) => { @@ -126,8 +133,7 @@ export const observeElementRect = ( } const handler = (rect: Rect) => { - const { width, height } = rect - cb({ width: Math.round(width), height: Math.round(height) }) + cb(rect) } handler(getRect(element as unknown as HTMLElement)) @@ -169,7 +175,7 @@ export const observeWindowRect = ( } const handler = () => { - cb({ width: element.innerWidth, height: element.innerHeight }) + cb(getWindowRect(element)) } handler() @@ -1118,7 +1124,7 @@ export class Virtualizer< this.unsubs.push( this.options.observeElementRect(this, (rect) => { - this.scrollRect = rect + this._setScrollRect(rect) this.maybeNotify() }), ) @@ -1250,6 +1256,14 @@ export class Virtualizer< this.scrollState = null } + // The element may have changed size since its ResizeObserver last + // reported it: a resize fires its scroll event (from the clamped + // position), and the isScrolling reset timer, before the observer + // runs. Read the size with the offset, so a notify never pairs a + // new position with an old size. + const liveRect = this._readDefaultRect() + if (liveRect !== null) this._setScrollRect(liveRect) + // A held correction is written once a write is possible. this._flushHoldIfReady(true) @@ -2355,6 +2369,32 @@ export class Virtualizer< return target > maxAtWrite + 0.5 ? { target, maxAtWrite } : null } + // The scroll element's current size, measured as the default rect observer + // measures it. Null when the consumer supplied their own rect observer: only + // a size produced the same way can be refreshed, and their size is theirs. + private _readDefaultRect = (): Rect | null => { + const el = this.scrollElement as unknown + const observe = this.options.observeElementRect as unknown + if (el == null) return null + if (observe === observeElementRect) return getRect(el as HTMLElement) + if (observe === observeWindowRect) return getWindowRect(el as Window) + return null + } + + // Take a new size for the scroll element, from its rect observer or read + // with a scroll event. + private _setScrollRect = (rect: Rect) => { + const prev = this.scrollRect + if ( + prev !== null && + prev.width === rect.width && + prev.height === rect.height + ) { + return + } + this.scrollRect = rect + } + private getVirtualDistanceFromEnd = () => { // `_getLayoutTotalSize()` measures the sizer, which starts `scrollMargin` // into the scroll content, while `getScrollOffset()` is measured from the diff --git a/packages/virtual-core/tests/anchor-end.test.ts b/packages/virtual-core/tests/anchor-end.test.ts index 0a95bfb7..ba1745a6 100644 --- a/packages/virtual-core/tests/anchor-end.test.ts +++ b/packages/virtual-core/tests/anchor-end.test.ts @@ -3,6 +3,8 @@ import { Virtualizer, observeElementOffset, observeElementRect, + observeWindowOffset, + observeWindowRect, } from '../src/index' // ─── end anchoring / chat-style reverse virtualization ────────────────────── @@ -700,3 +702,196 @@ test('#1258: cleanup drops a pending clamped write', () => { expect(virtualizer['_clampedAdjustment']).toBeNull() }) + +// ─── the size is read with a scroll event ─────────────────────────────────── + +// An end-anchored list using the default rect observer (the one the refresh +// measures like), 10 rows of 50px in a 200px viewport, scrolled to the end. +function createDefaultRectVirtualizer( + onChange?: (v: Virtualizer) => void, +) { + let offsetCb: ((offset: number, isScrolling: boolean) => void) | null = null + const el: any = { + scrollTop: 300, + scrollLeft: 0, + scrollHeight: 500, + scrollWidth: 400, + clientHeight: 200, + clientWidth: 400, + offsetHeight: 200, + ownerDocument: { + defaultView: { + requestAnimationFrame: () => 1, + cancelAnimationFrame: () => {}, + performance: { now: () => 0 }, + ResizeObserver: vi.fn(function () { + return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() } + }), + }, + }, + } + const virtualizer = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => el, + scrollToFn: () => {}, + observeElementRect, + observeElementOffset: (_inst, cb) => { + offsetCb = cb + cb(el.scrollTop, false) + return () => {} + }, + anchorTo: 'end', + onChange, + }) + virtualizer._willUpdate() + virtualizer['getMeasurements']() + return { + virtualizer, + el, + emitScroll(nextOffset: number, isScrolling = false) { + el.scrollTop = nextOffset + offsetCb?.(nextOffset, isScrolling) + }, + } +} + +test('a scroll event after the scroll element grew reports the new size with the new offset', () => { + // The browser clamps scrollTop and fires the scroll event before the + // ResizeObserver runs; the notify must not pair the new offset with the + // old height. + const { virtualizer, el, emitScroll } = createDefaultRectVirtualizer() + expect(virtualizer.scrollRect).toEqual({ width: 400, height: 200 }) + expect(virtualizer.isAtEnd()).toBe(true) + + el.clientHeight = 250 + el.offsetHeight = 250 + emitScroll(250, false) + + expect(virtualizer.scrollRect?.height).toBe(250) + expect(virtualizer.getVirtualDistanceFromEnd()).toBe(0) + expect(virtualizer.isAtEnd()).toBe(true) +}) + +test('a consumer sees the new size with the new offset in onChange for that scroll event', () => { + const seen: Array<[number, number | undefined]> = [] + const { el, emitScroll } = createDefaultRectVirtualizer((instance) => { + seen.push([instance.scrollOffset!, instance.scrollRect?.height]) + }) + + el.clientHeight = 250 + el.offsetHeight = 250 + emitScroll(250, true) + + expect(seen.at(-1)).toEqual([250, 250]) +}) + +test('a scroll event leaves a size from a custom rect observer alone', () => { + // The consumer measures differently (here: a fixed 580 while the element + // reads 600). Only a size produced by the default observer is refreshed. + const scrollToFn = vi.fn() + let scrollCb: ((o: number, s: boolean) => void) | null = null + const v = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => + ({ + scrollTop: 0, + scrollLeft: 0, + scrollHeight: 500, + clientHeight: 600, + clientWidth: 400, + offsetHeight: 600, + }) as any, + scrollToFn, + observeElementRect: (_inst, cb) => { + cb({ width: 400, height: 580 }) + return () => {} + }, + observeElementOffset: (_inst, cb) => { + scrollCb = cb + cb(0, false) + return () => {} + }, + }) + v._willUpdate() + v['getMeasurements']() + + scrollCb!(100, true) + + expect(v.scrollRect).toEqual({ width: 400, height: 580 }) +}) + +test('a scroll event does not invent a size for a scroll element whose rect observer reports nothing', () => { + const scrollToFn = vi.fn() + let scrollCb: ((o: number, s: boolean) => void) | null = null + const v = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => + ({ + scrollTop: 0, + scrollLeft: 0, + scrollHeight: 500, + clientHeight: 200, + clientWidth: 400, + offsetHeight: 200, + }) as any, + scrollToFn, + observeElementRect: () => {}, + observeElementOffset: (_inst, cb) => { + scrollCb = cb + cb(0, false) + return () => {} + }, + }) + v._willUpdate() + v['getMeasurements']() + + scrollCb!(100, true) + + expect(v.scrollRect).toEqual({ width: 0, height: 0 }) +}) + +test('a window scroller reads innerHeight with the scroll event, as its rect observer does', () => { + const listeners = new Map void>>() + const win: any = { + innerWidth: 800, + innerHeight: 600, + scrollX: 0, + scrollY: 0, + document: { documentElement: { scrollHeight: 5000, scrollWidth: 800 } }, + addEventListener: (name: string, fn: () => void) => { + if (!listeners.has(name)) listeners.set(name, new Set()) + listeners.get(name)!.add(fn) + }, + removeEventListener: (name: string, fn: () => void) => { + listeners.get(name)?.delete(fn) + }, + requestAnimationFrame: () => 1, + cancelAnimationFrame: () => {}, + setTimeout: () => 1, + clearTimeout: () => {}, + performance: { now: () => 0 }, + } + win.window = win + const v = new Virtualizer({ + count: 100, + estimateSize: () => 50, + getScrollElement: () => win, + scrollToFn: () => {}, + observeElementRect: observeWindowRect, + observeElementOffset: observeWindowOffset, + }) + v._willUpdate() + v['getMeasurements']() + expect(v.scrollRect).toEqual({ width: 800, height: 600 }) + + // The window grows and its scroll event arrives before the resize event. + win.innerHeight = 700 + win.scrollY = 4300 + listeners.get('scroll')!.forEach((fn) => fn()) + + expect(v.scrollRect).toEqual({ width: 800, height: 700 }) + expect(v.getVirtualDistanceFromEnd()).toBe(0) +})