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; + } + }); +});