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
200 changes: 198 additions & 2 deletions packages/react-aria-components/test/ComboBox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {ListLayout} from 'react-stately/private/layout/ListLayout';
import {Popover} from '../src/Popover';
import React, {useState} from 'react';
import {Text} from '../src/Text';
import {useAsyncList} from 'react-stately/useAsyncList';
import {User} from '@react-aria/test-utils';
import userEvent from '@testing-library/user-event';
import {Virtualizer} from '../src/Virtualizer';
Expand Down Expand Up @@ -857,11 +858,11 @@ describe('ComboBox', () => {
act(() => {getByTestId('form').checkValidity();});
expect(combobox).toHaveAttribute('aria-describedby');
expect(container.querySelector('.react-aria-ComboBox')).toHaveAttribute('data-invalid');

await comboboxTester.open();
let options = comboboxTester.options();
await user.click(options[0]);

act(() => combobox.blur());
expect(combobox).not.toHaveAttribute('required');
expect(combobox.validity.valid).toBe(true);
Expand Down Expand Up @@ -937,4 +938,199 @@ describe('ComboBox', () => {
expect(comboboxTester.combobox).toHaveFocus();
expect(onOpenChange).toHaveBeenCalledTimes(1);
});

it('should re-open the menu when controlled items go from empty to non-empty controlled items', async () => {
let onOpenChange = jest.fn();
let onInputChange = jest.fn().mockReturnValueOnce(true).mockReturnValue(false);

function ControlledComboBox() {
let [items, setItems] = useState([{id: 1, name: 'Luke Skywalker'}]);
return (
<ComboBox
items={items}
onInputChange={() => {
if (onInputChange()) {
setItems([]);
} else {
setItems([{id: 1, name: 'Luke Skywalker'}]);
}
}}
onOpenChange={onOpenChange}>
<Label>SW Characters</Label>
<Input />
<Button>{'<'}</Button>
<Popover>
<ListBox>
{(item) => {
return <ListBoxItem id={item.id}>{item.name}</ListBoxItem>;
}}
</ListBox>
</Popover>
</ComboBox>
);
}

let {container, queryByRole} = render(<ControlledComboBox />);
let comboboxTester = testUtilUser.createTester('ComboBox', {root: container});
await user.tab();
await user.keyboard('{ArrowDown}');
expect(onOpenChange).toHaveBeenCalledTimes(1);
expect(comboboxTester.listbox).toBeVisible();
onOpenChange.mockClear();

await user.keyboard('L');
expect(queryByRole('listbox')).toBeNull();

await user.keyboard('{Backspace}');
expect(comboboxTester.listbox).toBeVisible();
});

it('should re-open the menu with useAsyncList after an empty async result then backspace', async () => {
const ASYNC_DELAY_MS = 50;

function itemsForFilterText(filterText) {
if (filterText === 'luka') {
return [];
}
return [{id: 1, name: 'Luke Skywalker'}];
}

function AsyncComboBox() {
let list = useAsyncList({
getKey: (item) => item.id,
async load({filterText}) {
let rows = itemsForFilterText(filterText);
await new Promise((resolve) => setTimeout(resolve, ASYNC_DELAY_MS));
return {items: rows};
}
});

return (
<ComboBox items={list.items} inputValue={list.filterText} onInputChange={list.setFilterText}>
<Label>SW Characters</Label>
<Input />
<Button>{'<'}</Button>
<Popover>
<ListBox>
{(item) => <ListBoxItem id={item.id}>{item.name}</ListBoxItem>}
</ListBox>
</Popover>
</ComboBox>
);
}

let {container, queryByRole} = render(<AsyncComboBox />);
let comboboxTester = testUtilUser.createTester('ComboBox', {root: container});

await act(async () => {
jest.runAllTimers();
});

await user.tab();
await user.keyboard('{ArrowDown}');
await act(async () => {
jest.runAllTimers();
});
expect(comboboxTester.listbox).toBeVisible();
expect(
within(comboboxTester.listbox).getByRole('option', {name: 'Luke Skywalker'})
).toBeInTheDocument();

await user.keyboard('luka');
await act(async () => {
jest.runAllTimers();
});
expect(queryByRole('listbox')).toBeNull();

await user.keyboard('{Backspace}');
expect(queryByRole('listbox')).toBeNull();
await act(async () => {
jest.runAllTimers();
});
expect(comboboxTester.listbox).toBeVisible();
expect(
within(comboboxTester.listbox).getByRole('option', {name: 'Luke Skywalker'})
).toBeInTheDocument();
});

it('should still close the menu when uncontrolled items are empty', async () => {
let onOpenChange = jest.fn();

let items = [{id: 1, name: 'Luke Skywalker'}];
function ControlledComboBox() {
return (
<ComboBox
defaultItems={items}
onOpenChange={onOpenChange}>
<Label>SW Characters</Label>
<Input />
<Button>{'<'}</Button>
<Popover>
<ListBox>
{(item) => {
return <ListBoxItem id={item.id}>{item.name}</ListBoxItem>;
}}
</ListBox>
</Popover>
</ComboBox>
);
}

let {container, queryByRole} = render(<ControlledComboBox />);
let comboboxTester = testUtilUser.createTester('ComboBox', {root: container});
await user.tab();
await user.keyboard('{ArrowDown}');
expect(onOpenChange).toHaveBeenCalledTimes(1);
expect(comboboxTester.listbox).toBeVisible();
onOpenChange.mockClear();

await user.keyboard('Z');
expect(queryByRole('listbox')).toBeNull();
});

it('should not re-open after user dismisses with Escape (revert) controlled items', async () => {
let onOpenChange = jest.fn();
let onInputChange = jest.fn().mockReturnValueOnce(true).mockReturnValue(false);

function ControlledComboBox() {
let [items, setItems] = useState([{id: 1, name: 'Luke Skywalker'}]);
return (
<ComboBox
items={items}
onInputChange={() => {
if (onInputChange()) {
setItems([]);
} else {
setItems([{id: 1, name: 'Luke Skywalker'}]);
}
}}
onOpenChange={onOpenChange}>
<Label>SW Characters</Label>
<Input />
<Button>{'<'}</Button>
<Popover>
<ListBox>
{(item) => {
return <ListBoxItem id={item.id}>{item.name}</ListBoxItem>;
}}
</ListBox>
</Popover>
</ComboBox>
);
}

let {container, queryByRole} = render(<ControlledComboBox />);
let comboboxTester = testUtilUser.createTester('ComboBox', {root: container});
await user.tab();
await user.keyboard('{ArrowDown}');
expect(onOpenChange).toHaveBeenCalledTimes(1);
expect(comboboxTester.listbox).toBeVisible();
onOpenChange.mockClear();

await user.keyboard('L');
expect(queryByRole('listbox')).toBeNull();

await user.keyboard('{Escape}');
expect(queryByRole('listbox')).toBeNull();
});
});
19 changes: 19 additions & 0 deletions packages/react-stately/src/combobox/useComboBoxState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export function useComboBoxState<T extends object, M extends SelectionMode = 'si
let [showAllItems, setShowAllItems] = useState(false);
let [isFocused, setFocusedState] = useState(false);
let [focusStrategy, setFocusStrategy] = useState<FocusStrategy | null>(null);
let closedDueToEmptyControlled = useRef(false);

let defaultValue = useMemo(() => {
return props.defaultValue !== undefined ? props.defaultValue : (selectionMode === 'single' ? props.defaultSelectedKey ?? null : []) as ValueType<M>;
Expand Down Expand Up @@ -359,9 +360,25 @@ export function useComboBoxState<T extends object, M extends SelectionMode = 'si
triggerState.isOpen &&
filteredCollection.size === 0
) {
if (props.items != null) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One issue here is that async ComboBoxes typically make use of a Collection + LoadMoreItem and thus pass the controlled items to the wrapped ListBox, meaning props.items here will be null always. I suppose we could ask users to pass items to both places, but that feels a bit gross...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

example of this in the storybook:

<ComboBox inputValue={list.filterText} onInputChange={list.setFilterText} allowsEmptyCollection>
<Label style={{display: 'block'}}>Async Virtualized Dynamic ComboBox</Label>
<div style={{display: 'flex', position: 'relative'}}>
<Input />
{list.isLoading && <LoadingSpinner style={{left: '130px', top: '0px', height: 20, width: 20}} />}
<Button>
<span aria-hidden="true" style={{padding: '0 2px'}}></span>
</Button>
</div>
<Popover>
<Virtualizer
layout={ListLayout}
layoutOptions={{rowHeight: 25, loaderHeight: 30}}>
<ListBox<Character> className={styles.menu} renderEmptyState={renderEmptyState}>
<Collection items={list.items}>
{item => <MyListBoxItem id={item.name}>{item.name}</MyListBoxItem>}
</Collection>
<MyListBoxLoaderIndicator isLoading={list.loadingState === 'loadingMore'} onLoadMore={list.loadMore} />
</ListBox>
</Virtualizer>
</Popover>
</ComboBox>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its possible that we could pass something to useComboBoxState to inform it of this specific configuration and use the collection provided by the props instead of .items

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — I hadn't considered the Collection + LoadMoreItem pattern where items go to ListBox's Collection rather than to ComboBox directly.

I traced through the code for all three patterns (items on ComboBox, Collection+LoadMoreItem, and allowsEmptyCollection) and I think the simplest fix is to remove the props.items != null guard entirely — just set closedDueToEmpty unconditionally when the menu auto-closes due to empty collection.

This is safe because:

  • Uncontrolled (static items): filteredCollection only changes when inputValue changes, which already triggers re-open via the inputValue !== lastValue check above. The flag becomes redundant but harmless.
  • Collection+LoadMoreItem: The collection updates when async items arrive via CollectionBuilder, so filteredCollection goes from 0 → N without input changing — exactly the scenario this flag is designed for.
  • allowsEmptyCollection: The auto-close block doesn't fire at all (!allowsEmptyCollection is false), so the flag is never set. No change.

I'll also rename closedDueToEmptyControlledclosedDueToEmpty since it's no longer specific to controlled items, and add a test covering the Collection-based async pattern.

closedDueToEmptyControlled.current = true;
}
closeMenu();
}

