Skip to content

Commit 05bb4d3

Browse files
fix(virtual-core): measure the visible scroll viewport (#1326)
1 parent 4296ac2 commit 05bb4d3

5 files changed

Lines changed: 105 additions & 15 deletions

File tree

‎.changeset/tidy-client-viewport.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/virtual-core': patch
3+
---
4+
5+
Measure the scroll viewport using client dimensions so borders and scrollbars do not obscure end-aligned items.

‎docs/api/virtualizer.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ observeElementRect: (
177177

178178
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`.
179179

180+
The built-in `observeElementRect` measures the visible client area (`clientWidth` and `clientHeight`), excluding borders and scrollbars. Custom implementations should report the same viewport dimensions.
181+
180182
### `observeElementOffset`
181183

182184
```tsx

‎packages/marko-virtual/tests/setup.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ if (!global.requestAnimationFrame) {
3131
})
3232

3333
// ---------------------------------------------------------------------------
34-
// offsetHeight / offsetWidth — always 0 in jsdom (no CSS layout engine).
35-
// observeElementRect calls getRect(element) = { offsetWidth, offsetHeight }.
34+
// Element dimensions are always 0 in jsdom (no CSS layout engine).
35+
// The viewport uses client dimensions, while rows use offset dimensions.
3636
// Without this, the virtualizer sees a 0-height viewport and renders no items.
3737
// 400px ÷ 50px default estimateSize = 8 visible + 5 overscan each side ≈ 18.
3838
// ---------------------------------------------------------------------------
@@ -44,3 +44,12 @@ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
4444
configurable: true,
4545
get: () => 400,
4646
})
47+
48+
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
49+
configurable: true,
50+
get: () => 400,
51+
})
52+
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
53+
configurable: true,
54+
get: () => 400,
55+
})

‎packages/virtual-core/src/index.ts‎

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ export interface Rect {
7474
//
7575

7676
const getRect = (element: HTMLElement): Rect => {
77-
const { offsetWidth, offsetHeight } = element
78-
return { width: offsetWidth, height: offsetHeight }
77+
const { clientWidth, clientHeight } = element
78+
return { width: clientWidth, height: clientHeight }
7979
}
8080

8181
// Animation frames for `useAnimationFrameWithResizeObserver`, requested from
@@ -141,16 +141,8 @@ export const observeElementRect = <T extends Element>(
141141
}
142142

143143
const frames = createFrameQueue(targetWindow)
144-
const observer = new targetWindow.ResizeObserver((entries) => {
144+
const observer = new targetWindow.ResizeObserver(() => {
145145
const run = () => {
146-
const entry = entries[0]
147-
if (entry?.borderBoxSize) {
148-
const box = entry.borderBoxSize[0]
149-
if (box) {
150-
handler({ width: box.inlineSize, height: box.blockSize })
151-
return
152-
}
153-
}
154146
handler(getRect(element as unknown as HTMLElement))
155147
}
156148

@@ -159,7 +151,7 @@ export const observeElementRect = <T extends Element>(
159151
: run()
160152
})
161153

162-
observer.observe(element, { box: 'border-box' })
154+
observer.observe(element, { box: 'content-box' })
163155

164156
return () => {
165157
observer.unobserve(element)

‎packages/virtual-core/tests/measurement.test.ts‎

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -877,7 +877,8 @@ function createOwnWindow() {
877877
frames.clear()
878878
queued.forEach((cb) => cb(0))
879879
}
880-
const resize = (target: Element, blockSize: number) =>
880+
const resize = (target: Element, blockSize: number) => {
881+
Object.assign(target, { clientHeight: blockSize })
881882
roCallback!(
882883
[
883884
{
@@ -887,7 +888,10 @@ function createOwnWindow() {
887888
],
888889
{} as ResizeObserver,
889890
)
891+
}
890892
const el = {
893+
clientWidth: 400,
894+
clientHeight: 600,
891895
scrollTop: 0,
892896
scrollLeft: 0,
893897
scrollWidth: 400,
@@ -994,3 +998,81 @@ test('observeElementRect defers to a frame of the scroll element window and canc
994998
expect(globalRaf).not.toHaveBeenCalled()
995999
})
9961000
})
1001+
1002+
test.each([false, true])(
1003+
'viewport measurements exclude borders and scrollbars (horizontal: %s)',
1004+
(horizontal) => {
1005+
const { el, resize } = createOwnWindow()
1006+
Object.assign(el, {
1007+
clientWidth: 370,
1008+
clientHeight: 570,
1009+
scrollWidth: 5000,
1010+
})
1011+
const virtualizer = new Virtualizer({
1012+
count: 100,
1013+
horizontal,
1014+
estimateSize: () => 50,
1015+
getScrollElement: () => el,
1016+
scrollToFn: vi.fn(),
1017+
observeElementRect,
1018+
observeElementOffset: (_instance, cb) => {
1019+
cb(0, false)
1020+
},
1021+
})
1022+
virtualizer._willUpdate()
1023+
expect(virtualizer.scrollRect).toEqual({ width: 370, height: 570 })
1024+
expect(virtualizer.getOffsetForIndex(10, 'end')?.[0]).toBe(
1025+
horizontal ? 180 : 0,
1026+
)
1027+
resize(el, 470)
1028+
expect(virtualizer.scrollRect).toEqual({ width: 370, height: 470 })
1029+
expect(virtualizer.getOffsetForIndex(10, 'end')?.[0]).toBe(
1030+
horizontal ? 180 : 80,
1031+
)
1032+
virtualizer._didMount()()
1033+
},
1034+
)
1035+
1036+
test('refreshes horizontal end alignment when a scrollbar toggles', () => {
1037+
const { el } = createOwnWindow()
1038+
let deliverResize: ResizeObserverCallback
1039+
let observedBox: ResizeObserverBoxOptions | undefined
1040+
el.ownerDocument.defaultView!.ResizeObserver = vi.fn(function (cb) {
1041+
deliverResize = cb
1042+
return {
1043+
observe: (_target: Element, options?: ResizeObserverOptions) => {
1044+
observedBox = options?.box
1045+
},
1046+
unobserve: vi.fn(),
1047+
disconnect: vi.fn(),
1048+
}
1049+
}) as unknown as typeof ResizeObserver
1050+
Object.assign(el, { scrollWidth: 5000 })
1051+
const virtualizer = new Virtualizer({
1052+
count: 100,
1053+
horizontal: true,
1054+
estimateSize: () => 50,
1055+
getScrollElement: () => el,
1056+
scrollToFn: vi.fn(),
1057+
observeElementRect,
1058+
observeElementOffset: (_instance, cb) => {
1059+
cb(0, false)
1060+
},
1061+
})
1062+
virtualizer._willUpdate()
1063+
expect(virtualizer.getOffsetForIndex(10, 'end')?.[0]).toBe(150)
1064+
for (const width of [385, 400]) {
1065+
// A classic scrollbar changes the content box, leaving the border box fixed.
1066+
Object.assign(el, { clientWidth: width })
1067+
if (observedBox !== 'border-box') {
1068+
deliverResize!(
1069+
[{ target: el } as ResizeObserverEntry],
1070+
{} as ResizeObserver,
1071+
)
1072+
}
1073+
expect(el.offsetWidth).toBe(400)
1074+
expect(virtualizer.scrollRect?.width).toBe(width)
1075+
expect(virtualizer.getOffsetForIndex(10, 'end')?.[0]).toBe(550 - width)
1076+
}
1077+
virtualizer._didMount()()
1078+
})

0 commit comments

Comments
 (0)