Skip to content

Prepare for performance improvements #3684

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 3 commits into from
Apr 10, 2025

Conversation

RobinMalfait
Copy link
Member

This PR is just a chore to prepare for future performance optimizations. Essentially I want to improve the performance of the Menu, Listbox and Combobox components but I want to do it in separate PRs such that reverting the improvements can be done if needed.

This PR just sets up a Machine for state machines, and adds some helpers such as a useSlice to calculate parts of the state machine. Component using the useSlice will only re-render if the slice changes.

So apart from adding a library (useSyncExternalStoreWithSelector) and adding some setup code. Nothing in this PR changes the behavior of the components.

This helps with a development build of React for example in the browser
instead of minified React.
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 7:47pm
headlessui-vue ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 10, 2025 7:47pm

// Assumption: everything else is considered equal
// TODO: Add more specific checks for other types when needed such as Map,
// Set, ...
return true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like defaulting to true here could lead to some subtle bugs. I think defaulting to false unless you can prove a and b are equal is a safer choice.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aside: you could probably check for [Symbol.iterator] and iterate both as the base for an equality check on Sets, Maps, etc…

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah fair, and for objects I could just use Object.entries(obj) 🤔

Also handles Map, Set, ...
@RobinMalfait RobinMalfait merged commit e2a6376 into main Apr 10, 2025
8 checks passed
@RobinMalfait RobinMalfait deleted the chore/prepare-performance-improvements branch April 10, 2025 20:26
RobinMalfait added a commit that referenced this pull request Apr 10, 2025
This PR improves the performance of the `Menu` component.

Before this PR, the `Menu` component is built in a way where all the
state lives in the `Menu` 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 `Menu` 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 `MenuItems` will re-render and have an updated
`aria-activedescendant`
2. The `MenuItem` that _was_ active, will re-render and the `data-focus`
attribute wil be removed.
3. The `MenuItem` 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 item, we need to sort the DOM nodes and make sure that
we go to the correct item when using arrow up and down. This sorting was
happening every time a new `MenuItem` was registered.

Luckily, once an array is sorted, you don't have to do a lot, but you
still have to loop over `n` items which is not ideal.

This PR will now delay the sorting until all `MenuItem`s are registered.

On that note, we also batch the `RegisterItem` 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 item, 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 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/513b02c1-fc69-47f3-a97e-c56d44dd585a

After:


https://github.com/user-attachments/assets/266236a0-b64a-4322-9a54-ead7fb62191f
RobinMalfait added a commit that referenced this pull request 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 `ListboxOption`s 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:


https://github.com/user-attachments/assets/a2850c84-57f6-428a-aa51-e6f83d2aee97

After:


https://github.com/user-attachments/assets/157c6e99-5da8-4d72-87c6-a5e34f122531
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