From 005fef34ada724d9214c84712a911c219b8f911a Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Tue, 3 Mar 2026 11:03:42 -0600 Subject: [PATCH] fix(ComboBox): avoid extra onSelectionChange calls --- .../@react-aria/combobox/src/useComboBox.ts | 4 +- .../combobox/test/useComboBox.test.js | 17 ++++++ .../combobox/src/useComboBoxState.ts | 19 +++++-- .../combobox/test/useComboBoxState.test.js | 17 ++++++ .../test/ComboBox.test.js | 57 +++++++++++++++++++ 5 files changed, 107 insertions(+), 7 deletions(-) diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index bb511e156d7..7e4dd1300b1 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -157,7 +157,9 @@ export function useComboBox(props: AriaCo break; } } - state.commit(); + if (e.key === 'Enter' || state.isOpen) { + state.commit(); + } break; case 'Escape': if ( diff --git a/packages/@react-aria/combobox/test/useComboBox.test.js b/packages/@react-aria/combobox/test/useComboBox.test.js index 9b83283f355..b6b128cea30 100644 --- a/packages/@react-aria/combobox/test/useComboBox.test.js +++ b/packages/@react-aria/combobox/test/useComboBox.test.js @@ -103,6 +103,23 @@ describe('useComboBox', function () { expect(preventDefault).toHaveBeenCalledTimes(1); }); + it('should only call commit on Tab when the menu is open', function () { + let commitSpy = jest.fn(); + let {result: state} = renderHook((props) => useComboBoxState(props), {initialProps: props}); + let closedState = {...state.current, isOpen: false, commit: commitSpy}; + let {result: closedResult} = renderHook((props) => useComboBox(props, closedState), {initialProps: props}); + act(() => { + closedResult.current.inputProps.onKeyDown(event({key: 'Tab'})); + }); + expect(commitSpy).toHaveBeenCalledTimes(0); + let openState = {...state.current, isOpen: true, commit: commitSpy}; + let {result: openResult} = renderHook((props) => useComboBox(props, openState), {initialProps: props}); + act(() => { + openResult.current.inputProps.onKeyDown(event({key: 'Tab'})); + }); + expect(commitSpy).toHaveBeenCalledTimes(1); + }); + it('calls open and toggle with the expected parameters when arrow down/up/trigger button is pressed', function () { let {result: state} = renderHook((props) => useComboBoxState(props), {initialProps: props}); state.current.open = openSpy; diff --git a/packages/@react-stately/combobox/src/useComboBoxState.ts b/packages/@react-stately/combobox/src/useComboBoxState.ts index 54838afb8d9..c424eac76d1 100644 --- a/packages/@react-stately/combobox/src/useComboBoxState.ts +++ b/packages/@react-stately/combobox/src/useComboBoxState.ts @@ -369,14 +369,21 @@ export function useComboBoxState useComboBoxState(props), {initialProps}); + + act(() => {result.current.setFocused(false);}); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('fires onSelectionChange on blur when fully controlled inputValue is out of sync', function () { + let initialProps = {...defaultProps, selectedKey: '1', inputValue: 'onom'}; + let {result} = renderHook((props) => useComboBoxState(props), {initialProps}); + + act(() => {result.current.setFocused(false);}); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onSelectionChange).toHaveBeenCalledWith('1'); + }); + it('won\'t update the returned collection if the combobox is closed (uncontrolled items)', function () { let filter = renderHook((props) => useFilter(props), {sensitivity: 'base'}); let initialProps = {...defaultProps, items: null, defaultItems: [{id: '0', name: 'one'}, {id: '1', name: 'onomatopoeia'}], defaultInputValue: '', defaultFilter: filter.result.current.contains}; diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index f84db0b02e9..4a6c16f06f6 100644 --- a/packages/react-aria-components/test/ComboBox.test.js +++ b/packages/react-aria-components/test/ComboBox.test.js @@ -263,6 +263,63 @@ describe('ComboBox', () => { expect(document.querySelector('input[type=hidden]')).toBeNull(); }); + it.each(['click', 'tab'])('should not fire extra onSelectionChange calls after focus moves away in fully controlled mode via %s', async (focusMove) => { + let onSelectionChange = jest.fn(); + let keyToText = { + '1': 'Cat', + '2': 'Dog', + '3': 'Kangaroo' + }; + + function ControlledComboBox() { + let [selectedKey, setSelectedKey] = useState(null); + let [inputValue, setInputValue] = useState(''); + + return ( + <> + { + onSelectionChange(key); + setSelectedKey(key); + setInputValue(key != null ? keyToText[key] : ''); + }} + onInputChange={setInputValue}> + + + + + ); + } + + let tree = render(); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container}); + + await comboboxTester.selectOption({option: 'Dog'}); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + + if (focusMove === 'click') { + await user.click(tree.getByRole('button', {name: 'Next'})); + } else { + act(() => { + comboboxTester.combobox.focus(); + }); + await user.tab(); + } + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + }); + it('should support form reset', async () => { const tree = render(