-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Improve Listbox
component performance
#3688
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
Conversation
The latest updates on your projects. Learn more about Vercel for Git ↗︎
|
if (!data.value) | ||
machine.actions.goToOption({ | ||
focus: Focus.First, | ||
}) |
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.
Now that this wraps I'd probably add braces
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 think I can make it not wrap. Good eye!
listboxState, | ||
activationTrigger, | ||
/* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ | ||
activeOptionIndex, |
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 this be abstracted into a useSlice for shouldScrollIntoView like we did with Menu or nah?
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.
Yep, that happens in a later commit: 4ee5b37
This is a direct port without any optimizations at all. So this will still be slow because it fetches the activeOptionIndex for _every_ option for example.
dc94f70
to
f1ed63a
Compare
This PR improves the performance of the `Combobox` component. This is a similar implementation as: - #3685 - #3688 Before this PR, the `Combobox` component is built in a way where all the state lives in the `Combobox` itself. If state changes, everything re-renders and re-computes the necessary derived state. However, if you have a 1000 items, then every time the active item changes, all 1000 items have to re-render. To solve this, we can move the state outside of the `Combobox` component, and "subscribe" to state changes using the `useSlice` hook introduced in #3684. This will allow us to subscribe to a slice of the state, and only re-render if the computed slice actually changes. If the active item changes, only 3 things will happen: 1. The `ComboboxOptions` will re-render and have an updated `aria-activedescendant` 2. The `ComboboxOption` that _was_ active, will re-render and the `data-focus` attribute wil be removed. 3. The `ComboboxOption` that is now active, will re-render and the `data-focus` attribute wil be added. The `Combobox` component already has a `virtual` option if you want to render many many more items. This is a bit of a different model where all the options are passed in via an array instead of rendering all `ComboboxOption` components immediately. Because of this, I didn't want to batch the registration of the options as part of this PR (similar to what we do in the `Menu` and `Listbox`) because it behaves differently compared to what mode you are using (virtual or not). Since not all components will be rendered, batching the registration until everything is registered doesn't really make sense in the general case. However, it does make sense in non-virtual mode. But because of this difference, I didn't want to implement this as part of this PR and increase the complexity of the PR even more. Instead I will follow up with more PRs with more improvements. But the key improvement of looking at the slice of the data is what makes the biggest impact. This also means that we can do another release once this is merged. Last but not least, recently we fixed a bug where the `Combobox` in `virtual` mode could crash if you search for an item that doesn't exist. To solve it, we implemented a workaround in: - #3678 Which used a double `requestAnimationFrame` to delay the scrolling to the item. While this solved this issue, this also caused visual flicker when holding down your arrow keys. I also fixed it in this PR by introducing `patch-package` and work around the issue in the `@tanstack/virtual-core` package itself. More info: 96f4da7 Before: https://github.com/user-attachments/assets/132520d3-b4d6-42f9-9152-57427de20639 After: https://github.com/user-attachments/assets/41f198fe-9326-42d1-a09f-077b60a9f65d ## Test plan 1. All tests still pass 2. Tested this in the browser with a 1000 items. In the videos below the only thing I'm doing is holding down the `ArrowDown` key. Before: https://github.com/user-attachments/assets/945692a3-96e6-4ac7-bee0-36a1fd89172b After: https://github.com/user-attachments/assets/98a151d0-16cc-4823-811c-fcee0019937a
This PR improves the performance of the `Combobox` component. This is a similar implementation as: - #3685 - #3688 Before this PR, the `Combobox` component is built in a way where all the state lives in the `Combobox` itself. If state changes, everything re-renders and re-computes the necessary derived state. However, if you have a 1000 items, then every time the active item changes, all 1000 items have to re-render. To solve this, we can move the state outside of the `Combobox` component, and "subscribe" to state changes using the `useSlice` hook introduced in #3684. This will allow us to subscribe to a slice of the state, and only re-render if the computed slice actually changes. If the active item changes, only 3 things will happen: 1. The `ComboboxOptions` will re-render and have an updated `aria-activedescendant` 2. The `ComboboxOption` that _was_ active, will re-render and the `data-focus` attribute wil be removed. 3. The `ComboboxOption` that is now active, will re-render and the `data-focus` attribute wil be added. The `Combobox` component already has a `virtual` option if you want to render many many more items. This is a bit of a different model where all the options are passed in via an array instead of rendering all `ComboboxOption` components immediately. Because of this, I didn't want to batch the registration of the options as part of this PR (similar to what we do in the `Menu` and `Listbox`) because it behaves differently compared to what mode you are using (virtual or not). Since not all components will be rendered, batching the registration until everything is registered doesn't really make sense in the general case. However, it does make sense in non-virtual mode. But because of this difference, I didn't want to implement this as part of this PR and increase the complexity of the PR even more. Instead I will follow up with more PRs with more improvements. But the key improvement of looking at the slice of the data is what makes the biggest impact. This also means that we can do another release once this is merged. Last but not least, recently we fixed a bug where the `Combobox` in `virtual` mode could crash if you search for an item that doesn't exist. To solve it, we implemented a workaround in: - #3678 Which used a double `requestAnimationFrame` to delay the scrolling to the item. While this solved this issue, this also caused visual flicker when holding down your arrow keys. I also fixed it in this PR by introducing `patch-package` and work around the issue in the `@tanstack/virtual-core` package itself. More info: 96f4da7 Before: https://github.com/user-attachments/assets/132520d3-b4d6-42f9-9152-57427de20639 After: https://github.com/user-attachments/assets/41f198fe-9326-42d1-a09f-077b60a9f65d ## Test plan 1. All tests still pass 2. Tested this in the browser with a 1000 items. In the videos below the only thing I'm doing is holding down the `ArrowDown` key. Before: https://github.com/user-attachments/assets/945692a3-96e6-4ac7-bee0-36a1fd89172b After: https://github.com/user-attachments/assets/98a151d0-16cc-4823-811c-fcee0019937a
This PR improves the performance of the
Listbox
component.Before this PR, the
Listbox
component is built in a way where all the state lives in theListbox
itself. If state changes, everything re-renders and re-computes the necessary derived state.However, if you have a 1000 options, then every time the active option changes, all 1000 options have to re-render.
To solve this, we can move the state outside of the
Listbox
component, and "subscribe" to state changes using theuseSlice
hook introduced in #3684.This will allow us to subscribe to a slice of the state, and only re-render if the computed slice actually changes.
If the active option changes, only 3 things will happen:
ListboxOptions
will re-render and have an updatedaria-activedescendant
ListboxOption
that was active, will re-render and thedata-focus
attribute wil be removed.ListboxOption
that is now active, will re-render and thedata-focus
attribute wil be added.Another improvement is that in order to make sure that your arrow keys go to the correct option, we need to sort the DOM nodes and make sure that we go to the correct option when using arrow up and down. This sorting was happening every time a new
ListboxOption
was registered.Luckily, once an array is sorted, you don't have to do a lot, but you still have to loop over
n
options which is not ideal.This PR will now delay the sorting until all
ListboxOption
s are registered.On that note, we also batch the
RegisterOption
so we can perform a single update instead ofn
updates. We use a microTask for the batching (so if you only are registering a single option, you don't have to wait compared to asetTimeout
or arequestAnimationFrame
).Test plan
ArrowDown
key.Before:
Screen.Recording.2025-04-10.at.20.22.52.mov
After:
Screen.Recording.2025-04-10.at.21.04.52.mov