Skip to content

fix(combobox): re-open menu when async items arrive after empty response#9823

Open
costajohnt wants to merge 4 commits intoadobe:mainfrom
costajohnt:fix/combobox-async-empty-reopen
Open

fix(combobox): re-open menu when async items arrive after empty response#9823
costajohnt wants to merge 4 commits intoadobe:mainfrom
costajohnt:fix/combobox-async-empty-reopen

Conversation

@costajohnt
Copy link
Copy Markdown
Contributor

Summary

Fixes #9820

When using useComboBox with useAsyncList, if a query returns zero results (e.g. typing "luka" against a Star Wars API), the menu closes. Subsequent queries that return results (e.g. backspacing to "luk" which matches "Luke Skywalker") fail to re-open the menu because the inputValue tracking has already been updated by the time the async items arrive.

The root cause is an asymmetry between the open() function (which has a || props.items guard to allow opening with controlled empty collections) and the auto-close check in the useEffect (which closes unconditionally when filteredCollection.size === 0).

The fix tracks when the menu was auto-closed due to an empty controlled collection and re-opens it when items become non-empty while the input is still focused. The flag is reset on user-initiated closes (blur, Escape, commit) so those remain permanent.

Note: the @react-spectrum/combobox component already works around this by auto-setting allowsEmptyCollection: true when loadingState is provided, but direct hook users (useComboBox + useComboBoxState) and RAC ComboBox users don't get this automatic behavior.

Test plan

  • Added test: menu re-opens when controlled items go from empty to non-empty while focused
  • Added test: uncontrolled items still close the menu on empty (regression guard)
  • All 276 existing combobox tests pass across 4 suites (useComboBoxState, useComboBox, RAC ComboBox, Spectrum ComboBox)

Closes #9820

…empty response

When using useComboBox with useAsyncList, if a query returns zero results
the menu closes. A subsequent query that returns results fails to re-open
the menu because the inputValue tracking has already been updated by the
time the async items arrive.

Track when the menu was auto-closed due to an empty controlled collection
and re-open it when items become non-empty while the input is still focused.
Reset the flag on user-initiated closes (blur, Escape, commit) so those
remain permanent.

Fixes adobe#9820
- Reset closedDueToEmptyControlled in revert() so Escape prevents
  re-opening when async items arrive after user dismissal
- Add test for Escape key scenario
- Add onOpenChange assertion for trigger reason on re-open
- Fix minor style: use direct variable instead of destructured object literal
@costajohnt costajohnt marked this pull request as ready for review March 22, 2026 03:40
@github-actions github-actions bot added the RAC label Mar 24, 2026
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)?

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

useComboBox + useAsyncList does not show results after a call that was after a call that returned an empty list

3 participants