From 34eb905348eabad7deeca5337fc684e017fe50e1 Mon Sep 17 00:00:00 2001 From: Aditya Reddy <82123567+adityareddy-dev@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:58:36 -0500 Subject: [PATCH 1/2] fix(virtual-core): measure the visible scroll viewport --- .changeset/tidy-client-viewport.md | 5 +++ docs/api/virtualizer.md | 2 + packages/marko-virtual/tests/setup.ts | 13 +++++- packages/virtual-core/src/index.ts | 14 ++----- .../virtual-core/tests/measurement.test.ts | 40 ++++++++++++++++++- 5 files changed, 60 insertions(+), 14 deletions(-) create mode 100644 .changeset/tidy-client-viewport.md 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..be1011e20 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)) } diff --git a/packages/virtual-core/tests/measurement.test.ts b/packages/virtual-core/tests/measurement.test.ts index ba16444d2..0623bf5af 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,37 @@ 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()() + }, +) From 4fb2d35070a846c12a2faaeac48137069302b783 Mon Sep 17 00:00:00 2001 From: Aditya Reddy <82123567+adityareddy-dev@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:47:06 -0500 Subject: [PATCH 2/2] fix(virtual-core): observe scrollbar viewport changes --- packages/virtual-core/src/index.ts | 2 +- .../virtual-core/tests/measurement.test.ts | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index be1011e20..949eb20d0 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -151,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 0623bf5af..f29604613 100644 --- a/packages/virtual-core/tests/measurement.test.ts +++ b/packages/virtual-core/tests/measurement.test.ts @@ -1032,3 +1032,47 @@ test.each([false, true])( 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()() +})