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