Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions packages/react-aria-components/test/ComboBox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<ComboBox
selectedKey={selectedKey}
inputValue={inputValue}
onSelectionChange={key => {
onSelectionChange(key);
setTimeout(() => {
setSelectedKey(key);
setInputValue(key != null ? keyToText[key] : '');
}, 10);
}}
onInputChange={setInputValue}>
<Label>Favorite Animal</Label>
<Input />
<Button />
<Popover>
<ListBox>
<ListBoxItem id="1">Cat</ListBoxItem>
<ListBoxItem id="2">Dog</ListBoxItem>
<ListBoxItem id="3">Kangaroo</ListBoxItem>
</ListBox>
</Popover>
</ComboBox>
<button type="button">Next</button>
</>
);
}

let tree = render(<ControlledComboBox />);
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(
<form>
Expand Down
23 changes: 22 additions & 1 deletion packages/react-stately/src/combobox/useComboBoxState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,17 @@ export function useComboBoxState<T, M extends SelectionMode = 'single'>(
? 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<Key | null | undefined>(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 {
Expand Down Expand Up @@ -491,6 +497,10 @@ export function useComboBoxState<T, M extends SelectionMode = 'single'>(
}
}

if (displayValue !== lastValueRef.current) {
pendingValueRef.current = undefined;
}

lastValueRef.current = displayValue;
lastSelectedKeyText.current = selectedItemText;
});
Expand Down Expand Up @@ -534,6 +544,13 @@ export function useComboBoxState<T, M extends SelectionMode = 'single'>(
// 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);
Expand Down Expand Up @@ -565,7 +582,11 @@ export function useComboBoxState<T, M extends SelectionMode = 'single'>(
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);
Expand Down