From 22f0f72fd4badb2a167cfa6a8d59f5ed5c11acbd Mon Sep 17 00:00:00 2001 From: Devesh Date: Fri, 21 Aug 2026 00:14:26 +0530 Subject: [PATCH] fix(virtualizer): preserve subpixel precision in ScrollView to prevent fractional width overflow --- .../stories/ListBox.stories.tsx | 35 ++++++++++ .../test/ListBox.test.js | 68 +++++++++++++++++++ .../react-aria/src/virtualizer/ScrollView.tsx | 33 ++++++--- 3 files changed, 128 insertions(+), 8 deletions(-) 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); + }); + } } }