From f0d9f615ccc71817af0b3835a0ebb5ab6f79e57b Mon Sep 17 00:00:00 2001 From: waterWang Date: Thu, 20 Aug 2026 13:52:09 +0800 Subject: [PATCH] fix: floor contentSize and item widths in layouts to prevent horizontal scrollbar from fractional container widths When the container width is fractional (e.g. 250.5px), the browser's clientWidth returns an integer that may be rounded up, causing the virtualizer's contentSize.width to be larger than the actual available space. This produces a horizontal scrollbar. Fix: floor the width in contentSize calculation and item rect width computation across ListLayout, GridLayout, WaterfallLayout, and the AI ListLayout, ensuring no layout spills beyond the container. Reference: TableLayout fix #9448 (PR #10238) which used the same Math.floor approach in TableUtils.calculateColumnSizes. Fixes #10471 --- packages/@react-spectrum/ai/src/ListLayout.ts | 2 +- .../react-stately/src/layout/GridLayout.ts | 2 +- .../react-stately/src/layout/ListLayout.ts | 16 +-- .../src/layout/WaterfallLayout.ts | 2 +- .../test/virtualizer/ListLayout.test.ts | 120 ++++++++++++++++++ 5 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 packages/react-stately/test/virtualizer/ListLayout.test.ts diff --git a/packages/@react-spectrum/ai/src/ListLayout.ts b/packages/@react-spectrum/ai/src/ListLayout.ts index f0dbca74df4..29b9fc9df77 100644 --- a/packages/@react-spectrum/ai/src/ListLayout.ts +++ b/packages/@react-spectrum/ai/src/ListLayout.ts @@ -584,7 +584,7 @@ export class ListLayout } let contentHeight = Math.max(contentLength, this.virtualizer!.size.height); - this.contentSize = new Size(this.virtualizer!.size.width, contentHeight); + this.contentSize = new Size(Math.floor(this.virtualizer!.size.width), contentHeight); // Iterate last → first so the last item in the collection (newest) is placed at the visual // bottom and written to nodes[0] (first in DOM) for screen-reader accessibility. diff --git a/packages/react-stately/src/layout/GridLayout.ts b/packages/react-stately/src/layout/GridLayout.ts index 160040f13ff..f0c17da4b61 100644 --- a/packages/react-stately/src/layout/GridLayout.ts +++ b/packages/react-stately/src/layout/GridLayout.ts @@ -277,7 +277,7 @@ export class GridLayout } this.layoutInfos = newLayoutInfos; - this.contentSize = new Size(this.virtualizer!.size.width, y); + this.contentSize = new Size(Math.floor(this.virtualizer!.size.width), y); } getLayoutInfo(key: Key): LayoutInfo | null { diff --git a/packages/react-stately/src/layout/ListLayout.ts b/packages/react-stately/src/layout/ListLayout.ts index 2edd1fbf200..4ff269848db 100644 --- a/packages/react-stately/src/layout/ListLayout.ts +++ b/packages/react-stately/src/layout/ListLayout.ts @@ -444,8 +444,8 @@ export class ListLayout offset += isEmptyOrLoading ? 0 : this.padding; this.contentSize = this.orientation === 'horizontal' - ? new Size(offset, this.virtualizer!.size.height) - : new Size(this.virtualizer!.size.width, offset); + ? new Size(offset, Math.floor(this.virtualizer!.size.height)) + : new Size(Math.floor(this.virtualizer!.size.width), offset); return nodes; } @@ -519,8 +519,8 @@ export class ListLayout protected buildSection(node: Node, x: number, y: number): LayoutNode { let collection = this.virtualizer!.collection; - let width = this.virtualizer!.size.width - this.padding - x; - let height = this.virtualizer!.size.height - this.padding - y; + let width = Math.floor(this.virtualizer!.size.width - this.padding - x); + let height = Math.floor(this.virtualizer!.size.height - this.padding - y); let rect = this.orientation === 'horizontal' ? new Rect(x, y, 0, height) : new Rect(x, y, width, 0); let layoutInfo = new LayoutInfo(node.type, node.key, rect); @@ -576,10 +576,10 @@ export class ListLayout protected buildSectionHeader(node: Node, x: number, y: number): LayoutNode { let widthProperty = this.orientation === 'horizontal' ? 'height' : 'width'; let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; - let width = + let width = Math.floor( this.virtualizer!.size[widthProperty] - this.padding - - (this.orientation === 'horizontal' ? y : x); + (this.orientation === 'horizontal' ? y : x)); let rectHeight = this.headingSize; let isEstimated = false; @@ -626,10 +626,10 @@ export class ListLayout let widthProperty = this.orientation === 'horizontal' ? 'height' : 'width'; let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; - let width = + let width = Math.floor( this.virtualizer!.size[widthProperty] - this.padding - - (this.orientation === 'horizontal' ? y : x); + (this.orientation === 'horizontal' ? y : x)); let rectHeight = this.rowSize; let isEstimated = false; diff --git a/packages/react-stately/src/layout/WaterfallLayout.ts b/packages/react-stately/src/layout/WaterfallLayout.ts index cfe38a56d2b..d14dd110918 100644 --- a/packages/react-stately/src/layout/WaterfallLayout.ts +++ b/packages/react-stately/src/layout/WaterfallLayout.ts @@ -247,7 +247,7 @@ export class WaterfallLayout< maxHeight = layoutInfo.rect.maxY; } - this.contentSize = new Size(this.virtualizer!.size.width, maxHeight); + this.contentSize = new Size(Math.floor(this.virtualizer!.size.width), maxHeight); this.layoutInfos = newLayoutInfos; this.numColumns = numColumns; } diff --git a/packages/react-stately/test/virtualizer/ListLayout.test.ts b/packages/react-stately/test/virtualizer/ListLayout.test.ts new file mode 100644 index 00000000000..b7d4f5968a1 --- /dev/null +++ b/packages/react-stately/test/virtualizer/ListLayout.test.ts @@ -0,0 +1,120 @@ +/* + * 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 {Key, Node} from '@react-types/shared'; +import {ListLayout, ListLayoutOptions} from '../../src/layout/ListLayout'; +import {Rect} from '../../src/virtualizer/Rect'; +import {Size} from '../../src/virtualizer/Size'; + +/** + * Creates a minimal mock virtualizer and collection, then calls layout.update() + * so the layout has valid internal state for contentSize verification. + */ +function setupListLayout( + options: ListLayoutOptions = {}, + itemCount = 4, + viewportWidth = 250.5, + viewportHeight = 600 +) { + let layout = new ListLayout, ListLayoutOptions>(); + + // Build a minimal collection + let items: Node[] = []; + for (let i = 0; i < itemCount; i++) { + items.push({ + type: 'item', + key: `item-${i}`, + value: null, + level: 0, + hasChildNodes: false, + rendered: null, + textValue: `Item ${i}`, + 'aria-label': undefined, + index: i, + parentKey: null, + prevKey: i > 0 ? `item-${i - 1}` : null, + nextKey: i < itemCount - 1 ? `item-${i + 1}` : null, + childNodes: [], + props: {} + } as unknown as Node); + } + + let collection = { + size: items.length, + getItem(key: Key) { + return items.find(i => i.key === key) ?? null; + }, + getFirstKey() { + return items[0]?.key ?? null; + }, + getLastKey() { + return items[items.length - 1]?.key ?? null; + }, + getKeyBefore(key: Key) { + let idx = items.findIndex(i => i.key === key); + return idx > 0 ? items[idx - 1].key : null; + }, + getKeyAfter(key: Key) { + let idx = items.findIndex(i => i.key === key); + return idx < items.length - 1 ? items[idx + 1].key : null; + }, + [Symbol.iterator]() { + return items[Symbol.iterator](); + } + }; + + // Attach a mock virtualizer + (layout as any).virtualizer = { + collection, + visibleRect: new Rect(0, 0, viewportWidth, viewportHeight), + size: new Size(viewportWidth, viewportHeight), + isPersistedKey: () => false + }; + + // Run layout update + layout.update({ + layoutOptions: { + rowSize: 40, + ...options + }, + sizeChanged: true, + offsetChanged: false, + layoutOptionsChanged: true + }); + + return layout; +} + +describe('ListLayout', () => { + it('floors the contentSize width when the viewport width is fractional', () => { + let layout = setupListLayout(); + let contentSize = layout.getContentSize(); + // Viewport width 250.5 should be rounded down to 250 so no content + // overflows the container, which would produce a horizontal scrollbar. + expect(contentSize.width).toBe(250); + expect(Number.isInteger(contentSize.width)).toBe(true); + }); + + it('does not change contentSize for integer viewport widths', () => { + let layout = setupListLayout({}, 4, 300); + let contentSize = layout.getContentSize(); + expect(contentSize.width).toBe(300); + }); + + it('floors section and item rect widths to avoid fractional overflow', () => { + let layout = setupListLayout(); + let itemInfo = layout.getLayoutInfo('item-0'); + expect(itemInfo).not.toBeNull(); + expect(itemInfo!.rect.width).toBeLessThanOrEqual(250); + expect(Number.isInteger(itemInfo!.rect.width)).toBe(true); + }); +}); \ No newline at end of file