From 22f0f72fd4badb2a167cfa6a8d59f5ed5c11acbd Mon Sep 17 00:00:00 2001 From: Devesh Date: Fri, 21 Aug 2026 00:14:26 +0530 Subject: [PATCH 1/6] 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); + }); + } } } From b3d63b239453fb473894953bfd132397ae1dbb0e Mon Sep 17 00:00:00 2001 From: Devesh Date: Sun, 23 Aug 2026 16:07:05 +0530 Subject: [PATCH 2/6] test: remove artificial fractional sizing tests --- .../test/ListBox.test.js | 68 ------------------- 1 file changed, 68 deletions(-) diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index de653ddfd65..b5703338085 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -1386,74 +1386,6 @@ 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'}); From 780e31ea546dd62dec811aca3048d5f1975cf969 Mon Sep 17 00:00:00 2001 From: Devesh Date: Sun, 23 Aug 2026 16:08:31 +0530 Subject: [PATCH 3/6] fix: preserve fractional virtualizer dimensions --- .../react-aria/src/virtualizer/ScrollView.tsx | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/react-aria/src/virtualizer/ScrollView.tsx b/packages/react-aria/src/virtualizer/ScrollView.tsx index abc2839e69d..0256d54b3e5 100644 --- a/packages/react-aria/src/virtualizer/ScrollView.tsx +++ b/packages/react-aria/src/virtualizer/ScrollView.tsx @@ -70,14 +70,10 @@ interface ScrollViewAria { 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); - } + clientWidth = rect.width - Math.max(0, dom.offsetWidth - dom.clientWidth); + clientHeight = rect.height - Math.max(0, dom.offsetHeight - dom.clientHeight); } return {clientWidth, clientHeight}; } @@ -275,7 +271,7 @@ export function useScrollView( let isClientHeightMocked = Object.getOwnPropertyNames(window.HTMLElement.prototype).includes( 'clientHeight' ); - let {clientWidth, clientHeight} = getClientSize(dom); + let {clientWidth, clientHeight} = isTestEnv ? dom : getClientSize(dom); let w = isTestEnv && !isClientWidthMocked ? Infinity : clientWidth; let h = isTestEnv && !isClientHeightMocked ? Infinity : clientHeight; @@ -300,15 +296,16 @@ 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) { - let nextSize = getClientSize(dom); - if (clientWidth !== nextSize.clientWidth || clientHeight !== nextSize.clientHeight) { - state.size = new Size(nextSize.clientWidth, nextSize.clientHeight); - flush(() => { - updateVisibleRect(); - onSizeChange?.(state.size); - }); - } + let nextSize = isTestEnv ? dom : getClientSize(dom); + if ( + (!isTestEnv && clientWidth !== nextSize.clientWidth) || + clientHeight !== nextSize.clientHeight + ) { + state.size = new Size(nextSize.clientWidth, nextSize.clientHeight); + flush(() => { + updateVisibleRect(); + onSizeChange?.(state.size); + }); } } From 3512cb48e82e96e7d2057a7eed10bd4b7c4b318a Mon Sep 17 00:00:00 2001 From: Devesh Date: Sun, 23 Aug 2026 17:31:20 +0530 Subject: [PATCH 4/6] fix: handle virtualizer test environment measurements --- packages/react-aria/src/virtualizer/ScrollView.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/react-aria/src/virtualizer/ScrollView.tsx b/packages/react-aria/src/virtualizer/ScrollView.tsx index 0256d54b3e5..80611b0a4ad 100644 --- a/packages/react-aria/src/virtualizer/ScrollView.tsx +++ b/packages/react-aria/src/virtualizer/ScrollView.tsx @@ -264,14 +264,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, clientHeight} = isTestEnv ? dom : getClientSize(dom); + let {clientWidth, clientHeight} = isTest ? dom : getClientSize(dom); let w = isTestEnv && !isClientWidthMocked ? Infinity : clientWidth; let h = isTestEnv && !isClientHeightMocked ? Infinity : clientHeight; @@ -296,9 +297,9 @@ 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. - let nextSize = isTestEnv ? dom : getClientSize(dom); + let nextSize = isTest ? dom : getClientSize(dom); if ( - (!isTestEnv && clientWidth !== nextSize.clientWidth) || + (!isTest && clientWidth !== nextSize.clientWidth) || clientHeight !== nextSize.clientHeight ) { state.size = new Size(nextSize.clientWidth, nextSize.clientHeight); From ed45a753e46a2cccdeb9c4bf1dbf6db91505d35a Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 24 Aug 2026 15:10:28 +1000 Subject: [PATCH 5/6] add chromatic and remove story now it's automated --- .../s2/chromatic/ListView.stories.tsx | 29 +++++++++++++++ .../stories/ListBox.stories.tsx | 35 ------------------- 2 files changed, 29 insertions(+), 35 deletions(-) diff --git a/packages/@react-spectrum/s2/chromatic/ListView.stories.tsx b/packages/@react-spectrum/s2/chromatic/ListView.stories.tsx index 6ec9dff6462..3d2d3fc0e8d 100644 --- a/packages/@react-spectrum/s2/chromatic/ListView.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/ListView.stories.tsx @@ -303,6 +303,35 @@ export const EmptyState: Story = { ) }; +// Renders two virtualized ListViews inside a 501px container split into 50% +// columns (250.5px each). Exercises the Virtualizer ScrollView's handling of +// fractional container widths so VRT catches subpixel overflow regressions. +export const FractionalWidth: Story = { + render: () => { + let fractionalItems = Array.from({length: 50}, (_, i) => ({id: i, name: `Item ${i + 1}`})); + return ( +
+
+ + {item => {item.name}} + +
+
+ + {item => {item.name}} + +
+
+ ); + } +}; + export const InsertionIndicator: Story = { ...Reorderable, play: async ({canvasElement}) => { diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index e4d0c396f57..24e721fc529 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -1146,38 +1146,3 @@ 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}} - - -
-
- ); -}; From fabdc41f0085562f1e17ece313340774e0e5dd38 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 24 Aug 2026 15:26:49 +1000 Subject: [PATCH 6/6] revert chromatic and fix storybook --- .../s2/chromatic/ListView.stories.tsx | 29 --------------- .../stories/ListBox.stories.tsx | 35 +++++++++++++++++++ 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/packages/@react-spectrum/s2/chromatic/ListView.stories.tsx b/packages/@react-spectrum/s2/chromatic/ListView.stories.tsx index 3d2d3fc0e8d..6ec9dff6462 100644 --- a/packages/@react-spectrum/s2/chromatic/ListView.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/ListView.stories.tsx @@ -303,35 +303,6 @@ export const EmptyState: Story = { ) }; -// Renders two virtualized ListViews inside a 501px container split into 50% -// columns (250.5px each). Exercises the Virtualizer ScrollView's handling of -// fractional container widths so VRT catches subpixel overflow regressions. -export const FractionalWidth: Story = { - render: () => { - let fractionalItems = Array.from({length: 50}, (_, i) => ({id: i, name: `Item ${i + 1}`})); - return ( -
-
- - {item => {item.name}} - -
-
- - {item => {item.name}} - -
-
- ); - } -}; - export const InsertionIndicator: Story = { ...Reorderable, play: async ({canvasElement}) => { 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}} + + +
+
+ ); +};