useComboBox not syncing state correctly and incorrectly selects current selection on tab out of popup
- Dominant language
- TypeScript
- Stars
- 15.9k
- Forks
- 1.6k
- Avg merge
- 3d 5h
- Merged PRs (30d)
- 58
Description
# 🐛 Bug Report
The state manager does not immediately show results via popup when a network request returns a response. At first I thought this was a bug related to me applying conditional logic with the useAsyncList load function, whereby a fetch should only be done after a certain amount of characters have been input. This predicate had to be done within the async load function because i needed access to the filterText, trying to hold the text in external state and manage that and the useAsyncState quickly turned into a hot mess of useCallbacks and infinite loops. However im finding the same issue present using react-query.
Extending on this, when a list does appear (the state seems to be one step behind the render itself - a list is loaded into memory on first fetch, nothing appears, type another character, and suddenly the popup appears), when navigating the list using the up and down arrows, the focused element should not be 'selected' when attempting to tab out of the field.
## 🤔 Expected Behavior
I should be able to apply a predicate within any async function generating a response for the useComboBox (and by extension its state hook) and it should appear on first instance of an iterable list. When navigating said list via up and down arrows, tabbing should not 'select' the current focused element, it should just exit and clear the state.
## 😯 Current Behavior
If the component with the useComboBox does not render an initial list, when it does receive a list, it does not immediately render it. Follow up filterTexts will make it render however, but not on the initial passing of whatever predicate prevented fetching in the first place. also in this instance, tabbing away from a focused element selects it -creating a redundant network request (it clearly shows which item i wish to select in the dropdown, any action moving forward should be whatever action i assign the list item, in this case, a redirect). So at present when the item i wish to redirect too is available in the search list, i have to currently click it, which performs another search request returning just that particular item, and then another followup click to finally redirect.
## 🔦 Context
Im trying to create an asynchronous search field that functions in much the same way user experience wise as the amazon search field, for context. it provides a dropdown of results, navigable with up and down arrows, tabbing doesnt mistakenly select one and pressing space or enter automatically navigates to that page and clears the state of the field on redirect. With that out of the way...
## 💻 Code Sample
This link is almost exactly the same as @devongovett's example of an async autocomplete. The only thing ive changed is added in a predicate within the async load function. All the above concerns are demonstratable.
[example code](https://codesandbox.io/s/musing-sound-vix0w9?file=/src/App.tsx)
1. type in 'a'
2. predicate is passed (greater than or equal to 1 character) and a request is made, however no popup shows with results
3. type in "r" and popup with names finally shows, despite predicate passing and results recieved in the previous render
4. using up down arrows, focus on any item and press tab, instead of closing and clearing the field and its state, the focused item is incorrectly selected, however the escape key seems to work in in this instance.
## Solution (edit)
At present this is the best i can come up with to get halfway there as far as the correct behavior i was aiming for. It ticks the box for tabbing, predicate fetching as well as debouncing of the search term. however again, no popup appears on initial render, only on subsequent inputs. Everything else from the code sandbox is pretty well identical. This code is a cut and paste straight from my current work task.
```typescript
export function FleetSearch(props: FleetSearchProps) {
const navigate = useNavigate();
const allowRefetch = useRef(true);
const [term, setTerm] = useState("");
const [items, setItems] = useState([]);
const debouncedTerm = useDebounce(term);
// Mutable flag to enforce no fetching on selection action
useEffect(() => {
allowRefetch.current = true;
}, [term]);
/**
* Had unexpected results passing data straight from use query to the NewSearchField.
* Seems to work appropriately when passed to local state and referenced from there.
*
* Note the predicate, preventing further requests on debounced term length and refetch flag.
*/
const { fetchStatus } = useQuery({
queryKey: ["fleetSearch", debouncedTerm],
queryFn: async ({ signal }) => {
// executes a TaskEither, which resolves to a promise.
return promisifyTE(props.onSearch({ term: debouncedTerm, signal }));
},
onSuccess: (response) => setItems(response.results),
enabled: debouncedTerm.length >= 2 && allowRefetch.current,
retry: false,
});
/**
* Because this field is present for the entire lifecycle of the application, onBlur and
* onSelection should both finish with clearance of any state.
*/
const clear = useCallback(() => {
setTerm("");
setItems([]);
}, []);
/**
* Override default selection behavior to prevent redundant refetch and derive redirect url
* from the selected items props.
*/
const handleSelection = useCallback(
(key: Key) => {
const result = items.find((item) => item.resourceId.toString() === key);
if (result) {
const redirectURL = `/${result.resourceType}/${result.resourceId}`;
navigate(redirectURL);
clear()
}
allowRefetch.current = false;
},
[clear, items, navigate],
);
return (
/**
* Everything within here is identical to codesandbox link. FleetSearchItem
* is a simple container wrapper, nothing more.
*/
{(item) => (
)}
);
}
```
## 🌍 Your Environment
| Software | Version(s) |
| ---------------- | ---------- |
| react-aria | 3.22.0
| react-stately | 3.20.0
| Browser | Chrome@^109
| VSCode | v1.75
| Node | 16.14.2
| Operating System | Windows 11
Contributor guide
Research direction
Start by reproducing the linked CodeSandbox with useComboBox, useAsyncList, and the predicate that delays fetching, then compare the initial response with the later filterText update. Check the selection behavior when moving through the popup with the arrow keys and pressing Tab; done means the first response renders immediately and Tab closes without selecting the focused item.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- accessibility, frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100