Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions packages/x-virtualizer/src/features/dimensions.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
});
68 changes: 53 additions & 15 deletions packages/x-virtualizer/src/features/dimensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BaseState>,
Expand All @@ -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);
}
Expand Down Expand Up @@ -660,7 +689,8 @@ export function observeRootNode(
}

const scrollbarSizeCache = new WeakMap<Element, number>();
function measureScrollbarSize(element: Element | null, scrollbarSize: number | undefined) {

export function measureScrollbarSize(element: Element | null, scrollbarSize?: number | undefined) {
if (scrollbarSize !== undefined) {
return scrollbarSize;
}
Expand All @@ -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);
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions packages/x-virtualizer/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading