diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index 4fe042f3c76..a127bf39f35 100644 --- a/packages/react-aria-components/test/ComboBox.test.js +++ b/packages/react-aria-components/test/ComboBox.test.js @@ -368,6 +368,70 @@ describe('ComboBox', () => { } ); + it('should not clear the selection when a fully controlled value is applied asynchronously', async () => { + let onSelectionChange = jest.fn(); + let keyToText = { + 1: 'Cat', + 2: 'Dog', + 3: 'Kangaroo' + }; + + // Form libraries such as Formik and react-hook-form apply the controlled value after + // running validation, so it lands in a later render than the one that reported it. + function ControlledComboBox() { + let [selectedKey, setSelectedKey] = useState(null); + let [inputValue, setInputValue] = useState(''); + + return ( + <> + { + onSelectionChange(key); + setTimeout(() => { + setSelectedKey(key); + setInputValue(key != null ? keyToText[key] : ''); + }, 10); + }} + onInputChange={setInputValue}> + + + + + ); + } + + let tree = render(); + let input = tree.getByRole('combobox'); + + await user.tab(); + await user.keyboard('Do'); + act(() => jest.runAllTimers()); + + // Select without letting the deferred update land first, so focus moves away while the + // controlled value still reports the previous selection. + await user.click(within(tree.getByRole('listbox')).getByRole('option', {name: 'Dog'})); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onSelectionChange).toHaveBeenCalledWith('2'); + + await user.tab(); + act(() => jest.runAllTimers()); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(input).toHaveValue('Dog'); + expect(tree.queryByRole('listbox')).toBeNull(); + }); + it('should support form reset', async () => { const tree = render(
diff --git a/packages/react-stately/src/combobox/useComboBoxState.ts b/packages/react-stately/src/combobox/useComboBoxState.ts index af0d29deded..d906bcead46 100644 --- a/packages/react-stately/src/combobox/useComboBoxState.ts +++ b/packages/react-stately/src/combobox/useComboBoxState.ts @@ -228,11 +228,17 @@ export function useComboBoxState( ? controlledValue[0] : controlledValue; + // Tracks a selection that has been reported to the user but that the controlled value hasn't + // reflected back yet. Form libraries commonly apply the update asynchronously (e.g. after running + // validation), so the rendered selection is stale until then and must not be reported back. + let pendingValueRef = useRef(undefined); + let setValue = (value: Key | Key[] | null) => { if (selectionMode === 'single') { let key = Array.isArray(value) ? (value[0] ?? null) : value; setControlledValue(key); if (key !== displayValue) { + pendingValueRef.current = key; props.onSelectionChange?.(key); } } else { @@ -491,6 +497,10 @@ export function useComboBoxState( } } + if (displayValue !== lastValueRef.current) { + pendingValueRef.current = undefined; + } + lastValueRef.current = displayValue; lastSelectedKeyText.current = selectedItemText; }); @@ -534,6 +544,13 @@ export function useComboBoxState( // If multiple things are controlled, call onSelectionChange only when selecting the focused item, // or when inputValue needs to be synced back to the selected item on commit/blur. if (value !== undefined && props.inputValue !== undefined) { + if (pendingValueRef.current !== undefined) { + // Stop menu from reopening from useEffect + setLastValue(inputValue); + closeMenu(); + return; + } + let itemText = selectedKey != null ? (collection.getItem(selectedKey)?.textValue ?? '') : ''; if (shouldForceSelectionChange || selectionMode === 'multiple' || inputValue !== itemText) { props.onSelectionChange?.(selectedKey); @@ -565,7 +582,11 @@ export function useComboBoxState( if (triggerState.isOpen && selectionManager.focusedKey != null) { // Reset inputValue and close menu here if the selected key is already the focused key. Otherwise // fire onSelectionChange to allow the application to control the closing. - if (selectionManager.isSelected(selectionManager.focusedKey) && selectionMode === 'single') { + if ( + (selectionManager.isSelected(selectionManager.focusedKey) || + pendingValueRef.current === selectionManager.focusedKey) && + selectionMode === 'single' + ) { commitSelection(true); } else { selectionManager.select(selectionManager.focusedKey);