Skip to content

Conversation

RobinMalfait
Copy link
Member

@RobinMalfait RobinMalfait commented 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 MenuItems 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:

Screen.Recording.2025-04-08.at.16.55.46.mov

After:

Screen.Recording.2025-04-08.at.16.54.50.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 ❌ Failed (Inspect) Apr 10, 2025 8:27pm
headlessui-vue ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 10, 2025 8:27pm

Copy link
Contributor

@thecrypticace thecrypticace left a comment

Choose a reason for hiding this comment

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

Looks good but I left some comments. I'm not sure if things need changing tho


return [
(id: string, dataRef: MenuItemDataRef) => items.push({ id, dataRef }),
() => this.send({ type: ActionTypes.RegisterItems, items: items.splice(0) }),
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess I don't know how these property declarations work b/c I would've sworn this wouldn't have had a this lol

Copy link
Member Author

Choose a reason for hiding this comment

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

Haha as long as you use arrows () => {} then there is no this, so the closest this is going to be the class instance itself.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah that's what I meant — I didn't think it'd even pick up the class instance in this case 🤷‍♂️

Base automatically changed from chore/prepare-performance-improvements to main April 10, 2025 20:26
This essentially replaces all the state, stored in the reducer in the
`Menu` component, with the state machine.

The `useSlice(machine, …)` calls will make sure to _only_ re-render the
current component if the state actually changes. Instead of always
re-rendering all components if _something_ changes.
Right now we are working in 2 steps:

1. Open the Menu (wrapped in a `flushSync`)
2. "wait" until all items are registered
3. Trigger a `GoToItem` to hopefully go to the correct item

This has some flaws because while it's guaranteed that due to the
`flushSync` everything is going to re-render. It's not guaranteed that
all `MenuItem`s will be registered on time, before the `GoToItem`
trigger.
This will batch the `RegisterItem` calls. Once all individual items are
registered, the `RegisterItems` event will be handled. When this gets
handled, we will also handel the `pendingFocus` to focus the right item
when handling this in one go.
This introduces `machine.selectors` to retrieve specific state. This
will give you a slice of the state. Only if this state changes will the
component re-render.
We will only sort the DOM nodes _if_ we need to. That is if items are
registered, DOM nodes could be in the wrong order.
This is necessary because reading the `machine.state` is instant,
compared to reading the state after a re-render in React.
Instead of relying on re-renders to get access to certain DOM nodes, we
can access them immediately if they are local.
@RobinMalfait RobinMalfait force-pushed the perf/improve-menu-performance branch from aab7174 to 8b2403d Compare April 10, 2025 20:26
@RobinMalfait RobinMalfait merged commit a293af9 into main Apr 10, 2025
2 of 4 checks passed
@RobinMalfait RobinMalfait deleted the perf/improve-menu-performance branch April 10, 2025 20:27
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
@razzeee
Copy link

razzeee commented Apr 22, 2025

Relese 2.2.2 broke menus autoclosing for us in our navigation.
(mentioned here https://headlessui.com/react/menu#closing-menus-manually)

I'm not 100% sure, it's this MR, as there's another one touching menu

@RobinMalfait
Copy link
Member Author

@razzeee can you open an issue with a minimal reproduction repo attached so we can take a look?

You might be able to start from this reproduction where it still works: https://stackblitz.com/edit/vitejs-vite-ghu71flh?file=src%2FApp.tsx

Another thing that did change is the fact that the Menu opens on mousedown instead of click when you press the MenuButton. Maybe that's somehow related as well.

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.

3 participants