Skip to content

Commit b5cbf5b

Browse files
fix(ComboBox): avoid extra onSelectionChange calls (#9729)
Co-authored-by: Robert Snow <rsnow@adobe.com>
1 parent daa9d7b commit b5cbf5b

File tree

5 files changed

+107
-7
lines changed

5 files changed

+107
-7
lines changed

packages/@react-aria/combobox/src/useComboBox.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,9 @@ export function useComboBox<T, M extends SelectionMode = 'single'>(props: AriaCo
156156
break;
157157
}
158158
}
159-
state.commit();
159+
if (e.key === 'Enter' || state.isOpen) {
160+
state.commit();
161+
}
160162
break;
161163
case 'Escape':
162164
if (

packages/@react-aria/combobox/test/useComboBox.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,23 @@ describe('useComboBox', function () {
119119
expect(preventDefault).toHaveBeenCalledTimes(1);
120120
});
121121

122+
it('should only call commit on Tab when the menu is open', function () {
123+
let commitSpy = jest.fn();
124+
let {result: state} = renderHook((props) => useComboBoxState(props), {initialProps: props});
125+
let closedState = {...state.current, isOpen: false, commit: commitSpy};
126+
let {result: closedResult} = renderHook((props) => useComboBox(props, closedState), {initialProps: props});
127+
act(() => {
128+
closedResult.current.inputProps.onKeyDown(event({key: 'Tab'}));
129+
});
130+
expect(commitSpy).toHaveBeenCalledTimes(0);
131+
let openState = {...state.current, isOpen: true, commit: commitSpy};
132+
let {result: openResult} = renderHook((props) => useComboBox(props, openState), {initialProps: props});
133+
act(() => {
134+
openResult.current.inputProps.onKeyDown(event({key: 'Tab'}));
135+
});
136+
expect(commitSpy).toHaveBeenCalledTimes(1);
137+
});
138+
122139
it('calls open and toggle with the expected parameters when arrow down/up/trigger button is pressed', function () {
123140
let {result: state} = renderHook((props) => useComboBoxState(props), {initialProps: props});
124141
state.current.open = openSpy;

packages/@react-stately/combobox/src/useComboBoxState.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -369,14 +369,21 @@ export function useComboBoxState<T extends object, M extends SelectionMode = 'si
369369
closeMenu();
370370
};
371371

372-
let commitSelection = () => {
373-
// If multiple things are controlled, call onSelectionChange
372+
let commitSelection = (shouldForceSelectionChange = false) => {
373+
// If multiple things are controlled, call onSelectionChange only when selecting the focused item,
374+
// or when inputValue needs to be synced back to the selected item on commit/blur.
374375
if (value !== undefined && props.inputValue !== undefined) {
375-
props.onSelectionChange?.(selectedKey);
376-
props.onChange?.(displayValue as ChangeValueType<M>);
376+
let itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : '';
377+
if (
378+
shouldForceSelectionChange ||
379+
selectionMode === 'multiple' ||
380+
inputValue !== itemText
381+
) {
382+
props.onSelectionChange?.(selectedKey);
383+
props.onChange?.(displayValue as ChangeValueType<M>);
384+
}
377385

378386
// Stop menu from reopening from useEffect
379-
let itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : '';
380387
setLastValue(itemText);
381388
closeMenu();
382389
} else {
@@ -401,7 +408,7 @@ export function useComboBoxState<T extends object, M extends SelectionMode = 'si
401408
// Reset inputValue and close menu here if the selected key is already the focused key. Otherwise
402409
// fire onSelectionChange to allow the application to control the closing.
403410
if (selectionManager.isSelected(selectionManager.focusedKey) && selectionMode === 'single') {
404-
commitSelection();
411+
commitSelection(true);
405412
} else {
406413
selectionManager.select(selectionManager.focusedKey);
407414
}

packages/@react-stately/combobox/test/useComboBoxState.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,23 @@ describe('useComboBoxState tests', function () {
209209
expect(onSelectionChange).toHaveBeenCalledWith('1');
210210
});
211211

212+
it('does not fire onSelectionChange on blur when fully controlled values are already synced', function () {
213+
let initialProps = {...defaultProps, selectedKey: '1', inputValue: 'onomatopoeia'};
214+
let {result} = renderHook((props) => useComboBoxState(props), {initialProps});
215+
216+
act(() => {result.current.setFocused(false);});
217+
expect(onSelectionChange).not.toHaveBeenCalled();
218+
});
219+
220+
it('fires onSelectionChange on blur when fully controlled inputValue is out of sync', function () {
221+
let initialProps = {...defaultProps, selectedKey: '1', inputValue: 'onom'};
222+
let {result} = renderHook((props) => useComboBoxState(props), {initialProps});
223+
224+
act(() => {result.current.setFocused(false);});
225+
expect(onSelectionChange).toHaveBeenCalledTimes(1);
226+
expect(onSelectionChange).toHaveBeenCalledWith('1');
227+
});
228+
212229
it('won\'t update the returned collection if the combobox is closed (uncontrolled items)', function () {
213230
let filter = renderHook((props) => useFilter(props), {sensitivity: 'base'});
214231
let initialProps = {...defaultProps, items: null, defaultItems: [{id: '0', name: 'one'}, {id: '1', name: 'onomatopoeia'}], defaultInputValue: '', defaultFilter: filter.result.current.contains};

packages/react-aria-components/test/ComboBox.test.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,63 @@ describe('ComboBox', () => {
263263
expect(document.querySelector('input[type=hidden]')).toBeNull();
264264
});
265265

266+
it.each(['click', 'tab'])('should not fire extra onSelectionChange calls after focus moves away in fully controlled mode via %s', async (focusMove) => {
267+
let onSelectionChange = jest.fn();
268+
let keyToText = {
269+
'1': 'Cat',
270+
'2': 'Dog',
271+
'3': 'Kangaroo'
272+
};
273+
274+
function ControlledComboBox() {
275+
let [selectedKey, setSelectedKey] = useState(null);
276+
let [inputValue, setInputValue] = useState('');
277+
278+
return (
279+
<>
280+
<ComboBox
281+
value={selectedKey}
282+
inputValue={inputValue}
283+
onChange={(key) => {
284+
onSelectionChange(key);
285+
setSelectedKey(key);
286+
setInputValue(key != null ? keyToText[key] : '');
287+
}}
288+
onInputChange={setInputValue}>
289+
<Label>Favorite Animal</Label>
290+
<Input />
291+
<Button />
292+
<Popover>
293+
<ListBox>
294+
<ListBoxItem id="1">Cat</ListBoxItem>
295+
<ListBoxItem id="2">Dog</ListBoxItem>
296+
<ListBoxItem id="3">Kangaroo</ListBoxItem>
297+
</ListBox>
298+
</Popover>
299+
</ComboBox>
300+
<button type="button">Next</button>
301+
</>
302+
);
303+
}
304+
305+
let tree = render(<ControlledComboBox />);
306+
let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container});
307+
308+
await comboboxTester.selectOption({option: 'Dog'});
309+
expect(onSelectionChange).toHaveBeenCalledTimes(1);
310+
311+
if (focusMove === 'click') {
312+
await user.click(tree.getByRole('button', {name: 'Next'}));
313+
} else {
314+
act(() => {
315+
comboboxTester.combobox.focus();
316+
});
317+
await user.tab();
318+
}
319+
320+
expect(onSelectionChange).toHaveBeenCalledTimes(1);
321+
});
322+
266323
it('should support form reset', async () => {
267324
const tree = render(
268325
<form>

0 commit comments

Comments
 (0)