Skip to content

[select][menu][combobox] Improve mount performance of large lists - #5819

Merged
atomiks merged 2 commits into
mui:masterfrom
atomiks:claude/list-item-mount-perf
Sep 29, 2026
Merged

atomiks merged 2 commits into
mui:masterfrom
atomiks:claude/list-item-mount-perf

Conversation

@atomiks

@atomiks atomiks commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

This PR reduces the mount cost of large Menu, Select, and Combobox lists. It came out of checking whether the store (useSyncExternalStore) is what makes our lists slower than a plain context + memo setup. In an isolated 1000-item list, the store added about 0.3 ms on mount and was on par or faster for highlight changes. Most of the gap came from CompositeList and from items subscribing to more than they need.

Changes

  • Menu items no longer read the positioner context. Items only needed nodeId from it, but the context value changes identity twice while the popup positions itself. So every item rendered 3 times on open. Items now read floatingNodeId from the menu store, which is the same value the positioner passes to its floating context. Menu.SubmenuTrigger is unchanged, because its node id belongs to the parent menu.
  • CompositeList sorting skips compareDocumentPosition for adjacent siblings. In Chromium that call scans siblings from the parent's first child, so sorting a long flat list was effectively O(n²). Already-ordered lists now sort in linear time.

Performance

Lists with 1000 items, measured in headless Chromium with a production React 19 build, as medians of interleaved runs:

Scenario (1000 items) Before After Delta
Menu: synchronous mount 18.6 ms 11.5 ms −38%
Select: synchronous mount 15.8 ms 14.2 ms −10%
Combobox: synchronous mount 11.3 ms 10.1 ms −11%
Menu: full event-to-paint 50 ms 33 ms −33%
Menu: full event-to-paint, 6× CPU slowdown 229 ms 132 ms −42%

Synchronous mount measures render + commit for already-open demos; Combobox uses an inline list. Event-to-paint measures keyboard opening from an already-mounted, closed Menu, from the keydown timestamp to the end of the first frame where the first item is visible. The popup has a 300px maximum height with scrolling and no animation. CPU slowdown applies only to the final row.

Moving the highlight with the arrow keys is unchanged: only the two affected items re-render, before and after.

@atomiks atomiks added performance type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. component: menu Changes related to the menu component. component: select Changes related to the select component. component: combobox Changes related to the combobox component. labels Sep 29, 2026
@pkg-pr-new

pkg-pr-new Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

commit: 0d0280d

@code-infra-dashboard

code-infra-dashboard Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+79B(+0.02%) 🔺+18B(+0.01%)

Details of bundle changes

Performance

Total duration: 852.03 ms -37.15 ms(-4.2%) | Renders: 76 (+0) | Paint: 1,405.71 ms -59.60 ms(-4.1%)

Test Duration Renders
Checkbox mount (500 instances) 61.47 ms 🔺+12.89 ms(+26.5%) 1 (+0)
Menu open (500 items) 27.62 ms ▼-37.14 ms(-57.3%) 11 (+0)

13 tests within noise — details

Metric alarms

Test Metric Change
Checkbox mount (500 instances) bench:paint 🔺 +18.68 ms

Check out the code infra dashboard for more information about this PR.

@netlify

netlify Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit 0d0280d
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6abb95beb81c430007c2f85f
😎 Deploy Preview https://deploy-preview-5819--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

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

@atomiks
atomiks marked this pull request as ready for review September 29, 2026 09:05

michaldudak commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Keyboard highlighting can stop working in a Combobox while a list reorder is suspended: pressing ArrowDown updates the active index to 0, but the first visible item remains unhighlighted. This occurs when keyed items have explicit indexes and a transition renders their new order without committing it.

The new fastComponent wrapper lets that unfinished render overwrite the subscription indexes used by the still-visible items. I would defer the item wrappers until the batching helper is fixed. The menu node-ID substitution and adjacent-sibling sorting shortcut can remain.

Verified in Chromium with the repository's React 19.3.0, at head 967940104a58e0d7e4bf3aed0acbe8167b532b5a. The same component test passes on base 01b8ae0125d076210471a3b25faeff02bcc3c6f4 and fails on this head.

Reproduction

  1. Render an inline Combobox with keyed items A and B, explicitly indexed 0 and 1.
  2. Start a transition that reverses the items while preserving their keys, assigning B index 0 and A index 1.
  3. Have a later sibling inside the same Suspense boundary suspend after the items render. The committed A–B list stays visible.
  4. Focus the input and press ArrowDown while the transition is pending.

Expected: visible A becomes highlighted.

Actual: the store's activeIndex becomes 0, but visible A does not receive data-highlighted.

Cause

