base-ui
base-ui copied to clipboard
[select] Defer mounting until typeahead is needed
This defers mounting the <Select.Portal> component until it's needed for closed typeahead (when the trigger is focused). After it opens for the first time, it stays mounted, which helps performance when opening/closing large lists.
Select.Value API change
The placeholder prop has been changed to children as a plain string. This must always be specified because the items are no longer rendered upon mount, which was previously used to grab the selected item value's .textContent to render the value in the trigger while it was closed. However, they always needed to use placeholder for SSR too, and it was confusing how that API worked. The docs were using:
<Select.Value>
{(label) => label || ssrFallback}
</Select.Value>
So we could make children: string the "default/initial" label present during SSR, and now with this PR, before the select menu is opened for the first time. It will get overwritten by the selected label when it needs to after being interacted with:
<Select.Value>
{initialValue}
</Select.Value>
Performance
For 1,000 items: since the items don't need to mount on page load, scripting time is 35% of what it was before:
This PR: https://deploy-preview-1906--base-ui.netlify.app/experiments/select-perf
Master: https://master--base-ui.netlify.app/experiments/select-perf
However, there's a tradeoff here. When opening the Select for the first time, it's noticeably slow compared to before, since the work was already done on load when the user is less likely to notice (depending on the page/flow):
With 20x slowdown: Master: ~1,168 ms to open first time (~40ms without slowdown) This PR: ~2,544 ms to open first time (~136ms without slowdown)
Deploy Preview for base-ui ready!
| Name | Link |
|---|---|
| Latest commit | d47ed457ed7381a370d1496ba8d67ceb4cb9226d |
| Latest deploy log | https://app.netlify.com/sites/base-ui/deploys/6822af925dc67e0008c5b4fc |
| Deploy Preview | https://deploy-preview-1906--base-ui.netlify.app |
| Preview on mobile | Toggle QR Code...Use your smartphone camera to open QR code link. |
To edit notification comments on pull requests, go to your Netlify site configuration.
Deploy Preview for base-ui ready!
| Name | Link |
|---|---|
| Latest commit | 7f948996e157c987303770ec5299993bb2851b6e |
| Latest deploy log | https://app.netlify.com/projects/base-ui/deploys/682e5dd5eca79000082579b4 |
| Deploy Preview | https://deploy-preview-1906--base-ui.netlify.app |
| Preview on mobile | Toggle QR Code...Use your smartphone camera to open QR code link. |
To edit notification comments on pull requests, go to your Netlify project configuration.
@romgrk the main issue here is that <Select.Value> uses the label of the rendered item (by default its textContent) after hydration. It swaps from placeholder to the rendered value, but this can't be deferred - so it seems that this would need to be either opt-in, or require the user specify placeholder (or some better-named prop) for the first render
However, there's a tradeoff here. When opening the Select for the first time, it's noticeably slow compared to before, since the work was already done on load when the user is less likely to notice (depending on the page/flow)
One way to solve it would be to use an intersection observer to mount only visible selects. This should help, especially on mobile devices, as their viewport is much smaller. We could also make this behavior controllable with a prop (mountItems='eager|lazy|when-visible')
One way to solve it would be to use an intersection observer to mount only visible selects
All it takes is a couple of select with lots of items at the top of the page and the end-user will feel the delays. I don't think there's a viable solution that allows us to decide for users that we're going to dedicate CPU runtime for non-visible content.
it's noticeably slow compared to before
I've been holding off on optimizing select items while this PR is open, but I think them being split into outer/inner components contributes to their high initial mount time. If we spend time optimizing the item component, I think we can reduce the INP for select open to acceptable levels. If it's really necessary, we could also look into virtualizing large item lists.
I'm ok with the tradeoff of larger lists taking longer to open on first mount for now. It's better than the current default.
Can we then see how much the current implementation can be sped up and then decide on deferring mounting strategy? We also considered using an idle callback (or timeout) to make allow the page to load and only then mount the items.
Thinking over Select.Value API more: children as a string represents the initially selected item label during SSR and pre-mount. There should be no need for an extra placeholder prop?
https://codesandbox.io/p/sandbox/cranky-dream-7xc64p
Case 1: There is no way to unselect anything/no empty value
<Select.Root defaultValue="red">
<Select.Value>
Red {/* SSR/pre-mount label */}
</Select.Value>
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.Item value="red">
Red
</Select.Item>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
Case 2: There is an empty value but only initially
<Select.Root>
<Select.Value>
Select value {/* SSR/pre-mount label, can't revert back */}
</Select.Value>
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.Item value="red">
Red
</Select.Item>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
Case 3: There is an empty value, including as an item
<Select.Root>
<Select.Value>
Select value {/* SSR/pre-mount label */}
</Select.Value>
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.Item> {/* null value by default */}
Select value {/* Acts as the placeholder, which is the same */}
</Select.Item>
<Select.Item value="red">
Red
</Select.Item>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
Can we then see how much the current implementation can be sped up and then decide on deferring mounting strategy?
I think this is a better default so we can still go with this and decide on a mounting strategy in a separate PR