diff --git a/.changeset/tidy-client-viewport.md b/.changeset/tidy-client-viewport.md new file mode 100644 index 000000000..5ac85c397 --- /dev/null +++ b/.changeset/tidy-client-viewport.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +Measure the scroll viewport using client dimensions so borders and scrollbars do not obscure end-aligned items. diff --git a/docs/api/virtualizer.md b/docs/api/virtualizer.md index 4eafa0c43..a5715fbc1 100644 --- a/docs/api/virtualizer.md +++ b/docs/api/virtualizer.md @@ -177,6 +177,8 @@ observeElementRect: ( An optional function that if provided is called when the scrollElement changes and should implement the initial measurement and continuous monitoring of the scrollElement's `Rect` (an object with `width` and `height`). It's called with the instance (which also gives you access to the scrollElement via `instance.scrollElement`. Built-in implementations are exported as `observeElementRect` and `observeWindowRect` which are automatically configured for you by your framework adapter's exported functions like `useVirtualizer` or `useWindowVirtualizer`. +The built-in `observeElementRect` measures the visible client area (`clientWidth` and `clientHeight`), excluding borders and scrollbars. Custom implementations should report the same viewport dimensions. + ### `observeElementOffset` ```tsx diff --git a/packages/marko-virtual/tests/setup.ts b/packages/marko-virtual/tests/setup.ts index bc9a61100..0cf35811e 100644 --- a/packages/marko-virtual/tests/setup.ts +++ b/packages/marko-virtual/tests/setup.ts @@ -31,8 +31,8 @@ if (!global.requestAnimationFrame) { }) // --------------------------------------------------------------------------- -// offsetHeight / offsetWidth — always 0 in jsdom (no CSS layout engine). -// observeElementRect calls getRect(element) = { offsetWidth, offsetHeight }. +// Element dimensions are always 0 in jsdom (no CSS layout engine). +// The viewport uses client dimensions, while rows use offset dimensions. // Without this, the virtualizer sees a 0-height viewport and renders no items. // 400px ÷ 50px default estimateSize = 8 visible + 5 overscan each side ≈ 18. // --------------------------------------------------------------------------- @@ -44,3 +44,12 @@ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 400, }) + +Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + get: () => 400, +}) +Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get: () => 400, +}) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index b66fbe1ce..949eb20d0 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -74,8 +74,8 @@ export interface Rect { // const getRect = (element: HTMLElement): Rect => { - const { offsetWidth, offsetHeight } = element - return { width: offsetWidth, height: offsetHeight } + const { clientWidth, clientHeight } = element + return { width: clientWidth, height: clientHeight } } // Animation frames for `useAnimationFrameWithResizeObserver`, requested from @@ -141,16 +141,8 @@ export const observeElementRect = ( } const frames = createFrameQueue(targetWindow) - const observer = new targetWindow.ResizeObserver((entries) => { + const observer = new targetWindow.ResizeObserver(() => { const run = () => { - const entry = entries[0] - if (entry?.borderBoxSize) { - const box = entry.borderBoxSize[0] - if (box) { - handler({ width: box.inlineSize, height: box.blockSize }) - return - } - } handler(getRect(element as unknown as HTMLElement)) } @@ -159,7 +151,7 @@ export const observeElementRect = ( : run() }) - observer.observe(element, { box: 'border-box' }) + observer.observe(element, { box: 'content-box' }) return () => { observer.unobserve(element) diff --git a/packages/virtual-core/tests/measurement.test.ts b/packages/virtual-core/tests/measurement.test.ts index ba16444d2..f29604613 100644 --- a/packages/virtual-core/tests/measurement.test.ts +++ b/packages/virtual-core/tests/measurement.test.ts @@ -877,7 +877,8 @@ function createOwnWindow() { frames.clear() queued.forEach((cb) => cb(0)) } - const resize = (target: Element, blockSize: number) => + const resize = (target: Element, blockSize: number) => { + Object.assign(target, { clientHeight: blockSize }) roCallback!( [ { @@ -887,7 +888,10 @@ function createOwnWindow() { ], {} as ResizeObserver, ) + } const el = { + clientWidth: 400, + clientHeight: 600, scrollTop: 0, scrollLeft: 0, scrollWidth: 400, @@ -994,3 +998,81 @@ test('observeElementRect defers to a frame of the scroll element window and canc expect(globalRaf).not.toHaveBeenCalled() }) }) + +test.each([false, true])( + 'viewport measurements exclude borders and scrollbars (horizontal: %s)', + (horizontal) => { + const { el, resize } = createOwnWindow() + Object.assign(el, { + clientWidth: 370, + clientHeight: 570, + scrollWidth: 5000, + }) + const virtualizer = new Virtualizer({ + count: 100, + horizontal, + estimateSize: () => 50, + getScrollElement: () => el, + scrollToFn: vi.fn(), + observeElementRect, + observeElementOffset: (_instance, cb) => { + cb(0, false) + }, + }) + virtualizer._willUpdate() + expect(virtualizer.scrollRect).toEqual({ width: 370, height: 570 }) + expect(virtualizer.getOffsetForIndex(10, 'end')?.[0]).toBe( + horizontal ? 180 : 0, + ) + resize(el, 470) + expect(virtualizer.scrollRect).toEqual({ width: 370, height: 470 }) + expect(virtualizer.getOffsetForIndex(10, 'end')?.[0]).toBe( + horizontal ? 180 : 80, + ) + virtualizer._didMount()() + }, +) + +test('refreshes horizontal end alignment when a scrollbar toggles', () => { + const { el } = createOwnWindow() + let deliverResize: ResizeObserverCallback + let observedBox: ResizeObserverBoxOptions | undefined + el.ownerDocument.defaultView!.ResizeObserver = vi.fn(function (cb) { + deliverResize = cb + return { + observe: (_target: Element, options?: ResizeObserverOptions) => { + observedBox = options?.box + }, + unobserve: vi.fn(), + disconnect: vi.fn(), + } + }) as unknown as typeof ResizeObserver + Object.assign(el, { scrollWidth: 5000 }) + const virtualizer = new Virtualizer({ + count: 100, + horizontal: true, + estimateSize: () => 50, + getScrollElement: () => el, + scrollToFn: vi.fn(), + observeElementRect, + observeElementOffset: (_instance, cb) => { + cb(0, false) + }, + }) + virtualizer._willUpdate() + expect(virtualizer.getOffsetForIndex(10, 'end')?.[0]).toBe(150) + for (const width of [385, 400]) { + // A classic scrollbar changes the content box, leaving the border box fixed. + Object.assign(el, { clientWidth: width }) + if (observedBox !== 'border-box') { + deliverResize!( + [{ target: el } as ResizeObserverEntry], + {} as ResizeObserver, + ) + } + expect(el.offsetWidth).toBe(400) + expect(virtualizer.scrollRect?.width).toBe(width) + expect(virtualizer.getOffsetForIndex(10, 'end')?.[0]).toBe(550 - width) + } + virtualizer._didMount()() +})