-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Improve Menu
component performance
#3685
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 ↗︎
|
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.
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) }), |
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 guess I don't know how these property declarations work b/c I would've sworn this wouldn't have had a this
lol
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.
Haha as long as you use arrows () => {}
then there is no this
, so the closest this
is going to be the class instance itself.
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.
Yeah that's what I meant — I didn't think it'd even pick up the class instance in this case 🤷♂️
f8230e6
to
068fca1
Compare
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.
aab7174
to
8b2403d
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
Relese 2.2.2 broke menus autoclosing for us in our navigation. I'm not 100% sure, it's this MR, as there's another one touching |
@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 |
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 theMenu
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 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 item changes, only 3 things will happen:
MenuItems
will re-render and have an updatedaria-activedescendant
MenuItem
that was active, will re-render and thedata-focus
attribute wil be removed.MenuItem
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 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 ofn
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 asetTimeout
or arequestAnimationFrame
).Test plan
ArrowDown
key.Before:
Screen.Recording.2025-04-08.at.16.55.46.mov
After:
Screen.Recording.2025-04-08.at.16.54.50.mov