useStoreFast mutates the shared selector arguments and cached value during render. The suspended render changes A's subscription argument to index 1 even though its committed DOM still represents index 0. When the active index changes to 0, that subscription compares against the speculative index and misses the update.

The helper predates this PR, but wrapping the items exposes this failure to their index-dependent subscriptions.

This is not inherently limited to Combobox: the same helper is used by the newly wrapped Select and Menu items, so other components can be affected when a suspended render changes their selector arguments, such as an item's value or index. The component-level regression confirmed here is Combobox; I have not reproduced equivalent failures in Select or Menu. In particular, their composite indexes normally update at commit, so the explicit-index reorder scenario above does not transfer directly.

Runnable component regression test

Save as packages/react/src/combobox/item/SuspendedItemSubscription.test.tsx and run:

pnpm test:chromium SuspendedItemSubscription --no-watch
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import { it, expect } from 'vitest';
import { Combobox } from '@base-ui/react/combobox';
import { useComboboxRootContext } from '../root/ComboboxRootContext';

it('Combobox keeps the committed index subscription during a suspended reorder', async () => {
  const host = document.createElement('div');
  document.body.append(host);
  const root = createRoot(host);
  const pending = new Promise<never>(() => {});
  let suspendCount = 0;
  let reorder!: () => void;
  let store!: ReturnType<typeof useComboboxRootContext>;
  function Probe() { store = useComboboxRootContext(); return null; }
  function Suspend({ enabled }: { enabled: boolean }) {
    if (enabled) { suspendCount += 1; throw pending; }
    return null;
  }
  function Rows() {
    const [reversed, setReversed] = React.useState(false);
    reorder = () => setReversed(true);
    return <React.Suspense fallback="loading">
      {(reversed ? ['B', 'A'] : ['A', 'B']).map((name, index) =>
        <Combobox.Item key={name} value={name} index={index}>{name}</Combobox.Item>)}
      <Suspend enabled={reversed} />
    </React.Suspense>;
  }
  try {
    await React.act(async () => root.render(<Combobox.Root items={['A', 'B']} inline>
      <Probe /><Combobox.Input /><Combobox.List><Rows /></Combobox.List>
    </Combobox.Root>));
    await React.act(async () => React.startTransition(reorder));
    expect(suspendCount).toBeGreaterThan(0);
    expect([...host.querySelectorAll('[role="option"]')].map((el) => el.textContent)).toEqual(['A', 'B']);
    await React.act(async () => {
      const input = host.querySelector('input')!;
      input.focus();
      input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
    });
    expect(store.state.activeIndex).toBe(0);
    expect(host.querySelector('[role="option"]')).toHaveAttribute('data-highlighted');
  } finally {
    await React.act(async () => root.unmount());
    host.remove();
  }
});

The assertion checking activeIndex === 0 passes on both revisions; the final data-highlighted assertion fails only on the PR head.

Separate Activity concern

I also reproduced a separate stale-state bug in fastComponent and useStore at the helper level: mount an initially unselected row, hide its Activity so the subscription disconnects, update the live store, then reveal it while changing an unrelated presentation prop. The unwrapped control renders the current selection; the batched version renders the stale cached value. The trailing useSyncExternalStore snapshot read refreshes the cache after the component has already calculated its output.

However, my actual Select Activity test passes, so I am not claiming a confirmed Select regression from that reproduction. This remains a separately confirmed helper defect and needs its own regression coverage; fixing speculative selector arguments alone would not address it.

For this PR, dropping the item wrappers is the smaller change. Alternatively, repair the helper so subscription comparisons use committed selector metadata and each render calculates output from the snapshot React validates, with tests for both failure modes.

@atomiks
atomiks force-pushed the claude/list-item-mount-perf branch from 9679401 to 0d0280d Compare September 29, 2026 10:40
@atomiks

atomiks commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed: the test fails with the item wrappers and passes without them. I dropped the fastComponent commit; the node ID and sorting changes remain. The helper fix can follow separately.

1000 items Previous head (967940104a) Current head (0d0280d3fd)
Menu: synchronous mount 9.9 ms 11.5 ms
Select: synchronous mount 12.5 ms 14.2 ms
Combobox: synchronous mount 8.6 ms 10.1 ms
Menu: event-to-paint 30 ms 33 ms
Menu: event-to-paint, 6× CPU slowdown 123 ms 132 ms

@atomiks
atomiks merged commit 99f7a80 into mui:master Sep 29, 2026
24 checks passed
@atomiks
atomiks deleted the claude/list-item-mount-perf branch September 29, 2026 11:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: combobox Changes related to the combobox component. component: menu Changes related to the menu component. component: select Changes related to the select component. performance type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants