diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js
index 93145bacfed..92dc68e5e07 100644
--- a/packages/react-aria-components/test/ComboBox.test.js
+++ b/packages/react-aria-components/test/ComboBox.test.js
@@ -24,6 +24,7 @@ import {ListLayout} from 'react-stately/private/layout/ListLayout';
import {Popover} from '../src/Popover';
import React, {useState} from 'react';
import {Text} from '../src/Text';
+import {useAsyncList} from 'react-stately/useAsyncList';
import {User} from '@react-aria/test-utils';
import userEvent from '@testing-library/user-event';
import {Virtualizer} from '../src/Virtualizer';
@@ -857,11 +858,11 @@ describe('ComboBox', () => {
act(() => {getByTestId('form').checkValidity();});
expect(combobox).toHaveAttribute('aria-describedby');
expect(container.querySelector('.react-aria-ComboBox')).toHaveAttribute('data-invalid');
-
+
await comboboxTester.open();
let options = comboboxTester.options();
await user.click(options[0]);
-
+
act(() => combobox.blur());
expect(combobox).not.toHaveAttribute('required');
expect(combobox.validity.valid).toBe(true);
@@ -937,4 +938,199 @@ describe('ComboBox', () => {
expect(comboboxTester.combobox).toHaveFocus();
expect(onOpenChange).toHaveBeenCalledTimes(1);
});
+
+ it('should re-open the menu when controlled items go from empty to non-empty controlled items', async () => {
+ let onOpenChange = jest.fn();
+ let onInputChange = jest.fn().mockReturnValueOnce(true).mockReturnValue(false);
+
+ function ControlledComboBox() {
+ let [items, setItems] = useState([{id: 1, name: 'Luke Skywalker'}]);
+ return (
+ {
+ if (onInputChange()) {
+ setItems([]);
+ } else {
+ setItems([{id: 1, name: 'Luke Skywalker'}]);
+ }
+ }}
+ onOpenChange={onOpenChange}>
+
+
+
+
+
+ {(item) => {
+ return {item.name};
+ }}
+
+
+
+ );
+ }
+
+ let {container, queryByRole} = render();
+ let comboboxTester = testUtilUser.createTester('ComboBox', {root: container});
+ await user.tab();
+ await user.keyboard('{ArrowDown}');
+ expect(onOpenChange).toHaveBeenCalledTimes(1);
+ expect(comboboxTester.listbox).toBeVisible();
+ onOpenChange.mockClear();
+
+ await user.keyboard('L');
+ expect(queryByRole('listbox')).toBeNull();
+
+ await user.keyboard('{Backspace}');
+ expect(comboboxTester.listbox).toBeVisible();
+ });
+
+ it('should re-open the menu with useAsyncList after an empty async result then backspace', async () => {
+ const ASYNC_DELAY_MS = 50;
+
+ function itemsForFilterText(filterText) {
+ if (filterText === 'luka') {
+ return [];
+ }
+ return [{id: 1, name: 'Luke Skywalker'}];
+ }
+
+ function AsyncComboBox() {
+ let list = useAsyncList({
+ getKey: (item) => item.id,
+ async load({filterText}) {
+ let rows = itemsForFilterText(filterText);
+ await new Promise((resolve) => setTimeout(resolve, ASYNC_DELAY_MS));
+ return {items: rows};
+ }
+ });
+
+ return (
+
+
+
+
+
+
+ {(item) => {item.name}}
+
+
+
+ );
+ }
+
+ let {container, queryByRole} = render();
+ let comboboxTester = testUtilUser.createTester('ComboBox', {root: container});
+
+ await act(async () => {
+ jest.runAllTimers();
+ });
+
+ await user.tab();
+ await user.keyboard('{ArrowDown}');
+ await act(async () => {
+ jest.runAllTimers();
+ });
+ expect(comboboxTester.listbox).toBeVisible();
+ expect(
+ within(comboboxTester.listbox).getByRole('option', {name: 'Luke Skywalker'})
+ ).toBeInTheDocument();
+
+ await user.keyboard('luka');
+ await act(async () => {
+ jest.runAllTimers();
+ });
+ expect(queryByRole('listbox')).toBeNull();
+
+ await user.keyboard('{Backspace}');
+ expect(queryByRole('listbox')).toBeNull();
+ await act(async () => {
+ jest.runAllTimers();
+ });
+ expect(comboboxTester.listbox).toBeVisible();
+ expect(
+ within(comboboxTester.listbox).getByRole('option', {name: 'Luke Skywalker'})
+ ).toBeInTheDocument();
+ });
+
+ it('should still close the menu when uncontrolled items are empty', async () => {
+ let onOpenChange = jest.fn();
+
+ let items = [{id: 1, name: 'Luke Skywalker'}];
+ function ControlledComboBox() {
+ return (
+
+
+
+
+
+
+ {(item) => {
+ return {item.name};
+ }}
+
+
+
+ );
+ }
+
+ let {container, queryByRole} = render();
+ let comboboxTester = testUtilUser.createTester('ComboBox', {root: container});
+ await user.tab();
+ await user.keyboard('{ArrowDown}');
+ expect(onOpenChange).toHaveBeenCalledTimes(1);
+ expect(comboboxTester.listbox).toBeVisible();
+ onOpenChange.mockClear();
+
+ await user.keyboard('Z');
+ expect(queryByRole('listbox')).toBeNull();
+ });
+
+ it('should not re-open after user dismisses with Escape (revert) controlled items', async () => {
+ let onOpenChange = jest.fn();
+ let onInputChange = jest.fn().mockReturnValueOnce(true).mockReturnValue(false);
+
+ function ControlledComboBox() {
+ let [items, setItems] = useState([{id: 1, name: 'Luke Skywalker'}]);
+ return (
+ {
+ if (onInputChange()) {
+ setItems([]);
+ } else {
+ setItems([{id: 1, name: 'Luke Skywalker'}]);
+ }
+ }}
+ onOpenChange={onOpenChange}>
+
+
+
+
+
+ {(item) => {
+ return {item.name};
+ }}
+
+
+
+ );
+ }
+
+ let {container, queryByRole} = render();
+ let comboboxTester = testUtilUser.createTester('ComboBox', {root: container});
+ await user.tab();
+ await user.keyboard('{ArrowDown}');
+ expect(onOpenChange).toHaveBeenCalledTimes(1);
+ expect(comboboxTester.listbox).toBeVisible();
+ onOpenChange.mockClear();
+
+ await user.keyboard('L');
+ expect(queryByRole('listbox')).toBeNull();
+
+ await user.keyboard('{Escape}');
+ expect(queryByRole('listbox')).toBeNull();
+ });
});
diff --git a/packages/react-stately/src/combobox/useComboBoxState.ts b/packages/react-stately/src/combobox/useComboBoxState.ts
index 02f6bd0116c..8c8ce560b1e 100644
--- a/packages/react-stately/src/combobox/useComboBoxState.ts
+++ b/packages/react-stately/src/combobox/useComboBoxState.ts
@@ -167,6 +167,7 @@ export function useComboBoxState;
@@ -359,9 +360,25 @@ export function useComboBoxState {
+ closedDueToEmptyControlled.current = false;
if (allowsCustomValue) {
const itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : '';
(inputValue === itemText) ? commitSelection() : commitCustomValue();
diff --git a/packages/react-stately/test/combobox/useComboBoxState.test.js b/packages/react-stately/test/combobox/useComboBoxState.test.js
index 0f6894144a9..07906d256ec 100644
--- a/packages/react-stately/test/combobox/useComboBoxState.test.js
+++ b/packages/react-stately/test/combobox/useComboBoxState.test.js
@@ -305,4 +305,84 @@ describe('useComboBoxState tests', function () {
expect(result.current.collection.size).toEqual(2);
});
});
+
+ describe('controlled items (async loading)', function () {
+ it('should re-open the menu when controlled items go from empty to non-empty', function () {
+ let onOpenChange = jest.fn();
+ let initialProps = {
+ items: [{id: 1, name: 'Luke Skywalker'}],
+ children: (props) => - {props.name}
,
+ onOpenChange
+ };
+
+ let {result, rerender} = renderHook((props) => useComboBoxState(props), {initialProps});
+
+ // Focus and open the menu by setting input value
+ act(() => {result.current.setFocused(true);});
+ act(() => {result.current.open(null, 'input');});
+ expect(result.current.isOpen).toBe(true);
+
+ // Simulate async load returning empty results (e.g. user typed "luka")
+ rerender({...initialProps, items: []});
+ // Menu closes on empty collection
+ expect(result.current.isOpen).toBe(false);
+
+ // Simulate async load returning results again (e.g. user backspaced to "luk")
+ rerender({...initialProps, items: [{id: 1, name: 'Luke Skywalker'}]});
+ // Menu should re-open because items were controlled and the close was due to empty collection
+ expect(result.current.isOpen).toBe(true);
+ expect(result.current.collection.size).toEqual(1);
+ expect(onOpenChange).toHaveBeenLastCalledWith(true, 'input');
+ });
+
+ it('should not re-open after user dismisses with Escape (revert)', function () {
+ let onOpenChange = jest.fn();
+ let initialProps = {
+ items: [{id: 1, name: 'Luke Skywalker'}],
+ children: (props) => - {props.name}
,
+ onOpenChange
+ };
+
+ let {result, rerender} = renderHook((props) => useComboBoxState(props), {initialProps});
+
+ // Focus and open
+ act(() => {result.current.setFocused(true);});
+ act(() => {result.current.open(null, 'input');});
+ expect(result.current.isOpen).toBe(true);
+
+ // Async returns empty, menu auto-closes
+ rerender({...initialProps, items: []});
+ expect(result.current.isOpen).toBe(false);
+
+ // User presses Escape (revert) while menu is closed
+ act(() => {result.current.revert();});
+
+ // Async returns items — menu should NOT re-open because user explicitly dismissed
+ rerender({...initialProps, items: [{id: 1, name: 'Luke Skywalker'}]});
+ expect(result.current.isOpen).toBe(false);
+ });
+
+ it('should still close the menu when uncontrolled items are empty', function () {
+ let onOpenChange = jest.fn();
+ let contains = (a, b) => a.toLowerCase().includes(b.toLowerCase());
+ let initialProps = {
+ defaultItems: [{id: 1, name: 'Luke Skywalker'}],
+ children: (props) => - {props.name}
,
+ onOpenChange,
+ defaultFilter: contains
+ };
+
+ let {result} = renderHook((props) => useComboBoxState(props), {initialProps});
+
+ // Focus and open
+ act(() => {result.current.setFocused(true);});
+ act(() => {result.current.open(null, 'input');});
+ expect(result.current.isOpen).toBe(true);
+
+ // Type something that filters to zero results
+ act(() => {result.current.setInputValue('zzz');});
+ // Menu should close because items are uncontrolled and filtered to empty
+ expect(result.current.isOpen).toBe(false);
+ });
+ });
});