Skip to content

[combobox] Avoid re-rendering every item on each keystroke#4964

Open
flaviendelangle wants to merge 1 commit into
mui:masterfrom
flaviendelangle:perf/combobox-item-rerenders
Open

[combobox] Avoid re-rendering every item on each keystroke#4964
flaviendelangle wants to merge 1 commit into
mui:masterfrom
flaviendelangle:perf/combobox-item-rerenders

Conversation

@flaviendelangle

Copy link
Copy Markdown
Member

Problem

Every <Combobox.Item> subscribes to ComboboxDerivedItemsContext, whose value identity changes on every keystroke (it carries query and the freshly-filtered item arrays). A context value-identity change re-renders all consumers regardless of React.memo, so every mounted item re-renders on every keystroke, even when its own state and props are unchanged.

Items only actually need two things from that context: hasItems (a stable boolean) and — only for the virtualized fallback without an explicit indexflatFilteredItems.

Fix

  • Move hasItems into its own ComboboxHasItemsContext. It only flips when items toggles between provided/undefined, so it's stable across keystrokes. (Using a render-time context rather than the store preserves the async-item-load timing — the store updates one render later in a layout effect.)
  • Resolve the virtualized fallback index in a dedicated ComboboxItemVirtualizedIndex subscriber, so only virtualized items (which re-render every keystroke anyway) read the per-keystroke context. Non-virtualized items no longer subscribe to it.

With this, React.memo on <Combobox.Item> can bail for items with stable props, so typing no longer re-renders the whole list. Arrow-key navigation was already efficient (only the highlighted/unhighlighted items re-render) and is unchanged.

Benchmark

Adds test/performance/tests/combobox.bench.tsx: a 500-item open combobox using the documented function-children pattern, with the user typing into the input. Run in the existing harness (production build, headless Chromium, React Profiler, 20 iterations).

Total React render work per typing interaction:

Scenario before after speedup
Type "Row " — all 500 stay mounted 46.3 ms 13.8 ms 3.4×
Type "Row 25" — narrows to ~11 49.0 ms 16.0 ms 3.1×

Item re-renders during the same interaction (instrumented separately):

Scenario before after
Type "Row " (membership unchanged) 1500 0
Type "Row 25" (489 items unmount) 1744 122

The residual 122 are the necessary re-renders — when items unmount, the surviving items' composite indices shift. The fix removes only the wasteful re-renders.

Note: the benchmark harness resolves @base-ui/react from packages/react/build, so build the package before running it to reflect source changes.

Limitation

The improvement requires item props to be referentially stable so React.memo can bail. <Combobox.Item value={item}>{item.label}</Combobox.Item> qualifies. Wrapping item content in a freshly-created element each render (e.g. <span>{item.label}</span>) defeats React.memo on its own and sees no improvement; memoize such content if needed.

Test plan

  • pnpm test:jsdom Combobox / Autocomplete — 758 pass, 19 skipped, no regressions.
  • Added a regression-style check while developing that virtualized-without-index keyboard selection still resolves the correct item after filtering (this PR keeps that path fresh via the dedicated subscriber rather than a stale ref).
  • pnpm typescript, pnpm eslint, pnpm prettier all clean.

🤖 Generated with Claude Code

@flaviendelangle flaviendelangle marked this pull request as draft June 3, 2026 07:32
@pkg-pr-new

pkg-pr-new Bot commented Jun 3, 2026

Copy link
Copy Markdown

commit: 395e77f

@flaviendelangle flaviendelangle self-assigned this Jun 3, 2026
@flaviendelangle flaviendelangle added type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. performance component: autocomplete Changes related to the autocomplete component. component: combobox Changes related to the combobox component. labels Jun 3, 2026
Every `<Combobox.Item>` subscribed to `ComboboxDerivedItemsContext`, whose
value identity changes on every keystroke (it carries `query` and the freshly
filtered item arrays). A context value-identity change re-renders all consumers
regardless of `React.memo`, so all mounted items re-rendered on each input
change even when their own state and props were unchanged.

Items only needed `hasItems` (a stable boolean) and, for the virtualized
fallback without an explicit `index`, `flatFilteredItems`. This:

- Moves `hasItems` into its own `ComboboxHasItemsContext`, which only flips
  when `items` toggles between provided/undefined (stable across keystrokes).
- Resolves the virtualized fallback index in a dedicated `ComboboxItemVirtualizedIndex`
  subscriber so only virtualized items (which re-render every keystroke anyway)
  read the per-keystroke context; non-virtualized items no longer subscribe to it.

With this, `React.memo` on `<Combobox.Item>` can bail for items with stable
props, so typing no longer re-renders the whole list.

