From 4274d54c309cf0f0cb4314764e92b5dc1002dcfd Mon Sep 17 00:00:00 2001 From: David Langley Date: Mon, 9 Nov 2026 09:43:50 +0000 Subject: [PATCH] fix(virtual-core): read the scroll element's size with each scroll event After the scroll element is resized, the browser fires its scroll event (from the clamped position) and the isScrolling reset timer before the ResizeObserver reports the new size. The notify from that event then pairs the new scroll position with the old `scrollRect`: a consumer working out "at the end" from `scrollOffset` and `scrollRect` in onChange sees the viewport leave the end for a frame when it did not. Read the element's size along with the offset in the scroll handler, with the same functions the default rect observers measure with, so every notify is self-consistent. Only done when a default observer (`observeElementRect` / `observeWindowRect`) is in use: a consumer's own rect observer may measure differently, and its size is left alone. --- .changeset/scroll-rect-with-scroll-event.md | 5 + packages/virtual-core/src/index.ts | 50 ++++- .../virtual-core/tests/anchor-end.test.ts | 195 ++++++++++++++++++ 3 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 .changeset/scroll-rect-with-scroll-event.md 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) +})