Skip to content

Commit 1e2d9cd

Browse files
authored
fix: sync controlled ComboBox selection state (#22167)
1 parent c250038 commit 1e2d9cd

2 files changed

Lines changed: 55 additions & 3 deletions

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -819,6 +819,29 @@ describe('ComboBox', () => {
819819
`${prefix}--list-box__menu-item--active`
820820
);
821821
});
822+
823+
it('should keep the external selection when the menu closes after an external update', async () => {
824+
render(<ControlledComboBox />);
825+
826+
await openMenu();
827+
await userEvent.click(screen.getByRole('option', { name: 'Item 2' }));
828+
expect(screen.getByText('onChangeCallCount: 1')).toBeInTheDocument();
829+
expect(screen.getByTestId('selected-item').textContent).toBe('Item 2');
830+
831+
await userEvent.click(
832+
screen.getByRole('button', { name: 'Choose item 3' })
833+
);
834+
expect(screen.getByText('onChangeCallCount: 2')).toBeInTheDocument();
835+
expect(screen.getByTestId('selected-item').textContent).toBe('Item 3');
836+
837+
await openMenu();
838+
await userEvent.click(document.body);
839+
840+
expect(screen.getByText('onChangeCallCount: 2')).toBeInTheDocument();
841+
expect(screen.getByTestId('selected-item').textContent).toBe('Item 3');
842+
expect(findInputNode()).toHaveDisplayValue('Item 3');
843+
});
844+
822845
it('should update and call `onChange` once when selection is updated externally', async () => {
823846
const { rerender } = render(
824847
<ComboBox {...mockProps} selectedItem={mockProps.items[0]} />

packages/react/src/components/ComboBox/ComboBox.tsx

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,14 @@ const ComboBox = forwardRef(
510510
const prevSelectedItemProp = useRef<ItemType | null | undefined>(
511511
selectedItemProp
512512
);
513+
const isSyncingControlledSelectionRef = useRef(false);
514+
const pendingControlledSelectionRef = useRef<{
515+
pending: boolean;
516+
value: ItemType | null | undefined;
517+
}>({
518+
pending: false,
519+
value: undefined,
520+
});
513521

514522
useEffect(() => {
515523
isManualClearingRef.current = isClearing;
@@ -523,6 +531,10 @@ const ComboBox = forwardRef(
523531
// fully controlled combobox: handle changes to selectedItemProp
524532
useEffect(() => {
525533
if (prevSelectedItemProp.current !== selectedItemProp) {
534+
pendingControlledSelectionRef.current = {
535+
pending: true,
536+
value: selectedItemProp,
537+
};
526538
const currentInputValue = getInputValue({
527539
initialSelectedItem,
528540
itemToString,
@@ -885,13 +897,17 @@ const ComboBox = forwardRef(
885897
isItemDisabled: isDisabledItem,
886898
...downshiftProps,
887899
onStateChange: ({ type, selectedItem: newSelectedItem }) => {
900+
if (
901+
isManualClearingRef.current ||
902+
isSyncingControlledSelectionRef.current
903+
) {
904+
isSyncingControlledSelectionRef.current = false;
905+
return;
906+
}
888907
downshiftProps?.onStateChange?.({
889908
type,
890909
selectedItem: newSelectedItem,
891910
});
892-
if (isManualClearingRef.current) {
893-
return;
894-
}
895911
if (
896912
(type === ItemClick ||
897913
type === FunctionSelectItem ||
@@ -914,6 +930,19 @@ const ComboBox = forwardRef(
914930
const currentSelectedItem =
915931
typeof selectedItemProp !== 'undefined' ? selectedItemProp : selectedItem;
916932

933+
useEffect(() => {
934+
if (pendingControlledSelectionRef.current.pending) {
935+
const { value } = pendingControlledSelectionRef.current;
936+
const nextSelectedItem = typeof value === 'undefined' ? null : value;
937+
pendingControlledSelectionRef.current.pending = false;
938+
939+
if (!isEqual(selectedItem, nextSelectedItem)) {
940+
isSyncingControlledSelectionRef.current = true;
941+
selectItem(nextSelectedItem);
942+
}
943+
}
944+
}, [selectedItem, selectedItemProp, selectItem]);
945+
917946
useEffect(() => {
918947
// Used to expose the downshift actions to consumers for use with downshiftProps
919948
// An odd pattern, here we mutate the value stored in the ref provided from the consumer.

0 commit comments

Comments
 (0)