diff --git a/packages/x-virtualizer/src/features/dimensions.test.ts b/packages/x-virtualizer/src/features/dimensions.test.ts new file mode 100644 index 0000000000000..a66b2aeb44ec3 --- /dev/null +++ b/packages/x-virtualizer/src/features/dimensions.test.ts @@ -0,0 +1,79 @@ +import { isJSDOM } from 'test/utils/skipIf'; +import { Size } from '../models'; +import { measureScrollbarSize, observeRootNode } from './dimensions'; + +describe.skipIf(isJSDOM)('dimensions', () => { + describe('measureScrollbarSize', () => { + it('does not mistake a bordered, non-scrolling container border for a scrollbar', () => { + const borderless = document.createElement('div'); + borderless.style.overflow = 'hidden'; + borderless.style.width = '100px'; + borderless.style.height = '100px'; + document.body.appendChild(borderless); + + const bordered = document.createElement('div'); + bordered.style.overflow = 'hidden'; + bordered.style.border = '13px solid'; + bordered.style.width = '100px'; + bordered.style.height = '100px'; + document.body.appendChild(bordered); + + try { + const borderedSize = measureScrollbarSize(bordered); + const borderlessSize = measureScrollbarSize(borderless); + expect(borderedSize).to.equal(borderlessSize); + } finally { + borderless.remove(); + bordered.remove(); + } + }); + + it('measures a real overflowing scroll container directly', () => { + const scroller = document.createElement('div'); + scroller.style.overflow = 'scroll'; + scroller.style.width = '100px'; + scroller.style.height = '100px'; + const inner = document.createElement('div'); + inner.style.width = '200px'; + inner.style.height = '200px'; + scroller.appendChild(inner); + document.body.appendChild(scroller); + + try { + // A scrolling element is measured directly as `offsetWidth - clientWidth` + expect(measureScrollbarSize(scroller)).to.equal( + scroller.offsetWidth - scroller.clientWidth, + ); + } finally { + scroller.remove(); + } + }); + }); + + describe('observeRootNode', () => { + it('reports the correct content box size on the initial measurement', () => { + const node = document.createElement('div'); + node.style.boxSizing = 'border-box'; + node.style.width = '200px'; + node.style.height = '150px'; + node.style.border = '10px solid'; + node.style.padding = '5px'; + document.body.appendChild(node); + + let reported: Size | undefined; + const store = { state: { rootSize: Size.EMPTY } } as any; + const cleanup = observeRootNode(node, store, (size) => { + reported = size; + }); + + try { + // border-box 200x150 minus 10px border + 5px padding on each side. + expect(reported?.width).to.equal(200 - 2 * 10 - 2 * 5); // 170 + expect(reported?.height).to.equal(150 - 2 * 10 - 2 * 5); // 120 + } finally { + cleanup?.(); + node.remove(); + } + }); + }); +}); diff --git a/packages/x-virtualizer/src/features/dimensions.ts b/packages/x-virtualizer/src/features/dimensions.ts index 2ebee0bb9b6d8..1054c942f7368 100644 --- a/packages/x-virtualizer/src/features/dimensions.ts +++ b/packages/x-virtualizer/src/features/dimensions.ts @@ -619,6 +619,38 @@ function useRowsMeta( }; } +function parsePx(value: string): number { + return parseFloat(value) || 0; +} + +// Content-box size of the node, i.e. the same box that `ResizeObserver` reports via `entry.contentRect`. +// The initial synchronous read has to use the same box model as the observer, +// otherwise a root with a border or padding gets a different size on the first observer tick +// and the grid visibly jumps. +// We can't just use `clientWidth`/`clientHeight` because they round to integers, and we need to keep sub-pixel precision here +// (https://github.com/mui/mui-x/issues/9550, https://github.com/mui/mui-x/issues/15721). +function measureContentBoxSize(node: Element): Size { + const bounds = node.getBoundingClientRect(); + let { width, height } = bounds; + const style = ownerDocument(node).defaultView?.getComputedStyle(node); + if (style) { + width -= + parsePx(style.borderLeftWidth) + + parsePx(style.borderRightWidth) + + parsePx(style.paddingLeft) + + parsePx(style.paddingRight); + height -= + parsePx(style.borderTopWidth) + + parsePx(style.borderBottomWidth) + + parsePx(style.paddingTop) + + parsePx(style.paddingBottom); + } + return { + width: roundToDecimalPlaces(width, 1), + height: roundToDecimalPlaces(height, 1), + }; +} + export function observeRootNode( node: Element | null, store: Store, @@ -627,11 +659,8 @@ export function observeRootNode( if (!node) { return undefined; } - const bounds = node.getBoundingClientRect(); - const initialSize = { - width: roundToDecimalPlaces(bounds.width, 1), - height: roundToDecimalPlaces(bounds.height, 1), - }; + + const initialSize = measureContentBoxSize(node); if (store.state.rootSize === Size.EMPTY || !Size.equals(initialSize, store.state.rootSize)) { setRootSize(initialSize); } @@ -660,7 +689,8 @@ export function observeRootNode( } const scrollbarSizeCache = new WeakMap(); -function measureScrollbarSize(element: Element | null, scrollbarSize: number | undefined) { + +export function measureScrollbarSize(element: Element | null, scrollbarSize?: number | undefined) { if (scrollbarSize !== undefined) { return scrollbarSize; } @@ -675,15 +705,28 @@ function measureScrollbarSize(element: Element | null, scrollbarSize: number | u } const htmlElement = element as HTMLElement; + const doc = ownerDocument(element); + const style = doc.defaultView?.getComputedStyle(htmlElement); // First, try measuring `element` directly. When `element` is a scroll widget // that already has overflowing content (the typical case for the timeline's // virtual scrollbars), its rendered scrollbar reflects whatever // `scrollbar-width` / `::-webkit-scrollbar` styling is applied to *this* // element, which is exactly what we need. + // + // Only trust this on an axis that can actually scroll (`overflow: auto` or `scroll`) and is currently overflowing. + // Otherwise a bordered, non-scrolling element reports its border as + // the scrollbar size (`offsetWidth - clientWidth` is just the border when there is no scrollbar), + // which is wrong and causes a one-frame layout jump once the component mounts. + const canScrollY = style?.overflowY === 'auto' || style?.overflowY === 'scroll'; + const canScrollX = style?.overflowX === 'auto' || style?.overflowX === 'scroll'; const directSize = Math.max( - htmlElement.offsetWidth - htmlElement.clientWidth, - htmlElement.offsetHeight - htmlElement.clientHeight, + canScrollY && htmlElement.scrollHeight > htmlElement.clientHeight + ? htmlElement.offsetWidth - htmlElement.clientWidth + : 0, + canScrollX && htmlElement.scrollWidth > htmlElement.clientWidth + ? htmlElement.offsetHeight - htmlElement.clientHeight + : 0, ); if (directSize > 0) { scrollbarSizeCache.set(element, directSize); @@ -694,18 +737,13 @@ function measureScrollbarSize(element: Element | null, scrollbarSize: number | u // inherited, so copy it from the target element's computed style; otherwise // a parent that opts into `scrollbar-width: thin` would still be measured // with default scrollbar size. - const doc = ownerDocument(element); - const view = doc.defaultView; const scrollDiv = doc.createElement('div'); scrollDiv.style.width = '99px'; scrollDiv.style.height = '99px'; scrollDiv.style.position = 'absolute'; scrollDiv.style.overflow = 'scroll'; - if (view) { - const computed = view.getComputedStyle(htmlElement); - if (computed.scrollbarWidth) { - scrollDiv.style.scrollbarWidth = computed.scrollbarWidth; - } + if (style?.scrollbarWidth) { + scrollDiv.style.scrollbarWidth = style.scrollbarWidth; } scrollDiv.className = 'scrollDiv'; element.appendChild(scrollDiv); diff --git a/packages/x-virtualizer/tsconfig.json b/packages/x-virtualizer/tsconfig.json index 71f00540b7b4b..e4c4b141ed7c5 100644 --- a/packages/x-virtualizer/tsconfig.json +++ b/packages/x-virtualizer/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "types": ["@mui/internal-test-utils/initMatchers", "node"] + "types": ["@mui/internal-test-utils/initMatchers", "vitest/globals", "node"] }, - "include": ["src/**/*"] + "include": ["src/**/*", "../../test/utils/addChaiAssertions.ts"] }