-
-
Notifications
You must be signed in to change notification settings - Fork 32.5k
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
[Select][base] Prevent unnecessary rerendering of Select options #35946
[Select][base] Prevent unnecessary rerendering of Select options #35946
Conversation
+ fix all the TS errors
Netlify deploy previewhttps://deploy-preview-35946--material-ui.netlify.app/ @material-ui/unstyled: parsed: +2.25% , gzip: +2.49% Bundle size report |
Did we consider
Did you measure differences with this? I'm asking because I believe the default outcome with them is more CPU cycles (it takes more time to compare than to recreate the functions). However, it does make a difference when it prunes large branches of work (like not rendering 10 children).
|
No, but in this case, I don't think it would make a difference. The problem was rerendering all options when hover moved from one to another.
Do you have any resources on this? I wonder how can comparing two references be slower than recreating a function. Anyway, in this case, recreating functions that are placed in a context causes a rerender of all listeners of the context (all options), which is definitely slower.
It could be perceived as that because of the transition of the background color. I disabled it so the difference is more noticable. |
@michaldudak Ok, it might have been because of bugs with SVGs or some element that wrongly trigger the mousenter event. One thing I struggle to understand is the use case: why does the select need to know the hovered option? The Select in Material UI doesn't listen to it, the Autocomplete does but I think that it's a mistake I did per #23916. I shouldn't have mixed the hover and keyboard focus states, it breaks the UX and creates bugs like #20602.
The why: https://kentcdodds.com/blog/usememo-and-usecallback. It boils down to: useCallback is extra CPU work, the runtime still needs to recreate the function at each render.
Oh the transition, yes! @siriwatknp How about we remove all these transitions on hover (here and in other components when the delay to feedback is important)? It doesn't seem to help and doing a benchmark with other libraries, I can't find selects that have it that still feel great to use. It feels unresponsive (slow). Off-topic. It looks like we have a bug with the scroll positioning when opening the select:
It seems to be that the select is going beyond the viewport, we likely miss a max height of 100vh. |
This was a tough decision to make. Native
We could leave the "out" transition, but yes, I'd remove the "in" one.
Thanks for pointing this out. The Select demo needs a max-height. The Joy Select may as well.
But thanks to this, we can prevent thousands of renders. I agree that, in some cases, the performance gain is not noticeable. Here, it definitely is. I'm not using useCallback here to minimize the number of function allocations but to prevent unnecessary rerenders. |
@michaldudak Ah ok, looks great 👍
To experiment, I would expect no transition to feel better for hover. However, an "out" transition and no "in" sounds like a great idea to experiment with for the autocomplete, select, and date picker popups! Right now the autocomplete popup has no transitions, which I think feels great, but an out transition could feel even better. The date picker has an "in" transition which makes it feel too slow to be great (point 12 of mui/mui-x#7440): cc @gerdadesign. |
Agree, #35952 will take care of that. |
@michaldudak Maybe this is off-topic but I wonder if the Without the |
From Joy's perspective, this is a huge improvement! 👏 Great job @michaldudak. |
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.
Small nits here and there, apart from that I can't sport anything else. Great work @michaldudak!
const { children, value, className } = props; | ||
const { getRootProps, highlighted } = useOption({ | ||
value, | ||
disabled: false, |
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 would do either:
- Not add this at all
disabled: false, |
- Use the value from the props (we can initialize it above to false):
disabled: false, | |
disabled, |
This is closer to what developers would actually need.
@@ -47,6 +47,7 @@ const parseFile = (filename: string) => { | |||
shouldSkip: | |||
filename.indexOf('internal') !== -1 || | |||
!!src.match(/@ignore - internal component\./) || | |||
!!src.match(/@ignore - internal hook\./) || |
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.
👍
@@ -53,25 +54,28 @@ describe('useControllableReducer', () => { | |||
}); | |||
|
|||
const actionToDispatch = { type: ActionTypes.setValue as const, value: 'b', event: null }; | |||
const props: UseListboxPropsWithDefaults<string> = { |
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.
Can we extract this above and define it only once?
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 did this to make the tests more self-sufficient (it's easier to read when you have everything nearby). I don't think repetition is a problem is tests.
const optionState = getOptionState(option); | ||
const index = options.findIndex((opt) => optionComparer(opt, option)); |
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.
const optionState = getOptionState(option); | |
const index = options.findIndex((opt) => optionComparer(opt, option)); | |
const optionState = getOptionState(option); | |
const { index } = optionState; |
nit: we already are finding the index in getOptionState, we could return it as part of the state. Not sure if it's worth the change
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.
Good point! This could make it even faster a bit.
I'll analyze it and create a separate PR. |
I think some more breaking change was introduced in this release. Without changing the options array, the onChange event handler now gets object instead of string, and also there's a type mismatch there. Also highlighted state from optionState doesn't seem to be set correctly anymore. https://codesandbox.io/s/heuristic-hypatia-brfyof?file=/demo.tsx |
@smellai Could you please open a new issue so we can track this problem? |
Hi, starting with Joy 5.0.0-alpha.76 (.75 is working fine, .76+ are broken), Select > Options > text changes will not update the Select component. The following code creates the options: <Select ...>
{items.map((item: {key: string, label: string}, idx: number) => (
<Option key={'opt-' + idx} value={item.key}>
{symbol ? '-' : ''} {item.label}
</Option>
))}
</Select> When toggling The current workaround is to fix the version of Joy to alpha-75. |
Thanks for reporting the issue! Can you open a new issue? |
This PR solves a performance issue with SelectUnstyled and dependent components (Joy's Select).
The root of the problem was that every
mouseover
event fired on an option caused rerendering of all options. The event correctly dispatches theoptionHover
action in the listbox reducer, but the surrounding logic caused some functions to be recreated, which caused the SelectUnstyledContext to be updated. This, in turn, caused all the options to detect the changes and rerender themselves.A couple of optimizations were made:
useCallback
The introduction of the lightweight event bus was necessary to update only the options that were changed. The Material UI implementation doesn't have this problem, as it uses
cloneElement
, so that the Select can set props directly on its MenuItems. This, however, can cause issues when grouping is used and prevents from using non-MenuItem components (or even Fragments) as Select's children. The discussion about removing cloneElement usage took place in #14943.With these changes, the Option component has more to do. To prevent developers from having to reimplement this logic in their own design systems, the
useOption
hook was added.Additionally, I refactored the code in a few places.
Breaking change
This PR changes what the
useSelect
anduseMenu
hooks return.useSelect
The
getOptionProps
andgetOptionState
functions that were returned by useSelect are now members of thecontextValue
object returned by the hook.The whole
contextValue
object is intended to be passed into the SelectUnstyledContext.Provider that wraps all options.useMenu
The
getItemProps
,getItemState
,registerItem
, andunregisterItem
functions that were returned by useMenu are now members of thecontextValue
object returned by the hook.The whole
contextValue
object is intended to be passed into the MenuUnstyledContext.Provider that wraps all items.Before: https://codesandbox.io/s/practical-oskar-ibmwvn?file=/demo.js
After: https://codesandbox.io/s/gifted-antonelli-srtk0u?file=/demo.js
This fixes #34112.