Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/@react-spectrum/ai/src/ListLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,7 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions>
}

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.
Expand Down
2 changes: 1 addition & 1 deletion packages/react-stately/src/layout/GridLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ export class GridLayout<T, O extends GridLayoutOptions = GridLayoutOptions>
}

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 {
Expand Down
16 changes: 8 additions & 8 deletions packages/react-stately/src/layout/ListLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,8 +444,8 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions>
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;
}
Expand Down Expand Up @@ -519,8 +519,8 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions>

protected buildSection(node: Node<T>, 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);
Expand Down Expand Up @@ -576,10 +576,10 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions>
protected buildSectionHeader(node: Node<T>, 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;

Expand Down Expand Up @@ -626,10 +626,10 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions>
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;

Expand Down
2 changes: 1 addition & 1 deletion packages/react-stately/src/layout/WaterfallLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
120 changes: 120 additions & 0 deletions packages/react-stately/test/virtualizer/ListLayout.test.ts
Original file line number Diff line number Diff line change
@@ -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<Node<unknown>, ListLayoutOptions>();

// Build a minimal collection
let items: Node<unknown>[] = [];
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<unknown>);
}

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);
});
});