// Re-open the menu when controlled items become non-empty after being auto-closed due to
// an empty collection (e.g. async load completed with results after a previous empty response).
if (
isFocused &&
closedDueToEmptyControlled.current &&
filteredCollection.size > 0 &&
!triggerState.isOpen &&
menuTrigger !== 'manual'
) {
closedDueToEmptyControlled.current = false;
open(null, 'input');
}

// Close when an item is selected.
if (
displayValue != null &&
Expand Down Expand Up @@ -418,6 +435,7 @@ export function useComboBoxState<T extends object, M extends SelectionMode = 'si

// Revert input value and close menu
let revert = () => {
closedDueToEmptyControlled.current = false;
if (allowsCustomValue && selectedKey == null) {
commitCustomValue();
} else {
Expand Down Expand Up @@ -457,6 +475,7 @@ export function useComboBoxState<T extends object, M extends SelectionMode = 'si
};

const commitValue = () => {
closedDueToEmptyControlled.current = false;
if (allowsCustomValue) {
const itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : '';
(inputValue === itemText) ? commitSelection() : commitCustomValue();
Expand Down
80 changes: 80 additions & 0 deletions packages/react-stately/test/combobox/useComboBoxState.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -305,4 +305,84 @@ describe('useComboBoxState tests', function () {
expect(result.current.collection.size).toEqual(2);
});
});

describe('controlled items (async loading)', function () {
it('should re-open the menu when controlled items go from empty to non-empty', function () {
let onOpenChange = jest.fn();
let initialProps = {
items: [{id: 1, name: 'Luke Skywalker'}],
children: (props) => <Item>{props.name}</Item>,
onOpenChange
};

let {result, rerender} = renderHook((props) => useComboBoxState(props), {initialProps});

// Focus and open the menu by setting input value
act(() => {result.current.setFocused(true);});
act(() => {result.current.open(null, 'input');});
expect(result.current.isOpen).toBe(true);

// Simulate async load returning empty results (e.g. user typed "luka")
rerender({...initialProps, items: []});
// Menu closes on empty collection
expect(result.current.isOpen).toBe(false);

// Simulate async load returning results again (e.g. user backspaced to "luk")
rerender({...initialProps, items: [{id: 1, name: 'Luke Skywalker'}]});
// Menu should re-open because items were controlled and the close was due to empty collection
expect(result.current.isOpen).toBe(true);
expect(result.current.collection.size).toEqual(1);
expect(onOpenChange).toHaveBeenLastCalledWith(true, 'input');
});

it('should not re-open after user dismisses with Escape (revert)', function () {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test and the one below appear to have been passing before the change, where did they come from?

They also passed in the component level, which I've pushed up here as well

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, both of those pass without the change too. The original thinking was they'd serve as guard rails for the re-open logic boundaries (Escape clearing the flag, uncontrolled items being excluded), but since they assert the default state (menu stays closed) they pass vacuously whether or not the re-open behavior exists.

Happy to remove them. The component-level tests you added cover the same scenarios, and the useAsyncList integration test is a much better representation of the actual user flow this fix is targeting.

Want me to drop those two hook-level tests and keep just the one that actually demonstrates the fix (controlled items going from empty to non-empty)?

let onOpenChange = jest.fn();
let initialProps = {
items: [{id: 1, name: 'Luke Skywalker'}],
children: (props) => <Item>{props.name}</Item>,
onOpenChange
};

let {result, rerender} = renderHook((props) => useComboBoxState(props), {initialProps});

// Focus and open
act(() => {result.current.setFocused(true);});
act(() => {result.current.open(null, 'input');});
expect(result.current.isOpen).toBe(true);

// Async returns empty, menu auto-closes
rerender({...initialProps, items: []});
expect(result.current.isOpen).toBe(false);

// User presses Escape (revert) while menu is closed
act(() => {result.current.revert();});

// Async returns items — menu should NOT re-open because user explicitly dismissed
rerender({...initialProps, items: [{id: 1, name: 'Luke Skywalker'}]});
expect(result.current.isOpen).toBe(false);
});

it('should still close the menu when uncontrolled items are empty', function () {
let onOpenChange = jest.fn();
let contains = (a, b) => a.toLowerCase().includes(b.toLowerCase());
let initialProps = {
defaultItems: [{id: 1, name: 'Luke Skywalker'}],
children: (props) => <Item>{props.name}</Item>,
onOpenChange,
defaultFilter: contains
};

let {result} = renderHook((props) => useComboBoxState(props), {initialProps});

// Focus and open
act(() => {result.current.setFocused(true);});
act(() => {result.current.open(null, 'input');});
expect(result.current.isOpen).toBe(true);

// Type something that filters to zero results
act(() => {result.current.setInputValue('zzz');});
// Menu should close because items are uncontrolled and filtered to empty
expect(result.current.isOpen).toBe(false);
});
});
});
Loading