diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx
index 24e721fc529..e4d0c396f57 100644
--- a/packages/react-aria-components/stories/ListBox.stories.tsx
+++ b/packages/react-aria-components/stories/ListBox.stories.tsx
@@ -1146,3 +1146,38 @@ export const DropOntoRoot = () => (
);
+
+export const FractionalWidth: StoryFn = () => {
+ let items = Array.from({length: 50}, (_, i) => ({id: i, name: `Item ${i + 1}`}));
+ return (
+
+
+
+
+ {item => {item.name}}
+
+
+
+
+
+
+ {item => {item.name}}
+
+
+
+
+ );
+};
diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js
index b5703338085..de653ddfd65 100644
--- a/packages/react-aria-components/test/ListBox.test.js
+++ b/packages/react-aria-components/test/ListBox.test.js
@@ -1386,6 +1386,74 @@ describe('ListBox', () => {
]);
});
+ it('should not cause horizontal overflow with fractional container width', () => {
+ let items = [
+ {id: 1, name: 'Item 1'},
+ {id: 2, name: 'Item 2'}
+ ];
+
+ jest.restoreAllMocks();
+ jest.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({
+ width: 250.5,
+ height: 500,
+ top: 0,
+ left: 0,
+ bottom: 500,
+ right: 250.5,
+ x: 0,
+ y: 0,
+ toJSON: () => {}
+ }));
+ jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 251);
+ jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 500);
+
+ let {getByRole} = render(
+
+
+ {item => {item.name}}
+
+
+ );
+
+ let listbox = getByRole('listbox');
+ let contentWrapper = listbox.firstElementChild;
+ expect(parseFloat(contentWrapper.style.width)).toBeLessThanOrEqual(250.5);
+ });
+
+ it('should not cause vertical overflow with fractional container height', () => {
+ let items = [
+ {id: 1, name: 'Item 1'},
+ {id: 2, name: 'Item 2'}
+ ];
+
+ jest.restoreAllMocks();
+ jest.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({
+ width: 500,
+ height: 250.5,
+ top: 0,
+ left: 0,
+ bottom: 250.5,
+ right: 500,
+ x: 0,
+ y: 0,
+ toJSON: () => {}
+ }));
+ jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 500);
+ jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 251);
+
+ let {getByRole} = render(
+
+
+ {item => {item.name}}
+
+
+ );
+
+ let listbox = getByRole('listbox');
+ let contentWrapper = listbox.firstElementChild;
+ expect(parseFloat(contentWrapper.style.height)).toBeLessThanOrEqual(250.5);
+ });
+
it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async () => {
let {getByRole} = renderListbox({selectionMode: 'multiple', escapeKeyBehavior: 'none'});
diff --git a/packages/react-aria/src/virtualizer/ScrollView.tsx b/packages/react-aria/src/virtualizer/ScrollView.tsx
index 86b94e4174f..abc2839e69d 100644
--- a/packages/react-aria/src/virtualizer/ScrollView.tsx
+++ b/packages/react-aria/src/virtualizer/ScrollView.tsx
@@ -67,6 +67,21 @@ interface ScrollViewAria {
contentProps: HTMLAttributes;
}
+function getClientSize(dom: HTMLElement) {
+ let clientWidth = dom.clientWidth;
+ let clientHeight = dom.clientHeight;
+ let isTestEnv = process.env.NODE_ENV === 'test' && !process.env.VIRT_ON;
+
+ let rect = dom.getBoundingClientRect?.();
+ if (rect && rect.width > 0 && rect.height > 0) {
+ if (!isTestEnv || rect.width % 1 !== 0 || rect.height % 1 !== 0) {
+ clientWidth = rect.width - Math.max(0, dom.offsetWidth - dom.clientWidth);
+ clientHeight = rect.height - Math.max(0, dom.offsetHeight - dom.clientHeight);
+ }
+ }
+ return {clientWidth, clientHeight};
+}
+
export function useScrollView(
props: ScrollViewProps,
ref: RefObject
@@ -260,8 +275,7 @@ export function useScrollView(
let isClientHeightMocked = Object.getOwnPropertyNames(window.HTMLElement.prototype).includes(
'clientHeight'
);
- let clientWidth = dom.clientWidth;
- let clientHeight = dom.clientHeight;
+ let {clientWidth, clientHeight} = getClientSize(dom);
let w = isTestEnv && !isClientWidthMocked ? Infinity : clientWidth;
let h = isTestEnv && !isClientHeightMocked ? Infinity : clientHeight;
@@ -286,12 +300,15 @@ export function useScrollView(
// adjusted space. In very specific cases this might result in the scrollbars disappearing
// again, resulting in extra padding. We stop after a maximum of two layout passes to avoid
// an infinite loop. This matches how browsers behavior with native CSS grid layout.
- if ((!isTestEnv && clientWidth !== dom.clientWidth) || clientHeight !== dom.clientHeight) {
- state.size = new Size(dom.clientWidth, dom.clientHeight);
- flush(() => {
- updateVisibleRect();
- onSizeChange?.(state.size);
- });
+ if (!isTestEnv) {
+ let nextSize = getClientSize(dom);
+ if (clientWidth !== nextSize.clientWidth || clientHeight !== nextSize.clientHeight) {
+ state.size = new Size(nextSize.clientWidth, nextSize.clientHeight);
+ flush(() => {
+ updateVisibleRect();
+ onSizeChange?.(state.size);
+ });
+ }
}
}