From 4bdf53f37d61c888b4b321b8e2b3acb14f1ed70f Mon Sep 17 00:00:00 2001 From: John Costa Date: Sat, 21 Mar 2026 20:31:20 -0700 Subject: [PATCH 1/4] 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 e0d27266cb5d9e0c84ed4f3bfd9753876af71492 Mon Sep 17 00:00:00 2001 From: John Costa Date: Sat, 21 Mar 2026 20:37:38 -0700 Subject: [PATCH 2/4] 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 5fbcaf164054f0934c08a89fe51f37da5699263b Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 24 Mar 2026 14:00:14 +1100 Subject: [PATCH 3/4] 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 93145bacfed..377b81b37f1 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); @@ -937,4 +937,132 @@ 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 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 4493e3cb214a4a6a96daedf7940a5872be7754a2 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 24 Mar 2026 14:48:39 +1100 Subject: [PATCH 4/4] 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 377b81b37f1..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'; @@ -984,9 +985,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() {