diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx
index 24e721fc529..7097b9d7632 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/src/virtualizer/ScrollView.tsx b/packages/react-aria/src/virtualizer/ScrollView.tsx
index 86b94e4174f..d04875af97a 100644
--- a/packages/react-aria/src/virtualizer/ScrollView.tsx
+++ b/packages/react-aria/src/virtualizer/ScrollView.tsx
@@ -67,6 +67,22 @@ interface ScrollViewAria {
contentProps: HTMLAttributes;
}
+function getClientSize(dom: HTMLElement) {
+ let clientWidth = dom.clientWidth;
+ let clientHeight = dom.clientHeight;
+ let doc = dom.ownerDocument;
+ if (!doc || dom === doc.documentElement || dom === doc.body || dom === doc.scrollingElement) {
+ return {clientWidth, clientHeight};
+ }
+
+ let rect = dom.getBoundingClientRect?.();
+ if (rect && rect.width > 0 && rect.height > 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
@@ -253,15 +269,15 @@ export function useScrollView(
// content size update, causing below layout effect to fire. This avoids infinite loops.
isUpdatingSize.current = true;
- let isTestEnv = process.env.NODE_ENV === 'test' && !process.env.VIRT_ON;
+ let isTest = process.env.NODE_ENV === 'test';
+ let isTestEnv = isTest && !process.env.VIRT_ON;
let isClientWidthMocked = Object.getOwnPropertyNames(window.HTMLElement.prototype).includes(
'clientWidth'
);
let isClientHeightMocked = Object.getOwnPropertyNames(window.HTMLElement.prototype).includes(
'clientHeight'
);
- let clientWidth = dom.clientWidth;
- let clientHeight = dom.clientHeight;
+ let {clientWidth, clientHeight} = isTest ? dom : getClientSize(dom);
let w = isTestEnv && !isClientWidthMocked ? Infinity : clientWidth;
let h = isTestEnv && !isClientHeightMocked ? Infinity : clientHeight;
@@ -286,8 +302,12 @@ 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);
+ let nextSize = isTest ? dom : getClientSize(dom);
+ if (
+ (!isTest && clientWidth !== nextSize.clientWidth) ||
+ clientHeight !== nextSize.clientHeight
+ ) {
+ state.size = new Size(nextSize.clientWidth, nextSize.clientHeight);
flush(() => {
updateVisibleRect();
onSizeChange?.(state.size);
diff --git a/packages/react-aria/test/virtualizer/ScrollView.test.tsx b/packages/react-aria/test/virtualizer/ScrollView.test.tsx
new file mode 100644
index 00000000000..3715fd29e19
--- /dev/null
+++ b/packages/react-aria/test/virtualizer/ScrollView.test.tsx
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2026 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+
+import {act, render} from '@react-spectrum/test-utils-internal';
+import React, {useRef} from 'react';
+import {Size} from 'react-stately/useVirtualizerState';
+import {useScrollView} from '../../src/virtualizer/ScrollView';
+
+function RootScrollView(
+ props: Partial[0]> & {target: HTMLElement}
+) {
+ let {target, ...otherProps} = props;
+ let ref = useRef(target);
+ let {contentProps} = useScrollView(
+ {
+ contentSize: new Size(1200, 2000),
+ onVisibleRectChange: jest.fn(),
+ allowsWindowScrolling: true,
+ ...otherProps
+ },
+ ref
+ );
+ return ;
+}
+
+describe('ScrollView', () => {
+ beforeAll(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ act(() => {
+ jest.runAllTimers();
+ });
+ });
+
+ it('preserves viewport client dimensions when attached to documentElement', () => {
+ let origNodeEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production';
+ try {
+ Object.defineProperty(document.documentElement, 'clientWidth', {
+ configurable: true,
+ value: 1200
+ });
+ Object.defineProperty(document.documentElement, 'clientHeight', {
+ configurable: true,
+ value: 800
+ });
+ Object.defineProperty(document.documentElement, 'offsetHeight', {
+ configurable: true,
+ value: 50
+ });
+ let rectSpy = jest.spyOn(document.documentElement, 'getBoundingClientRect').mockReturnValue({
+ width: 1200,
+ height: 50,
+ top: 0,
+ left: 0,
+ bottom: 50,
+ right: 1200,
+ x: 0,
+ y: 0,
+ toJSON: () => {}
+ });
+
+ let onSizeChange = jest.fn();
+ render();
+
+ expect(onSizeChange).toHaveBeenCalledWith(new Size(1200, 800));
+
+ delete (document.documentElement as any).clientWidth;
+ delete (document.documentElement as any).clientHeight;
+ delete (document.documentElement as any).offsetHeight;
+ rectSpy.mockRestore();
+ } finally {
+ process.env.NODE_ENV = origNodeEnv;
+ }
+ });
+});