From bdc5605d0590e05cd4f7aadd7d06b45342910b1e Mon Sep 17 00:00:00 2001 From: John Costa Date: Sat, 21 Mar 2026 20:31:20 -0700 Subject: [PATCH 1/5] fix(combobox): re-open menu when async controlled items arrive after empty response When using useComboBox with useAsyncList, if a query returns zero results the menu closes. A subsequent query that returns results fails to re-open the menu because the inputValue tracking has already been updated by the time the async items arrive. Track when the menu was auto-closed due to an empty controlled collection and re-open it when items become non-empty while the input is still focused. Reset the flag on user-initiated closes (blur, Escape, commit) so those remain permanent. Fixes #9820 --- .../src/combobox/useComboBoxState.ts | 18 +++++++ .../test/combobox/useComboBoxState.test.js | 52 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/packages/react-stately/src/combobox/useComboBoxState.ts b/packages/react-stately/src/combobox/useComboBoxState.ts index 02f6bd0116c..494458e943c 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 {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); + }); + + it('should still close the menu when uncontrolled items are empty', function () { + let onOpenChange = jest.fn(); + let {contains} = {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); + }); + }); }); From 488108e3e5b33f4ff22c0b827103554c4c4f46f1 Mon Sep 17 00:00:00 2001 From: John Costa Date: Sat, 21 Mar 2026 20:37:38 -0700 Subject: [PATCH 2/5] address review: reset flag on revert, add Escape key test - Reset closedDueToEmptyControlled in revert() so Escape prevents re-opening when async items arrive after user dismissal - Add test for Escape key scenario - Add onOpenChange assertion for trigger reason on re-open - Fix minor style: use direct variable instead of destructured object literal --- .../src/combobox/useComboBoxState.ts | 1 + .../test/combobox/useComboBoxState.test.js | 30 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/react-stately/src/combobox/useComboBoxState.ts b/packages/react-stately/src/combobox/useComboBoxState.ts index 494458e943c..8c8ce560b1e 100644 --- a/packages/react-stately/src/combobox/useComboBoxState.ts +++ b/packages/react-stately/src/combobox/useComboBoxState.ts @@ -435,6 +435,7 @@ export function useComboBoxState {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} = {contains: (a, b) => a.toLowerCase().includes(b.toLowerCase())}; + let contains = (a, b) => a.toLowerCase().includes(b.toLowerCase()); let initialProps = { defaultItems: [{id: 1, name: 'Luke Skywalker'}], children: (props) => {props.name}, From f9c8d833b7479bafadb04bb3009c92c7dcbbde12 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 24 Mar 2026 14:00:14 +1100 Subject: [PATCH 3/5] add component level test --- .../test/ComboBox.test.js | 132 +++++++++++++++++- 1 file changed, 130 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index af75cba5644..2fb9d9eb224 100644 --- a/packages/react-aria-components/test/ComboBox.test.js +++ b/packages/react-aria-components/test/ComboBox.test.js @@ -857,11 +857,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); @@ -949,4 +949,132 @@ describe('ComboBox', () => { rerender(); expect(input.closest('.react-aria-ComboBox')).toHaveAttribute('data-readonly'); }); + + 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 still close the menu when uncontrolled items are empty', async () => { + let onOpenChange = jest.fn(); + let onInputChange = jest.fn().mockReturnValueOnce(true).mockReturnValue(false); + + 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(); + }); }); From ac37b23bba9b9f8c1add00ea09ce4a5f52bcf343 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 24 Mar 2026 14:48:39 +1100 Subject: [PATCH 4/5] add a test actually using useAsyncList --- .../test/ComboBox.test.js | 70 ++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index 2fb9d9eb224..3a67cffd303 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/useVirtualizerState'; 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'; @@ -996,9 +997,76 @@ describe('ComboBox', () => { 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 onInputChange = jest.fn().mockReturnValueOnce(true).mockReturnValue(false); let items = [{id: 1, name: 'Luke Skywalker'}]; function ControlledComboBox() { From 95917f7a396709d4a93dfabd50a4413ee6a2be23 Mon Sep 17 00:00:00 2001 From: John Costa Date: Sun, 12 Apr 2026 19:29:42 -0700 Subject: [PATCH 5/5] fix(combobox): support re-open for Collection pattern async loading Remove the `props.items != null` guard so `closedDueToEmpty` is set unconditionally when the menu auto-closes due to empty collection. This makes the re-open logic work for both the `items` prop pattern and the Collection pattern (where items are on ListBox, not ComboBox). Rename `closedDueToEmptyControlled` to `closedDueToEmpty` to reflect the broader scope. Add component-level test for the Collection pattern. --- .../test/ComboBox.test.js | 68 +++++++++++++++++++ .../src/combobox/useComboBoxState.ts | 18 ++--- 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index 3a67cffd303..4cdc93813ff 100644 --- a/packages/react-aria-components/test/ComboBox.test.js +++ b/packages/react-aria-components/test/ComboBox.test.js @@ -1065,6 +1065,74 @@ describe('ComboBox', () => { ).toBeInTheDocument(); }); + it('should re-open the menu when using Collection pattern (items on ListBox, not ComboBox)', async () => { + const ASYNC_DELAY_MS = 50; + + function itemsForFilterText(filterText) { + if (filterText === 'luka') { + return []; + } + return [{id: 1, name: 'Luke Skywalker'}]; + } + + function CollectionComboBox() { + 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(); diff --git a/packages/react-stately/src/combobox/useComboBoxState.ts b/packages/react-stately/src/combobox/useComboBoxState.ts index 8c8ce560b1e..1dfc619b4c4 100644 --- a/packages/react-stately/src/combobox/useComboBoxState.ts +++ b/packages/react-stately/src/combobox/useComboBoxState.ts @@ -167,7 +167,7 @@ export function useComboBoxState; @@ -360,22 +360,22 @@ export function useComboBoxState { - closedDueToEmptyControlled.current = false; + closedDueToEmpty.current = false; if (allowsCustomValue) { const itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : ''; (inputValue === itemText) ? commitSelection() : commitCustomValue();