Skip to content

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

Merged
merged 7 commits into from
Apr 10, 2025

Conversation

RobinMalfait
Copy link
Member

@RobinMalfait RobinMalfait commented Apr 10, 2025

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 the Listbox 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 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 option changes, only 3 things will happen:

  1. The ListboxOptions will re-render and have an updated aria-activedescendant
  2. The ListboxOption that was active, will re-render and the data-focus attribute wil be removed.
  3. The ListboxOption that is now active, will re-render and the data-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 ListboxOptions are registered.

On that note, we also batch the RegisterOption so we can perform a single update instead of n 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 a setTimeout or a requestAnimationFrame).

Test plan

  1. All tests still pass
  2. Tested this in the browser with a 2000 options. In the videos below the only thing I'm doing is holding down the ArrowDown key.

Before:

Screen.Recording.2025-04-10.at.20.22.52.mov

After:

Screen.Recording.2025-04-10.at.21.04.52.mov

Copy link

vercel bot commented Apr 10, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
headlessui-react ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 10, 2025 8:32pm
headlessui-vue ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 10, 2025 8:32pm

if (!data.value)
machine.actions.goToOption({
focus: Focus.First,
})
Copy link
Contributor

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

Copy link
Member Author

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,
Copy link
Contributor

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?

Copy link
Member Author

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

Base automatically changed from chore/prepare-performance-improvements to main April 10, 2025 20:26
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.
@RobinMalfait RobinMalfait merged commit 9685af7 into main Apr 10, 2025
8 checks passed
@RobinMalfait RobinMalfait deleted the perf/improve-listbox-performance branch April 10, 2025 20:43
RobinMalfait added a commit that referenced this pull request Apr 17, 2025
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
RobinMalfait added a commit that referenced this pull request Apr 17, 2025
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants