From 84ded20e5cf18e2e904f513556f3fe6544c60f2e Mon Sep 17 00:00:00 2001 From: Vin Zhang Date: Fri, 5 Jun 2026 12:30:27 +0800 Subject: [PATCH 1/3] fix: keep virtualized async ListBox focus visible (#10129) --- .../react-aria-components/src/Collection.tsx | 2 + .../react-aria-components/src/GridList.tsx | 4 +- .../react-aria-components/src/ListBox.tsx | 4 +- packages/react-aria-components/src/Table.tsx | 4 +- .../react-aria-components/src/Virtualizer.tsx | 15 ++- .../stories/ListBox.stories.tsx | 80 +++++++++++ .../test/ListBox.browser.test.tsx | 127 +++++++++++++++++- packages/react-aria/src/grid/useGrid.ts | 4 + .../react-aria/src/gridlist/useGridList.ts | 4 + packages/react-aria/src/listbox/useListBox.ts | 2 + .../src/selection/useSelectableCollection.ts | 13 +- .../react-aria/src/virtualizer/ScrollView.tsx | 54 +++++--- .../react-stately/src/layout/ListLayout.ts | 6 +- .../test/virtualizer/ListLayout.test.ts | 102 ++++++++++++++ 14 files changed, 392 insertions(+), 29 deletions(-) create mode 100644 packages/react-stately/test/virtualizer/ListLayout.test.ts diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index ecc435879fe..189ea27bfb0 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -192,6 +192,8 @@ export interface CollectionRenderer { layoutDelegate?: LayoutDelegate; /** A delegate object that provides drop targets for pointer coordinates within the collection. */ dropTargetDelegate?: DropTargetDelegate; + /** Refreshes the virtualized collection's visible rect after programmatic scrolling. */ + refreshVisibleRect?: () => void; /** A component that renders the root collection items. */ CollectionRoot: React.ComponentType; /** A component that renders the child collection items. */ diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index fc46c414009..b5778cd5fe0 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -247,7 +247,8 @@ function GridListInner({props, collection, gridListRef: ref}: GridListInnerPr CollectionRoot, isVirtualized, layoutDelegate, - dropTargetDelegate: ctxDropTargetDelegate + dropTargetDelegate: ctxDropTargetDelegate, + refreshVisibleRect } = useContext(CollectionRendererContext); let gridlistState = useListState({ ...DOMCollectionProps, @@ -293,6 +294,7 @@ function GridListInner({props, collection, gridListRef: ref}: GridListInnerPr // Only tab navigation is supported in grid layout. keyboardNavigationBehavior: layout === 'grid' ? 'tab' : keyboardNavigationBehavior, isVirtualized, + UNSTABLE_virtualizerRefresh: refreshVisibleRect, shouldSelectOnPressUp: props.shouldSelectOnPressUp, disallowTypeAhead }, diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 7ba3542f5a0..8803c0620cb 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -250,6 +250,7 @@ function ListBoxInner({state: inputState, props, listBoxRef}: ListBoxInnerPro isVirtualized, layoutDelegate, dropTargetDelegate: ctxDropTargetDelegate, + refreshVisibleRect, CollectionRoot } = useContext(CollectionRendererContext); let keyboardDelegate = useMemo( @@ -285,7 +286,8 @@ function ListBoxInner({state: inputState, props, listBoxRef}: ListBoxInnerPro ...props, shouldSelectOnPressUp: isListDraggable || props.shouldSelectOnPressUp, keyboardDelegate, - isVirtualized + isVirtualized, + UNSTABLE_virtualizerRefresh: refreshVisibleRect }, state, listBoxRef diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 4b1b4092e47..1234017c931 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -721,6 +721,7 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl isVirtualized, layoutDelegate, dropTargetDelegate: ctxDropTargetDelegate, + refreshVisibleRect, CollectionRoot } = useContext(CollectionRendererContext); let {dragAndDropHooks} = props; @@ -728,7 +729,8 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl { ...DOMCollectionProps, layoutDelegate, - isVirtualized + isVirtualized, + UNSTABLE_virtualizerRefresh: refreshVisibleRect }, filteredState, ref diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index 893a2d4e9f6..d88369a2856 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -24,7 +24,7 @@ import { useVirtualizerState, VirtualizerState } from 'react-stately/useVirtualizerState'; -import React, {createContext, JSX, ReactNode, useContext, useMemo} from 'react'; +import React, {createContext, JSX, ReactNode, RefObject, useContext, useMemo, useRef} from 'react'; import {useScrollView} from 'react-aria/private/virtualizer/ScrollView'; import {VirtualizerItem} from 'react-aria/private/virtualizer/VirtualizerItem'; @@ -57,6 +57,7 @@ interface LayoutContextValue { const VirtualizerContext = createContext | null>(null); const LayoutContext = createContext(null); +const RefreshVisibleRectContext = createContext void) | null> | null>(null); /** * A Virtualizer renders a scrollable collection of data using customizable layouts. @@ -69,6 +70,7 @@ export function Virtualizer(props: VirtualizerProps): JSX.Element { () => (typeof layoutProp === 'function' ? new layoutProp() : layoutProp), [layoutProp] ); + let refreshVisibleRectRef = useRef<(() => void) | null>(null); let renderer: CollectionRenderer = useMemo( () => ({ isVirtualized: true, @@ -76,6 +78,7 @@ export function Virtualizer(props: VirtualizerProps): JSX.Element { dropTargetDelegate: layout.getDropTargetFromPoint ? (layout as DropTargetDelegate) : undefined, + refreshVisibleRect: () => refreshVisibleRectRef.current?.(), CollectionRoot, CollectionBranch }), @@ -84,7 +87,9 @@ export function Virtualizer(props: VirtualizerProps): JSX.Element { return ( - {children} + + {children} + ); } @@ -120,7 +125,7 @@ function CollectionRoot({ }, [layoutOptions, layoutOptions2]) }); - let {contentProps} = useScrollView( + let {contentProps, refreshVisibleRect} = useScrollView( { onVisibleRectChange: state.setVisibleRect, onSizeChange: state.setSize, @@ -131,6 +136,10 @@ function CollectionRoot({ }, scrollRef! ); + let refreshVisibleRectRef = useContext(RefreshVisibleRectContext); + if (refreshVisibleRectRef) { + refreshVisibleRectRef.current = refreshVisibleRect; + } return (
diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index 24e721fc529..0c0b4bc0bfc 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -920,6 +920,86 @@ export const AsyncListBoxVirtualized: StoryFn = args ); }; +interface AsyncVirtualizedEndKeyItem { + id: number; + name: string; +} + +export const AsyncListBoxVirtualizedEndKey: StoryFn = args => { + let list = useAsyncList({ + async load({cursor}) { + let page = cursor ? Number(cursor) : 0; + let pageSize = 25; + let pageCount = 12; + let start = page * pageSize; + + await new Promise(resolve => setTimeout(resolve, args.delay)); + + return { + items: Array.from({length: pageSize}, (_, index) => ({ + id: start + index, + name: `Item ${start + index}` + })), + cursor: page < pageCount - 1 ? String(page + 1) : undefined + }; + } + }); + + return ( +
+

+ Focus the listbox, press End, wait for more items to load, and repeat. The focused item should move to the last loaded item and scroll into view after each load. +

+ + renderEmptyState({isLoading: list.isLoading})}> + + {item => ( + + {item.name} + + )} + + + + +
+ ); +}; + +AsyncListBoxVirtualizedEndKey.args = { + delay: 500, + orientation: 'vertical' +}; + export const ListBoxScrollMargin: ListBoxStory = args => { let items: {id: number; name: string; description: string}[] = []; for (let i = 0; i < 100; i++) { diff --git a/packages/react-aria-components/test/ListBox.browser.test.tsx b/packages/react-aria-components/test/ListBox.browser.test.tsx index f8b99c311c9..3898fa38325 100644 --- a/packages/react-aria-components/test/ListBox.browser.test.tsx +++ b/packages/react-aria-components/test/ListBox.browser.test.tsx @@ -10,11 +10,16 @@ * governing permissions and limitations under the License. */ -import {expect, it} from 'vitest'; -import {ListBox, ListBoxItem} from '../src/ListBox'; +import {Collection} from 'react-aria/Collection'; +import {expect, it, vi} from 'vitest'; +import {ListBox, ListBoxItem, ListBoxLoadMoreItem} from '../src/ListBox'; +import {ListLayout} from 'react-stately/useVirtualizerState'; import React from 'react'; +import {useAsyncList} from 'react-stately/useAsyncList'; import {render} from 'vitest-browser-react'; import {User} from '@react-aria/test-utils'; +import {userEvent} from 'vitest/browser'; +import {Virtualizer} from '../src/Virtualizer'; function GridListBox() { return ( @@ -36,6 +41,89 @@ function GridListBox() { ); } +interface AsyncVirtualizedItem { + id: number; + name: string; +} + +let asyncVirtualizedItems: AsyncVirtualizedItem[] = Array.from({length: 300}, (_, index) => ({ + id: index, + name: `Item ${index}` +})); + +function AsyncVirtualizedListBox() { + let list = useAsyncList({ + async load({cursor}) { + let page = cursor ? Number(cursor) : 0; + let pageSize = 25; + let pageCount = 12; + let start = page * pageSize; + + await new Promise(resolve => setTimeout(resolve, 50)); + + return { + items: asyncVirtualizedItems.slice(start, start + pageSize), + cursor: page < pageCount - 1 ? String(page + 1) : undefined + }; + } + }); + + return ( + + + + {item => ( + + {item.name} + + )} + + + Loading... + + + + ); +} + +function expectFocusedOptionInView(listbox: HTMLElement, text?: string) { + let activeElement = document.activeElement as HTMLElement; + if (text != null) { + expect(activeElement.textContent).toBe(text); + } else { + expect(activeElement.textContent).toMatch(/^Item /); + } + + let listboxRect = listbox.getBoundingClientRect(); + let activeRect = activeElement.getBoundingClientRect(); + expect(activeRect.top).toBeGreaterThanOrEqual(listboxRect.top); + expect(activeRect.bottom).toBeLessThanOrEqual(listboxRect.bottom); +} + it.each` interactionType ${'mouse'} @@ -67,3 +155,38 @@ it.each` expect(document.activeElement).toBe(options[8]); } ); + +it('moves focus and scrolls to the last loaded item with End in a virtualized async listbox', async () => { + let {container} = await render(); + let listbox = container.querySelector('[role=listbox]') as HTMLElement; + + await vi.waitFor(() => { + expect(listbox.querySelector('[role=option]')?.textContent).toBe('Item 0'); + }); + listbox.focus(); + + for (let page = 1; page <= 6; page++) { + await userEvent.keyboard('{End}'); + await new Promise(resolve => setTimeout(resolve, 100)); + + let lastItem = `Item ${page * 25 - 1}`; + await vi.waitFor(() => expectFocusedOptionInView(listbox, lastItem)); + } +}); + +it('keeps the focused item visible while paging through a virtualized async listbox', async () => { + let {container} = await render(); + let listbox = container.querySelector('[role=listbox]') as HTMLElement; + + await vi.waitFor(() => { + expect(listbox.querySelector('[role=option]')?.textContent).toBe('Item 0'); + }); + listbox.focus(); + + for (let i = 0; i < 40; i++) { + await userEvent.keyboard('{PageDown}'); + await new Promise(resolve => setTimeout(resolve, 100)); + + await vi.waitFor(() => expectFocusedOptionInView(listbox)); + } +}); diff --git a/packages/react-aria/src/grid/useGrid.ts b/packages/react-aria/src/grid/useGrid.ts index b9897552a7f..0dbf0d64256 100644 --- a/packages/react-aria/src/grid/useGrid.ts +++ b/packages/react-aria/src/grid/useGrid.ts @@ -37,6 +37,8 @@ import {useSelectableCollection} from '../selection/useSelectableCollection'; export interface GridProps extends DOMProps, AriaLabelingProps { /** Whether the grid uses virtual scrolling. */ isVirtualized?: boolean; + /** @private Refreshes the virtualizer visible rect after programmatic scrolling. */ + UNSTABLE_virtualizerRefresh?: () => void; /** * Whether typeahead navigation is disabled. * @@ -105,6 +107,7 @@ export function useGrid( ): GridAria { let { isVirtualized, + UNSTABLE_virtualizerRefresh, disallowTypeAhead, keyboardDelegate, focusMode, @@ -155,6 +158,7 @@ export function useGrid( selectionManager: manager, keyboardDelegate: delegate, isVirtualized, + UNSTABLE_virtualizerRefresh, scrollRef, disallowTypeAhead, escapeKeyBehavior diff --git a/packages/react-aria/src/gridlist/useGridList.ts b/packages/react-aria/src/gridlist/useGridList.ts index 68a96c4158e..33a80124d3a 100644 --- a/packages/react-aria/src/gridlist/useGridList.ts +++ b/packages/react-aria/src/gridlist/useGridList.ts @@ -74,6 +74,8 @@ export interface AriaGridListProps extends GridListProps, DOMProps, AriaLa export interface AriaGridListOptions extends Omit, 'children'> { /** Whether the list uses virtual scrolling. */ isVirtualized?: boolean; + /** @private Refreshes the virtualizer visible rect after programmatic scrolling. */ + UNSTABLE_virtualizerRefresh?: () => void; /** * Whether typeahead navigation is disabled. * @@ -129,6 +131,7 @@ export function useGridList( ): GridListAria { let { isVirtualized, + UNSTABLE_virtualizerRefresh, keyboardDelegate, layoutDelegate, onAction, @@ -151,6 +154,7 @@ export function useGridList( keyboardDelegate, layoutDelegate, isVirtualized, + UNSTABLE_virtualizerRefresh, selectOnFocus: state.selectionManager.selectionBehavior === 'replace', shouldFocusWrap: props.shouldFocusWrap, linkBehavior, diff --git a/packages/react-aria/src/listbox/useListBox.ts b/packages/react-aria/src/listbox/useListBox.ts index 14185b14f5a..9d7798c2528 100644 --- a/packages/react-aria/src/listbox/useListBox.ts +++ b/packages/react-aria/src/listbox/useListBox.ts @@ -82,6 +82,8 @@ export interface ListBoxAria { export interface AriaListBoxOptions extends Omit, 'children'> { /** Whether the listbox uses virtual scrolling. */ isVirtualized?: boolean; + /** @private Refreshes the virtualizer visible rect after programmatic scrolling. */ + UNSTABLE_virtualizerRefresh?: () => void; /** * An optional keyboard delegate implementation for type to select, diff --git a/packages/react-aria/src/selection/useSelectableCollection.ts b/packages/react-aria/src/selection/useSelectableCollection.ts index 4ecad56edbf..8c1f2a92e0b 100644 --- a/packages/react-aria/src/selection/useSelectableCollection.ts +++ b/packages/react-aria/src/selection/useSelectableCollection.ts @@ -112,6 +112,8 @@ export interface AriaSelectableCollectionOptions { * Whether the collection items are contained in a virtual scroller. */ isVirtualized?: boolean; + /** @private Refreshes the virtualizer visible rect after programmatic scrolling. */ + UNSTABLE_virtualizerRefresh?: () => void; /** * The ref attached to the scrollable body. Used to provide automatic scrolling on item focus for * non-virtualized collections. If not provided, defaults to the collection ref. @@ -152,6 +154,8 @@ export function useSelectableCollection( disallowTypeAhead = false, shouldUseVirtualFocus, allowsTabNavigation = false, + isVirtualized, + UNSTABLE_virtualizerRefresh, // If no scrollRef is provided, assume the collection ref is the scrollable region scrollRef = ref, linkBehavior = 'action' @@ -581,12 +585,15 @@ export function useSelectableCollection( // Scroll the focused element into view when the focusedKey changes. let lastFocusedKey = useRef(manager.focusedKey); + let lastCollection = useRef(manager.collection); let raf = useRef(null); useEffect(() => { if ( manager.isFocused && manager.focusedKey != null && - (manager.focusedKey !== lastFocusedKey.current || didAutoFocusRef.current) && + (manager.focusedKey !== lastFocusedKey.current || + didAutoFocusRef.current || + (isVirtualized && manager.collection !== lastCollection.current)) && scrollRef.current && ref.current ) { @@ -610,6 +617,9 @@ export function useSelectableCollection( if (modality !== 'virtual') { scrollIntoViewport(element, {containingElement: ref.current}); } + if (isVirtualized) { + UNSTABLE_virtualizerRefresh?.(); + } } }); } @@ -627,6 +637,7 @@ export function useSelectableCollection( } lastFocusedKey.current = manager.focusedKey; + lastCollection.current = manager.collection; didAutoFocusRef.current = false; }); diff --git a/packages/react-aria/src/virtualizer/ScrollView.tsx b/packages/react-aria/src/virtualizer/ScrollView.tsx index db2a266b72c..816d30dc531 100644 --- a/packages/react-aria/src/virtualizer/ScrollView.tsx +++ b/packages/react-aria/src/virtualizer/ScrollView.tsx @@ -65,6 +65,7 @@ interface ScrollViewAria { isScrolling: boolean; scrollViewProps: HTMLAttributes; contentProps: HTMLAttributes; + refreshVisibleRect: () => void; } export function useScrollView( @@ -133,6 +134,33 @@ export function useScrollView( } }, [state, allowsWindowScrolling, onVisibleRectChange]); + let updateViewportOffset = useCallback(() => { + let boundingRect = ref.current!.getBoundingClientRect(); + let x = boundingRect.x < 0 ? -boundingRect.x : 0; + let y = boundingRect.y < 0 ? -boundingRect.y : 0; + state.viewportOffset = new Point(x, y); + }, [ref, state]); + + let updateScrollPosition = useCallback(() => { + state.scrollPosition = new Point( + Math.max(0, Math.min(getScrollLeft(ref.current!, direction), contentSize.width - state.size.width)), + Math.max(0, Math.min(ref.current!.scrollTop, contentSize.height - state.size.height)) + ); + }, [contentSize, direction, ref, state]); + + let refreshVisibleRect = useCallback(() => { + if (!ref.current) { + return; + } + + if (allowsWindowScrolling) { + updateViewportOffset(); + } + + updateScrollPosition(); + updateVisibleRect(); + }, [allowsWindowScrolling, ref, updateScrollPosition, updateViewportOffset, updateVisibleRect]); + let [isScrolling, setScrolling] = useState(false); let onScroll = useCallback( @@ -148,23 +176,18 @@ export function useScrollView( if (target !== ref.current) { // An ancestor element or the window was scrolled. Update the position of the scroll view relative to the viewport. - let boundingRect = ref.current!.getBoundingClientRect(); - let x = boundingRect.x < 0 ? -boundingRect.x : 0; - let y = boundingRect.y < 0 ? -boundingRect.y : 0; - if (x === state.viewportOffset.x && y === state.viewportOffset.y) { + let lastViewportOffset = state.viewportOffset; + updateViewportOffset(); + if ( + lastViewportOffset.x === state.viewportOffset.x && + lastViewportOffset.y === state.viewportOffset.y + ) { return; } - - state.viewportOffset = new Point(x, y); } else { // The scroll view itself was scrolled. Update the local scroll position. // Prevent rubber band scrolling from shaking when scrolling out of bounds - let scrollTop = target.scrollTop; - let scrollLeft = getScrollLeft(target, direction); - state.scrollPosition = new Point( - Math.max(0, Math.min(scrollLeft, contentSize.width - state.size.width)), - Math.max(0, Math.min(scrollTop, contentSize.height - state.size.height)) - ); + updateScrollPosition(); } flushSync(() => { @@ -208,9 +231,9 @@ export function useScrollView( [ onScrollProp, ref, - direction, state, - contentSize, + updateScrollPosition, + updateViewportOffset, updateVisibleRect, onScrollStart, onScrollEnd @@ -394,6 +417,7 @@ export function useScrollView( contentProps: { role: 'presentation', style: innerStyle - } + }, + refreshVisibleRect }; } diff --git a/packages/react-stately/src/layout/ListLayout.ts b/packages/react-stately/src/layout/ListLayout.ts index f200f12584d..c5d9454fac6 100644 --- a/packages/react-stately/src/layout/ListLayout.ts +++ b/packages/react-stately/src/layout/ListLayout.ts @@ -276,11 +276,7 @@ export class ListLayout // If the layout info wasn't found, it might be outside the bounds of the area that we've // computed layout for so far. This can happen when accessing a random key, e.g pressing Home/End. // Compute the full layout and try again. - if ( - !this.layoutNodes.has(key) && - this.requestedRect.area < this.contentSize.area && - this.lastCollection - ) { + if (!this.layoutNodes.has(key) && this.lastCollection?.getItem(key)) { this.requestedRect = new Rect(0, 0, Infinity, Infinity); this.rootNodes = this.buildCollection(); this.requestedRect = new Rect(0, 0, this.contentSize.width, this.contentSize.height); 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..b1d04ccd138 --- /dev/null +++ b/packages/react-stately/test/virtualizer/ListLayout.test.ts @@ -0,0 +1,102 @@ +/* + * 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'; + +function makeCollection(itemCount: number) { + let items: Node[] = Array.from({length: itemCount}, (_, index) => ({ + type: 'item', + key: index, + value: null, + level: 0, + hasChildNodes: false, + rendered: null, + textValue: `Item ${index}`, + 'aria-label': undefined, + index, + parentKey: null, + prevKey: index > 0 ? index - 1 : null, + nextKey: index < itemCount - 1 ? index + 1 : null, + childNodes: [], + props: {} + } as unknown as Node)); + + return { + size: items.length, + getItem(key: Key) { + return items.find(item => item.key === key) ?? null; + }, + getFirstKey() { + return items[0]?.key ?? null; + }, + getLastKey() { + return items.at(-1)?.key ?? null; + }, + getKeyBefore(key: Key) { + let index = items.findIndex(item => item.key === key); + return index > 0 ? items[index - 1].key : null; + }, + getKeyAfter(key: Key) { + let index = items.findIndex(item => item.key === key); + return index >= 0 && index < items.length - 1 ? items[index + 1].key : null; + }, + getKeys() { + return items.map(item => item.key); + }, + [Symbol.iterator]() { + return items[Symbol.iterator](); + } + }; +} + +describe('ListLayout', () => { + it('renders a persisted key that is newly added outside the current requested rect', () => { + let layout = new ListLayout, ListLayoutOptions>(); + let virtualizer = { + collection: makeCollection(75), + visibleRect: new Rect(0, 3354, 160, 400), + size: new Size(160, 400), + persistedKeys: new Set([74]), + isPersistedKey(key: Key) { + return this.persistedKeys.has(key); + } + }; + + (layout as any).virtualizer = virtualizer; + layout.update({ + layoutOptions: { + rowHeight: 50, + padding: 4, + loaderHeight: 30 + }, + sizeChanged: true, + offsetChanged: false, + layoutOptionsChanged: true + }); + + expect(layout.getVisibleLayoutInfos(virtualizer.visibleRect).map(info => info.key)).toContain(74); + + // Simulate a previous random access such as End, which can expand the requested rect + // to the full content size before additional async items are appended. + (layout as any).requestedRect = new Rect(0, 0, Infinity, Infinity); + + virtualizer.collection = makeCollection(100); + virtualizer.persistedKeys = new Set([99]); + (layout as any).lastCollection = virtualizer.collection; + + let keys = layout.getVisibleLayoutInfos(virtualizer.visibleRect).map(info => info.key); + expect(keys).toContain(99); + }); +}); From c053aad251da8c6eb2817c5078b83d74eba759f8 Mon Sep 17 00:00:00 2001 From: Vin Zhang Date: Sun, 7 Jun 2026 13:38:34 +0800 Subject: [PATCH 2/3] test: fix ListBox browser test lint --- .../react-aria-components/test/ListBox.browser.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-aria-components/test/ListBox.browser.test.tsx b/packages/react-aria-components/test/ListBox.browser.test.tsx index 3898fa38325..cd2fccb3fd8 100644 --- a/packages/react-aria-components/test/ListBox.browser.test.tsx +++ b/packages/react-aria-components/test/ListBox.browser.test.tsx @@ -14,9 +14,9 @@ import {Collection} from 'react-aria/Collection'; import {expect, it, vi} from 'vitest'; import {ListBox, ListBoxItem, ListBoxLoadMoreItem} from '../src/ListBox'; import {ListLayout} from 'react-stately/useVirtualizerState'; -import React from 'react'; -import {useAsyncList} from 'react-stately/useAsyncList'; +import React, {act} from 'react'; import {render} from 'vitest-browser-react'; +import {useAsyncList} from 'react-stately/useAsyncList'; import {User} from '@react-aria/test-utils'; import {userEvent} from 'vitest/browser'; import {Virtualizer} from '../src/Virtualizer'; @@ -163,7 +163,7 @@ it('moves focus and scrolls to the last loaded item with End in a virtualized as await vi.waitFor(() => { expect(listbox.querySelector('[role=option]')?.textContent).toBe('Item 0'); }); - listbox.focus(); + act(() => listbox.focus()); for (let page = 1; page <= 6; page++) { await userEvent.keyboard('{End}'); @@ -181,7 +181,7 @@ it('keeps the focused item visible while paging through a virtualized async list await vi.waitFor(() => { expect(listbox.querySelector('[role=option]')?.textContent).toBe('Item 0'); }); - listbox.focus(); + act(() => listbox.focus()); for (let i = 0; i < 40; i++) { await userEvent.keyboard('{PageDown}'); From f9afc0602b26a7132508b0d3c63998002dbb9a5f Mon Sep 17 00:00:00 2001 From: Vin Zhang Date: Sun, 7 Jun 2026 14:34:02 +0800 Subject: [PATCH 3/3] chore: format virtualized ListBox fix --- .../stories/ListBox.stories.tsx | 3 +- .../test/ListBox.browser.test.tsx | 4 +- packages/react-aria/src/grid/useGrid.ts | 2 +- .../react-aria/src/gridlist/useGridList.ts | 2 +- packages/react-aria/src/listbox/useListBox.ts | 2 +- .../src/selection/useSelectableCollection.ts | 6 ++- .../react-aria/src/virtualizer/ScrollView.tsx | 5 ++- .../test/virtualizer/ListLayout.test.ts | 40 +++++++++++-------- 8 files changed, 40 insertions(+), 24 deletions(-) diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index 0c0b4bc0bfc..e33db5f3aac 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -948,7 +948,8 @@ export const AsyncListBoxVirtualizedEndKey: StoryFn = return (

- Focus the listbox, press End, wait for more items to load, and repeat. The focused item should move to the last loaded item and scroll into view after each load. + Focus the listbox, press End, wait for more items to load, and repeat. The focused item + should move to the last loaded item and scroll into view after each load.

)} - + Loading... diff --git a/packages/react-aria/src/grid/useGrid.ts b/packages/react-aria/src/grid/useGrid.ts index 0dbf0d64256..8b060f35384 100644 --- a/packages/react-aria/src/grid/useGrid.ts +++ b/packages/react-aria/src/grid/useGrid.ts @@ -37,7 +37,7 @@ import {useSelectableCollection} from '../selection/useSelectableCollection'; export interface GridProps extends DOMProps, AriaLabelingProps { /** Whether the grid uses virtual scrolling. */ isVirtualized?: boolean; - /** @private Refreshes the virtualizer visible rect after programmatic scrolling. */ + /** @private Refreshes The virtualizer visible rect after programmatic scrolling. */ UNSTABLE_virtualizerRefresh?: () => void; /** * Whether typeahead navigation is disabled. diff --git a/packages/react-aria/src/gridlist/useGridList.ts b/packages/react-aria/src/gridlist/useGridList.ts index 33a80124d3a..6fbcf6cf984 100644 --- a/packages/react-aria/src/gridlist/useGridList.ts +++ b/packages/react-aria/src/gridlist/useGridList.ts @@ -74,7 +74,7 @@ export interface AriaGridListProps extends GridListProps, DOMProps, AriaLa export interface AriaGridListOptions extends Omit, 'children'> { /** Whether the list uses virtual scrolling. */ isVirtualized?: boolean; - /** @private Refreshes the virtualizer visible rect after programmatic scrolling. */ + /** @private Refreshes The virtualizer visible rect after programmatic scrolling. */ UNSTABLE_virtualizerRefresh?: () => void; /** * Whether typeahead navigation is disabled. diff --git a/packages/react-aria/src/listbox/useListBox.ts b/packages/react-aria/src/listbox/useListBox.ts index 9d7798c2528..bcf6bbd63fd 100644 --- a/packages/react-aria/src/listbox/useListBox.ts +++ b/packages/react-aria/src/listbox/useListBox.ts @@ -82,7 +82,7 @@ export interface ListBoxAria { export interface AriaListBoxOptions extends Omit, 'children'> { /** Whether the listbox uses virtual scrolling. */ isVirtualized?: boolean; - /** @private Refreshes the virtualizer visible rect after programmatic scrolling. */ + /** @private Refreshes The virtualizer visible rect after programmatic scrolling. */ UNSTABLE_virtualizerRefresh?: () => void; /** diff --git a/packages/react-aria/src/selection/useSelectableCollection.ts b/packages/react-aria/src/selection/useSelectableCollection.ts index 8c1f2a92e0b..1082ab21f34 100644 --- a/packages/react-aria/src/selection/useSelectableCollection.ts +++ b/packages/react-aria/src/selection/useSelectableCollection.ts @@ -112,7 +112,11 @@ export interface AriaSelectableCollectionOptions { * Whether the collection items are contained in a virtual scroller. */ isVirtualized?: boolean; - /** @private Refreshes the virtualizer visible rect after programmatic scrolling. */ + /** + * Refreshes the virtualizer visible rect after programmatic scrolling. + * + * @private + */ UNSTABLE_virtualizerRefresh?: () => void; /** * The ref attached to the scrollable body. Used to provide automatic scrolling on item focus for diff --git a/packages/react-aria/src/virtualizer/ScrollView.tsx b/packages/react-aria/src/virtualizer/ScrollView.tsx index 816d30dc531..432d4a0f090 100644 --- a/packages/react-aria/src/virtualizer/ScrollView.tsx +++ b/packages/react-aria/src/virtualizer/ScrollView.tsx @@ -143,7 +143,10 @@ export function useScrollView( let updateScrollPosition = useCallback(() => { state.scrollPosition = new Point( - Math.max(0, Math.min(getScrollLeft(ref.current!, direction), contentSize.width - state.size.width)), + Math.max( + 0, + Math.min(getScrollLeft(ref.current!, direction), contentSize.width - state.size.width) + ), Math.max(0, Math.min(ref.current!.scrollTop, contentSize.height - state.size.height)) ); }, [contentSize, direction, ref, state]); diff --git a/packages/react-stately/test/virtualizer/ListLayout.test.ts b/packages/react-stately/test/virtualizer/ListLayout.test.ts index b1d04ccd138..53185b4b7c2 100644 --- a/packages/react-stately/test/virtualizer/ListLayout.test.ts +++ b/packages/react-stately/test/virtualizer/ListLayout.test.ts @@ -16,22 +16,26 @@ import {Rect} from '../../src/virtualizer/Rect'; import {Size} from '../../src/virtualizer/Size'; function makeCollection(itemCount: number) { - let items: Node[] = Array.from({length: itemCount}, (_, index) => ({ - type: 'item', - key: index, - value: null, - level: 0, - hasChildNodes: false, - rendered: null, - textValue: `Item ${index}`, - 'aria-label': undefined, - index, - parentKey: null, - prevKey: index > 0 ? index - 1 : null, - nextKey: index < itemCount - 1 ? index + 1 : null, - childNodes: [], - props: {} - } as unknown as Node)); + let items: Node[] = Array.from( + {length: itemCount}, + (_, index) => + ({ + type: 'item', + key: index, + value: null, + level: 0, + hasChildNodes: false, + rendered: null, + textValue: `Item ${index}`, + 'aria-label': undefined, + index, + parentKey: null, + prevKey: index > 0 ? index - 1 : null, + nextKey: index < itemCount - 1 ? index + 1 : null, + childNodes: [], + props: {} + }) as unknown as Node + ); return { size: items.length, @@ -86,7 +90,9 @@ describe('ListLayout', () => { layoutOptionsChanged: true }); - expect(layout.getVisibleLayoutInfos(virtualizer.visibleRect).map(info => info.key)).toContain(74); + expect(layout.getVisibleLayoutInfos(virtualizer.visibleRect).map(info => info.key)).toContain( + 74 + ); // Simulate a previous random access such as End, which can expand the requested rect // to the full content size before additional async items are appended.