base-ui icon indicating copy to clipboard operation
base-ui copied to clipboard

[select] Defer mounting until typeahead is needed

Open atomiks opened this issue 7 months ago • 4 comments

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 Screenshot 2025-05-14 at 4 34 40 pm

Master: https://master--base-ui.netlify.app/experiments/select-perf Screenshot 2025-05-14 at 4 34 20 pm

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)

atomiks avatar May 13 '25 02:05 atomiks

Open in StackBlitz

npm i https://pkg.pr.new/@base-ui-components/react@1906

commit: 7f94899

pkg-pr-new[bot] avatar May 13 '25 02:05 pkg-pr-new[bot]

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

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

netlify[bot] avatar May 13 '25 02:05 netlify[bot]

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

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

netlify[bot] avatar May 13 '25 02:05 netlify[bot]

@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

atomiks avatar May 13 '25 02:05 atomiks

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')

michaldudak avatar May 21 '25 08:05 michaldudak

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.

romgrk avatar May 21 '25 09:05 romgrk

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.

atomiks avatar May 21 '25 09:05 atomiks

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.

michaldudak avatar May 21 '25 09:05 michaldudak

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>

atomiks avatar May 21 '25 09:05 atomiks

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

atomiks avatar May 21 '25 09:05 atomiks