Skip to content

Improve Combobox component performance #3697

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 12 commits into from
Apr 17, 2025

Conversation

RobinMalfait
Copy link
Member

@RobinMalfait RobinMalfait commented Apr 17, 2025

This PR improves the performance of the Combobox component. This is a similar implementation as:

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:

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:

Screen.Recording.2025-04-17.at.11.27.33.mov

After:

Screen.Recording.2025-04-17.at.11.28.14.mov

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:

Screen.Recording.2025-04-17.at.11.31.26.mov

After:

Screen.Recording.2025-04-17.at.11.32.01.mov

Inlines the single `isActive` call, and renames the `isActiveOption` to
`isActive` such that the API is similar to other components.

Unfortunately, due to the virtualization layer, not all `ComboboxOption`
IDs are known/available, so instead we also use the `value` itself to
lookup the active index by comparing the `value` values.
By bumping the versions to the same version, we can hoist the
`@tanstack/virtual-core` because it will be the same version now.
There is a timing issue if the Virtualizer is enabled and disabled in
rapid succession. This happens in our combobox when you see a list of
items and quickly search for something which in turn filters the list
and the options will become empty.

The `scrollToIndex` checks if an element's DOM node is available (via
the measurementsCache) but it also has an internal `setTimeout` where
the same code is used, but it's not re-checking the measurementsCache
cache.

The measurementsCache is also cleared the moment the `enabled` prop
becomes false. This means that there will be a split second where the
`measurementsCache[x]` will be `undefined` even though it was not
undefined moments ago.

In this scenario, it's safe to just not scroll (yet), but instead the
`@tanstack/virtual-core` is throwing an error via the `notUndefined`
function.

We unfortunately can't catch the error because it happens in a
`setTimeout`...

Instead of throwing, this patch ignores the result if it becomes
`undefined`. This also means that we don't scroll to the item, but there
is no item to scroll to in the first place.
This workaround worked by delaying the scroll and then the internal
`enabled` state in `@tanstack/virtual-core` was up to date. The problem
is that this causes flickering when holding `ArrowDown` for example.
Copy link

vercel bot commented Apr 17, 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 17, 2025 1:03pm
headlessui-vue ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 17, 2025 1:03pm

@RobinMalfait RobinMalfait marked this pull request as ready for review April 17, 2025 13:02
@RobinMalfait RobinMalfait enabled auto-merge (squash) April 17, 2025 13:02
@RobinMalfait RobinMalfait merged commit f93928a into main Apr 17, 2025
7 of 8 checks passed
@RobinMalfait RobinMalfait deleted the perf/improve-combobox-performance branch April 17, 2025 13:03
@RobinMalfait RobinMalfait restored the perf/improve-combobox-performance branch April 17, 2025 13:15
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.

1 participant