-
-
Notifications
You must be signed in to change notification settings - Fork 4.5k
perf(compact-select): render options lazily #103811
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
base: master
Are you sure you want to change the base?
Conversation
we no longer do trigger label updating
where auto focussing still works
as all selects need to be fully controlled
as it's no longer needed
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #103811 +/- ##
===========================================
- Coverage 80.61% 80.61% -0.01%
===========================================
Files 9284 9284
Lines 396288 396266 -22
Branches 25261 25261
===========================================
- Hits 319463 319442 -21
+ Misses 76365 76364 -1
Partials 460 460 |
| expect(dropdownOptionLabels[1]).toHaveTextContent('MongoDB'); | ||
|
|
||
| await userEvent.click(dropdownOptionLabels[0]!); | ||
| expect(dropdownSelector).toHaveTextContent('SystemPostgreSQL'); |
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.
note: for this test, the useLocalStorage hook is mocked so we don’t really have state management that would make sure the text updates. We previously relied on the value updating automatically from within. Now, we need state.
| await userEvent.click((await screen.findAllByTestId('edit-access-dropdown'))[0]!); | ||
| await userEvent.click(screen.getByText('All')); | ||
| expect(screen.getAllByPlaceholderText('Search Teams')[0]).toBeInTheDocument(); |
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.
this test wasn’t really opening anything, but because all the search fields were rendered invisibly, the toBeInTheDocument succeeded anyway 😅
| await userEvent.click(screen.getByRole('button', {name: 'Column project string'})); | ||
|
|
||
| const projectColumn = screen.getAllByTestId('editor-column')[1]!; | ||
|
|
||
| await userEvent.click( | ||
| within(projectColumn).getByRole('button', {name: 'Column project 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.
tbh, I’m not really sure why I had to change all these tests. I got a message that buttons with that name exist more than once, and from what I could tell, the whole row is a button with the same accessibility label. But I’m not sure what my PR changed to surface this or how this could’ve worked on master.
My best guess is that it worked coincidentally, I’m now just narrowing the scope of the selector to make the tests pass again. It’s hard to investigate if you don’t get to physically “see” what gets rendered though...
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.
Did logging the buttons give any clues?
| * A list of selected options across all select regions, to be used to generate the | ||
| * trigger label. | ||
| */ | ||
| const [selectedOptions, setSelectedOptions] = useState< | ||
| Array<SelectOption<SelectKey> | Array<SelectOption<SelectKey>>> | ||
| >([]); | ||
| const saveSelectedOptions = useCallback<SelectContextValue['saveSelectedOptions']>( | ||
| (index, newSelectedOptions) => { | ||
| setSelectedOptions(current => [ | ||
| ...current.slice(0, index), | ||
| newSelectedOptions, | ||
| ...current.slice(index + 1), | ||
| ]); | ||
| }, | ||
| [] | ||
| ); |
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.
no more internal state that needs to be synced just to calculate a trigger label
| )} | ||
| </FocusScope> | ||
| </StyledOverlay> | ||
| {overlayIsOpen && ( |
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.
this is where the perf improvement comes from 😅
| // Register the initialized list state once on mount | ||
| useLayoutEffect(() => { | ||
| saveSelectedOptions( | ||
| compositeIndex, | ||
| getSelectedOptions(items, listState.selectionManager.selectedKeys) | ||
| ); | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [listState.collection]); | ||
|
|
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.
yay
JonasBa
left a comment
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.
Beautiful work 🙏 I only left a few minor comments that are non blocking
|
|
||
| export interface ControlProps | ||
| extends Omit< | ||
| React.BaseHTMLAttributes<HTMLDivElement>, |
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 have never heard of basehtmlattributes
| return ( | ||
| <Section key={item.key} title={item.label}> | ||
| {item.options.map(opt => ( | ||
| {item.options?.map(opt => ( |
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 expect the check above to narrow it down to Array, is this an expected 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.
I think that's expected as items support an explicit undefined now?
| ...wrapperProps | ||
| }: ControlProps) { | ||
| }: ControlProps & { | ||
| items?: Array<SelectOptionOrSection<SelectKey>>; |
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.
Was items always optional?
| menuBody, | ||
| menuFooter, | ||
| onOpenChange, | ||
| items = [], |
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.
This is not a big deal, but when values are not provided, these default initializers will cause react components to inefficiently rerender as each rerender will initialize to a fresh reference.
I think it's a bit of an anti pattern in react and should be avoided if possible (not necessarily for performance, but maybe just as a guide to try and keep the APIs more explicit)
| /** | ||
| * Clears selection values | ||
| */ | ||
| const clearSelection = () => { |
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.
🤮
| await userEvent.click(screen.getByRole('button', {name: 'Column project string'})); | ||
|
|
||
| const projectColumn = screen.getAllByTestId('editor-column')[1]!; | ||
|
|
||
| await userEvent.click( | ||
| within(projectColumn).getByRole('button', {name: 'Column project 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.
Did logging the buttons give any clues?
| return ( | ||
| <Section key={item.key} title={item.label}> | ||
| {item.options.map(opt => ( | ||
| {item.options?.map(opt => ( |
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 think that's expected as items support an explicit undefined now?
| const selectedOptions = Array.isArray(value) | ||
| ? value | ||
| : value === undefined | ||
| ? [] | ||
| : [value]; | ||
| return selectedOptions.flat().length > 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.
Not sure why we need the array.flat() check here?
| const selectedOptions = Array.isArray(value) | |
| ? value | |
| : value === undefined | |
| ? [] | |
| : [value]; | |
| return selectedOptions.flat().length > 0; | |
| if (value === undefined) return false; | |
| if (Array.isArray(value)) return value.length > 0; | |
| return true; |
|
Just wanted to chime in with some gratitude on this one, we have a lot of slow |
This PR generally aims to improve performance of the
compactSelectcomponent by not rendering all options eagerly. This is achieved with a simple conditional rendering, however, some pre-requisites are required to get that going.Good question! We did that because the
compactSelectcomponent internally holds a<List>, which managed its state internally. That state is (was) bubbled up to the parent (with some effects, inverting the parent-child data flow into child-parent), so that the parent could create thelabelof the select trigger. For that, it needed to render all children, otherwise the bubble-up didn’t work.This just shows why child->parent dataflows are bad design. We need to render e.g. 1000 options just to get to the correct label of a button. It’s not react. Furthermore, the whole internal state of
<List>is unnecessary, because we use the component in a fully controlled way exclusively. The parent has the state already! Another reminder that state duplication & syncing is the root of all evil.So now, we’re using the parent state to calculate that and remove the internal state. That means we can also conditionally render options only when the select is opened. Getting there was painful:
First, we needed to make sure that we disallow uncontrolled usage of this component:
And make it required that we pass
valueandonChange:Then, there’s
CompositeSelect, which shares some internals withCompactSelect, however for those, the parent doesn’t pass the full state toCompositeSelectitself because it’s built up ofRegions. The good news is that we never use the default trigger label, so we don’t need to calculate it based on the current selection. To make sure we don’t do this in the future,CompositeSelectnow has a required trigger:Lastly, there’s
clearable. TheClearbutton appears outside ofList, so it doesn’t have all the information necessary to perform a clear if we remove the reverse data flow. I changed howclearworks by just triggering anonChangewith an empty value. Also, types were a mess and that’s now fixed:With that, I could finally remove the internal
useLayoutEffectthat synced state from theListchild with theControlparent.Test Changes
Then, many tests failed. Note that I checked all failing tests in the product before I changed them, and they all work fine. A couple of thoughts on why the tests failed:
onChange. Now, withoutonChange, there is no state change happening. I’ve changed some tests to do some basicuseStatearound the component (like we do in the product, too).