Adds `test/performance/tests/combobox.bench.tsx` measuring typing in a 500-item
open combobox. Against this benchmark, total render work per typing interaction
drops ~3.4x (e.g. 46ms -> 14ms) and per-keystroke item re-renders drop from
~all-items to zero (or to only the items whose membership actually changed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@flaviendelangle flaviendelangle force-pushed the perf/combobox-item-rerenders branch from 6d7d394 to 395e77f Compare June 3, 2026 07:35
@netlify

netlify Bot commented Jun 3, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit 6d7d394
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a1fd89f69de090008bf4762
😎 Deploy Preview https://deploy-preview-4964--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.

@code-infra-dashboard

code-infra-dashboard Bot commented Jun 3, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+475B(+0.10%) 🔺+205B(+0.14%)

Details of bundle changes

Performance

Total duration: 1,308.62 ms -37.88 ms(-2.8%) | Renders: 78 (🔺+28) | Paint: 2,035.76 ms +4.35 ms(+0.2%)

Test Duration Renders
Checkbox mount (500 instances) 69.05 ms ▼-31.32 ms(-31.2%) 1 (+0)
Popover mount (300 instances) 65.05 ms ▼-17.13 ms(-20.8%) 1 (+0)
Tooltip mount (300 contained roots) 46.18 ms ▼-13.90 ms(-23.1%) 1 (+0)
Combobox type — 500 items, all stay mounted (type "Row ") 28.89 ms 11
Combobox type — 500 items, narrows to ~11 (type "Row 25") 31.79 ms 17

9 tests within noise — details


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

@netlify

netlify Bot commented Jun 3, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit 395e77f
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a1fd92681eb25000adf9384
😎 Deploy Preview https://deploy-preview-4964--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.

@flaviendelangle flaviendelangle removed the component: autocomplete Changes related to the autocomplete component. label Jun 3, 2026
@flaviendelangle flaviendelangle marked this pull request as ready for review June 4, 2026 05:05

atomiks commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Code Review (GPT-5.5)

Approve after nits are addressed 🟡, because the performance split looks correct and focused validation passed, but the preserved virtualized no-index fallback should have committed regression coverage.

1. Bugs / Issues

1. 🟡 Add coverage for the virtualized no-index path (non-blocking)

The PR keeps a special subscriber for virtualized items that do not pass an explicit index, but I do not see a committed Combobox/Autocomplete test that exercises virtualized items at all.

packages/react/src/combobox/item/ComboboxItem.tsx

if (virtualized && componentProps.index == null) {
  return (
    <ComboboxItemVirtualizedIndex componentProps={componentProps} forwardedRef={forwardedRef} />
  );
}

That path is now the only place where those items still read flatFilteredItems and resolve their index after filtering. If it regresses, keyboard highlight/Enter selection can target the wrong filtered item while the normal and explicit-index paths keep passing.

Fix: Add a focused regression test with <Combobox.Root virtualized items={items}>, render <Combobox.Item value={item}> without index, type a filter, then ArrowDown/Enter and assert the filtered item is selected.

Benchmark Summary

Source Result
PR benchmark/CI comment New Combobox typing benchmarks ran and the dashboard reported the added rows at 28.89 ms and 31.79 ms, with overall benchmark duration down 2.8%.
Code review The split removes non-virtualized item subscriptions to the query-bearing derived-items context while preserving the virtualized fallback subscriber.

Correctness Trade-offs

The runtime shape is targeted: Combobox.Item now only decides whether the item needs the virtualized fallback subscriber, while ComboboxItemImpl keeps the existing selection, registration, pointer, and render-element behavior. I did not find a behavior regression in the index/list registration paths during code reading.

Test Coverage Assessment

I ran pnpm test:jsdom Combobox --no-watch, pnpm test:jsdom Autocomplete --no-watch, pnpm test:chromium Combobox --no-watch, pnpm typescript, and git diff --check; all passed. The only coverage gap I found is the missing direct test for the virtualized no-index branch above.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I've read the PR & code fairly quickly so I might not understand all the subtleties, but why is the combobox using so many contexts (including the new one introduced in this PR)? The Store was introduced to avoid re-renders due to context updates, is there a limitation that prevents it from being used?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It's largely because of timing issues. I know for certain that ComboboxInputValueContext is necessary to live outside the store, while the others I believe were following your (initial) Select store architecture.


Claude Fable explanation

The extra contexts exist because of a timing limitation: the store is synced from the root's render state in a layout effect i.e. one commit after the render that computed the value. That's fine for most state, but some values must reach consumers in the same render they're computed:

  • ComboboxInputValueContext — the controlled input value can't lag a commit behind the DOM or typing breaks (cursor jumps to the end with async suggestions, #2703)
  • ComboboxDerivedItemsContextfilteredItems/flatFilteredItems are derived from inputValue during render via useMemo. Routing them through the store would show stale filter results for a frame on every keystroke (and writing to the store during render isn't allowed).
  • ComboboxHasItemsContext (this PR) — same reason: the item registration effects branch on hasItems, and with async-loaded items a store read would be stale for one commit, so items could register into the wrong registry. It's also intentionally a separate context from the derived-items one precisely because it's stable per keystroke — that separation is what lets React.memo bail.
  • ComboboxRootContext/ComboboxFloatingContext carry stable object identities (the store itself, floating-ui's context), so they never trigger re-renders.

So context count isn't the re-render hazard — only a context whose value identity changes is, and after this PR the only hot one (derived items) is paid exclusively by the few consumers that genuinely need per-keystroke data.

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

3 participants