From 4b5c59887a079cfe57834d0d380ad9c2b094d1ac Mon Sep 17 00:00:00 2001 From: Devesh Date: Fri, 21 Aug 2026 00:14:26 +0530 Subject: [PATCH 1/7] 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 23826cafac42d0a0afcae81bd1818a315cf4265f Mon Sep 17 00:00:00 2001 From: Devesh Date: Sun, 23 Aug 2026 16:07:05 +0530 Subject: [PATCH 2/7] 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 46c642823b7e4d9029543c5905dedec9fe9baa95 Mon Sep 17 00:00:00 2001 From: Devesh Date: Sun, 23 Aug 2026 16:08:31 +0530 Subject: [PATCH 3/7] 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 750f688e14991966b7ae34708d967e0a8a3d44ef Mon Sep 17 00:00:00 2001 From: Devesh Date: Sun, 23 Aug 2026 17:31:20 +0530 Subject: [PATCH 4/7] 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 c4a97ac84cd2c1e0fbd5ac567e962196c4aa1502 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 24 Aug 2026 15:10:28 +1000 Subject: [PATCH 5/7] 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 6636228d3388abb8332717892d58850964e41e43 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 24 Aug 2026 15:26:49 +1000 Subject: [PATCH 6/7] 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}} + + +
+
+ ); +}; From 4e2acc42c06d7ba910491c05e23e1860f2bbf109 Mon Sep 17 00:00:00 2001 From: Devesh Date: Wed, 26 Aug 2026 10:26:39 +0530 Subject: [PATCH 7/7] fix: handle viewport dimensions in ScrollView --- .../react-aria/src/virtualizer/ScrollView.tsx | 5 ++ .../test/virtualizer/ScrollView.test.tsx | 87 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 packages/react-aria/test/virtualizer/ScrollView.test.tsx diff --git a/packages/react-aria/src/virtualizer/ScrollView.tsx b/packages/react-aria/src/virtualizer/ScrollView.tsx index 80611b0a4ad..d04875af97a 100644 --- a/packages/react-aria/src/virtualizer/ScrollView.tsx +++ b/packages/react-aria/src/virtualizer/ScrollView.tsx @@ -70,6 +70,11 @@ interface ScrollViewAria { 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); 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; + } + }); +});