Repository navigation
[select][menu][combobox] Improve mount performance of large lists - #5819
Conversation
commit: |
Bundle size
PerformanceTotal duration: 852.03 ms -37.15 ms(-4.2%) | Renders: 76 (+0) | Paint: 1,405.71 ms -59.60 ms(-4.1%)
13 tests within noise — details Metric alarms
Check out the code infra dashboard for more information about this PR. |
✅ Deploy Preview for base-ui ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
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 Verified in Chromium with the repository's React 19.3.0, at head Reproduction
Expected: visible A becomes highlighted. Actual: the store's Cause
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 testSave as pnpm test:chromium SuspendedItemSubscription --no-watchimport * 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 Separate Activity concernI also reproduced a separate stale-state bug in 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. |
9679401 to
0d0280d
Compare
|
Confirmed: the test fails with the item wrappers and passes without them. I dropped the
|
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
nodeIdfrom it, but the context value changes identity twice while the popup positions itself. So every item rendered 3 times on open. Items now readfloatingNodeIdfrom the menu store, which is the same value the positioner passes to its floating context.Menu.SubmenuTriggeris unchanged, because its node id belongs to the parent menu.compareDocumentPositionfor 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:
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.