Skip to content

Conversation

@TkDodo
Copy link
Collaborator

@TkDodo TkDodo commented Nov 21, 2025

This PR generally aims to improve performance of the compactSelect component by not rendering all options eagerly. This is achieved with a simple conditional rendering, however, some pre-requisites are required to get that going.

Why did we render all those options even though the select was closed?

Good question! We did that because the compactSelect component 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 the label of 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:

Then, there’s CompositeSelect, which shares some internals with CompactSelect, however for those, the parent doesn’t pass the full state to CompositeSelect itself because it’s built up of Regions. 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, CompositeSelect now has a required trigger:

Lastly, there’s clearable. The Clear button appears outside of List, so it doesn’t have all the information necessary to perform a clear if we remove the reverse data flow. I changed how clear works by just triggering an onChange with an empty value. Also, types were a mess and that’s now fixed:

With that, I could finally remove the internal useLayoutEffect that synced state from the List child with the Control parent.

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:

  • They sometimes relied on the internal state management and mocked onChange. Now, without onChange, there is no state change happening. I’ve changed some tests to do some basic useState around the component (like we do in the product, too).
  • They sometimes had tests that relied upon all option popups being rendered, so they were accessing the Nth search field. Now they are no longer in the DOM because of conditional rendering so all of those needed to update.

@github-actions github-actions bot added the Scope: Frontend Automatically applied to PRs that change frontend components label Nov 21, 2025
@codecov
Copy link

codecov bot commented Nov 21, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

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');
Copy link
Collaborator Author

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.

Comment on lines -297 to 298
await userEvent.click((await screen.findAllByTestId('edit-access-dropdown'))[0]!);
await userEvent.click(screen.getByText('All'));
expect(screen.getAllByPlaceholderText('Search Teams')[0]).toBeInTheDocument();
Copy link
Collaborator Author

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 😅

Comment on lines -185 to +201
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'})
);
Copy link
Collaborator Author

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...

Copy link
Member

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?

Comment on lines -416 to -431
* 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),
]);
},
[]
);
Copy link
Collaborator Author

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 && (
Copy link
Collaborator Author

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 😅

Comment on lines -265 to -273
// 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]);

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yay

@TkDodo TkDodo marked this pull request as ready for review November 21, 2025 18:02
@TkDodo TkDodo requested review from a team as code owners November 21, 2025 18:02
@TkDodo TkDodo requested a review from a team as a code owner November 21, 2025 18:02
Copy link
Member

@JonasBa JonasBa left a 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>,
Copy link
Member

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 => (
Copy link
Member

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?

Copy link
Member

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>>;
Copy link
Member

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 = [],
Copy link
Member

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 = () => {
Copy link
Member

Choose a reason for hiding this comment

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

🤮

Comment on lines -185 to +201
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'})
);
Copy link
Member

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 => (
Copy link
Member

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?

Comment on lines +444 to +449
const selectedOptions = Array.isArray(value)
? value
: value === undefined
? []
: [value];
return selectedOptions.flat().length > 0;
Copy link
Member

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?

Suggested change
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;

@gggritso
Copy link
Member

Just wanted to chime in with some gratitude on this one, we have a lot of slow CompactSelect components in the Widget Builder, and this improvement saves on the order of 200-250ms when rendering the Widget Builder contents for complicated widgets 🙌🏻

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants