-
Notifications
You must be signed in to change notification settings - Fork 1.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Updating combobox input value when selected item loads or updates #1683
Conversation
I think this is probably easier. Also ran into difficulties this was affecting inputValue/defaultSelected key cases and how the combobox actually renders twice due to setMenuWidth
Build successful! 🎉 |
Build successful! 🎉 |
Build successful! 🎉 |
// In this case, it's the user's responsibility to update inputValue in onSelectionChange. In addition, we preserve the defaultInputValue | ||
// on initial render, even if it doesn't match the selected item's text. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
last bit of this comment was outdated since the reseting is done in the if statement immediately above now
(props.selectedKey !== undefined && props.inputValue !== undefined) || | ||
(props.inputValue !== undefined && props.isOpen !== undefined) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to the commitSelection
logic, but w/o the selectedKey + isOpen
controlled condition here since resetInputValue
should work just fine on its own and we don't need to explicitly call onSelectionChange
to close the menu IMO. Still mulling this over
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, should we handle this case at all or should we only handle when inputValue
is uncontrolled? I feel like maybe it's the user's responsibility to update inputValue
when they update items
if it is controlled.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about when someone uses useAsyncList
with the ComboBox? Would this be the expectation:
- they calculate the new
filterValue
from the fetched list of items inside theload
func that they provide to theuseAsyncList
hook - they call
list.setFilterText
or provide the adjustedfilterText
value throughload
's return object?
Just trying to feel out the mechanics of how the user would go about doing this. IMO I felt this was quite similar to our commitSelection
logic in that we reset the inputValue
(or call onSelectionChange instead, depending on what is controlled) for them even if inputValue
was controlled
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, feels like if you're controlling inputValue
you wouldn't necessarily want it to change, even when items
changes, and therefore it's your responsibility to update it in that case. Basically, whenever you control inputValue
you need to update it whenever anything changes that would affect it, and I think that's true in other cases (e.g. in onSelectionChange
). So yeah, they'd need to do it in the places you said.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So essentially the logic would become just:
if (props.inputValue === undefined) {
resetInputValue();
}
?
I guess this is fine as long as we call this out in the docs (aka if users are controlling items or providing a item map then they should be aware that inputValue and selectedKey text value could drift).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're mentioning updating the docs, but I don't see that change? Separate PR based on how docs deploy?
…ue reset this covers the scenario where items/defaultItems are both undef and the user provides items to the combobox via map
Build successful! 🎉 |
only resetting input value when collection changes for uncontrolled inputValue comboboxes now
Build successful! 🎉 |
This example uses the [useAsyncList](../react-stately/useAsyncList.html) hook to handle loading the data. | ||
See the docs for more information. | ||
This example uses the [useAsyncList](../react-stately/useAsyncList.html) hook to handle loading the data. See the docs for more information. | ||
**Note:** If you provide a `selectedKey` to the Combobox example below, it is your responsibility to update the input text when the `items` change or load. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe call out specifically that it's because inputValue
is controlled as well. Also you need a newline if you want this in a separate paragraph.
**Note:** If you provide a `selectedKey` to the Combobox example below, it is your responsibility to update the input text when the `items` change or load. | |
**Note:** When both `inputValue` and `selectedKey` are controlled, and the `selectedKey` is set to an initial value before the `items` load, you must update the `inputValue` when the `items` load. This can be done by returning `filterText` from the `useAsyncList` load function. |
Also I guess we should implement the ability to return filterText
from the load function.
Should we show this in the example as well or would that make things too complex?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also hmm... we could probably do this automatically in useAsyncList
? Like, we have the selectedKey
, filterText
, and items
already. 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've put a first stab at doing the above in 67b8a93, is this kinda what you were envisioning?
As for adding it to the example, I can go ahead and add it a separate one, feels like a use case some people will have
@@ -182,6 +187,15 @@ function reducer<T, C>(data: AsyncListState<T, C>, action: Action<T, C>): AsyncL | |||
items: action.type === 'loading' ? [] : data.items, | |||
abortController: action.abortController | |||
}; | |||
case 'update': |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added update
case handling now that selectedKeys/setSelectedKeys
from useAsyncList
is being used instead of a user defined selectedKey
tracker
Build successful! 🎉 |
case 'loadingMore': | ||
// If already loading more and another loading more is triggered, abort the new load more. | ||
// Do not overwrite the data.abortController | ||
action.abortController.abort(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How would this case occur? Don't we prevent it here?
react-spectrum/packages/@react-stately/data/src/useAsyncList.ts
Lines 327 to 329 in 48c95b9
if (data.state === 'loadingMore' || data.state === 'filtering' || data.cursor == null) { | |
return; | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That is what I thought at first, but if loadMore
is called multiple times in quick succession it is apparently possible that data.state
doesn't update in time. I only noticed this in the async combobox story where Virtualizer occasionally called loadMore
multiple times when the menu and break cuz loadingMore wasn't a valid action when state was loadingMore
I added a test specifically for this here:
react-spectrum/packages/@react-stately/data/test/useAsyncList.test.js
Lines 746 to 777 in 48c95b9
it('should handle multiple loadMore called in quick succession', async () => { | |
let load = jest.fn() | |
.mockImplementationOnce(getItems) | |
.mockImplementationOnce(getItems2) | |
.mockImplementationOnce(getItems); | |
let {result} = renderHook( | |
() => useAsyncList({load}) | |
); | |
expect(load).toHaveBeenCalledTimes(1); | |
expect(result.current.isLoading).toBe(true); | |
expect(result.current.items).toEqual([]); | |
await act(async () => { | |
jest.runAllTimers(); | |
}); | |
expect(load).toHaveBeenCalledTimes(1); | |
expect(result.current.isLoading).toBe(false); | |
expect(result.current.items).toEqual(ITEMS); | |
await act(async () => { | |
result.current.loadMore(); | |
result.current.loadMore(); | |
jest.runAllTimers(); | |
}); | |
// Only original load more is handled, the other is canceled | |
expect(load).toHaveBeenCalledTimes(3); | |
expect(result.current.isLoading).toBe(false); | |
expect(result.current.items).toEqual(ITEMS.concat(ITEMS2)); | |
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you reset this branch to 121f4bc
(aka before I made these changes) and reuse the above test with a try catch around the double .loadMore
calls you can see the error Invalid action "loadingMore" in state "loadingMore"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh that makes sense. Ideally we wouldn't start a request only to immediately abort it though. One option would be to track the loading state in a ref so we don't have to wait for render to get the updated value. Another option would be to only trigger the request from within reduce
, but this may delay the start for longer than necessary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
kk, implemented this kinda. Added a separate loadingState
ref that gets updated in dispatchFetch
but preserved data.state
. Was the intention here to completely remove data.state
and replace it with the loadingState
ref?
@@ -199,6 +209,10 @@ function reducer<T, C>(data: AsyncListState<T, C>, action: Action<T, C>): AsyncL | |||
cursor: action.cursor | |||
}; | |||
case 'error': | |||
if (action.abortController !== data.abortController) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What was this case for?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Paired with the added case below handling loadingMore
if loadingMore
is already happening. The change below will abort the newer (and extraneous) loadingMore
actions which as a result will dispatch an error for the newer loadingMore
actions. This case will then be triggered and preserve the current data state (i.e. we don't want to change the state to 'error' just cuz we canceled some extraneous loadMore calls)
Build successful! 🎉 |
|
||
```tsx example | ||
function AsyncLoadingExample() { | ||
let [isFocused, setFocused] = React.useState(false); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This doesn't affect rendering so could be a ref to avoid extra re-renders
<ComboBox | ||
label="Star Wars Character Lookup" | ||
onFocus={() => setFocused(true)} | ||
onBlur={() => setFocused(false)} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
onFocusChange
might be a bit simpler
|
||
// If selectedKey exists and combobox isn't focused, update the input value with the selected key text | ||
// This allows the input value to be up to date when items load for the first time or the selected key text is updated server side. | ||
if (!isFocused && selectedKey) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A little unfortunate that the user needs to track isFocused
themselves for this. Would it update on blur though? I guess not because it's controlled? Should it perhaps be based on the loadingState
(so loadingState === 'loading'
) instead? If the user typed in the input before the initial load completed, the initial load would get aborted anyway.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, it does update on blur in this case since onSelectionChange
is called on blur in useComboBoxState
since this is a multiple controlled prop combobox. Since onSelectionChange
in this example is:
let onSelectionChange = (key) => {
let itemText = list.getItem(key)?.name;
list.setSelectedKeys(new Set([key]));
list.setFilterText(itemText);
};
the combobox value will be updated to "Luke Skywalker" on blur. I can try changing it to be based on loadingState
, I guess I'll add it to one of the params load
accepts in useAsyncList
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you think that makes sense? I guess the only case where it wouldn't work would be if the user focused the input before the items loaded but didn't type anything? But maybe that's ok? Just trying to think which is easier to explain/document.
Build successful! 🎉 |
Build successful! 🎉 |
name: string, | ||
url: string | ||
} | ||
let isFocused = useRef(false); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will use this story or revert to using the loadingState
in load
depending on what we feel is the most appropriate
if (action.type !== 'success' && action.type !== 'update') { | ||
loadingState.current = action.type; | ||
} else { | ||
loadingState.current = 'idle'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
opinions on this? Not sure a case exists where dispatchFetch is called w/ action=success/update
but I needed a state to be set if there is hence the idle
but that doesn't feel quite right... I could also modify dispatch in such a way that loadingState ref is modified in the reducer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm I don't think a case exists where action.type would be success or update calling dispatchFetch.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah, figured as much. Maybe I'll just delete the else part
Build successful! 🎉 |
Build successful! 🎉 |
// Fetch a new filtered list if filterText is updated via `load` response func rather than list.setFilterText | ||
// Only do this if not aborted (e.g. user triggers another filter action before load completes) | ||
if (filterText && (filterText !== previousFilterText) && !abortController.signal.aborted) { | ||
loadingState.current = 'filtering'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should be set inside dispatchFetch
already right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bleh, was too broad with my search on "dispatch", good catch
Build successful! 🎉 |
Build successful! 🎉 |
Closes #1645
✅ Pull Request Checklist:
📝 Test Instructions:
🧢 Your Project: