Skip to content

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

Merged
atomiks merged 5 commits into
mui:masterfrom
flaviendelangle:perf/combobox-item-rerenders
Jun 17, 2026
Merged

atomiks merged 5 commits 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 index — flatFilteredItems.

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: 4e9590e

@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 🔺+504B(+0.11%) 🔺+175B(+0.12%)

Details of bundle changes

Performance

Total duration: 1,284.84 ms +177.08 ms(+16.0%) | Renders: 78 (🔺+28)

Test Duration Renders
Slider mount (300 instances) 187.28 ms 🔺+36.27 ms(+24.0%) 3 (+0)
Checkbox mount (500 instances) 90.82 ms 🔺+24.95 ms(+37.9%) 1 (+0)
Combobox type — 500 items, all stay mounted (type "Row ") 26.67 ms 11
Combobox type — 500 items, narrows to ~11 (type "Row 25") 27.81 ms 17

10 tests within noise — details

Metric alarms

Test Metric Change
Slider mount (300 instances) bench:paint 🔺 +60.14 ms
Checkbox mount (500 instances) bench:paint 🔺 +26.03 ms

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 4e9590e
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a321a9233088300089f9c02
😎 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)
  • ComboboxDerivedItemsContext — filteredItems/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.

@michaldudak

Copy link
Copy Markdown
Member

Review summary

Solid, well-targeted fix. The refactor relocates the item's index-resolution logic verbatim and removes the one per-keystroke context subscription that was defeating React.memo. I traced every code path against master and the observable behavior is preserved; the measured 3.1–3.4× win is real and the mechanism is sound. No blocking issues.

Verification (run locally on the PR branch)

Check Result
pnpm test:jsdom Combobox ✅ 674 passed, 17 skipped
pnpm test:jsdom Autocomplete ✅ 65 passed, 2 skipped
pnpm eslint (4 files) ✅ clean
pnpm prettier (4 files) ✅ clean
pnpm typescript ⚠️ one error in docs/.../Demo.tsx — unrelated to this PR

The typecheck error (defaultOpen not in UseDemoOpts) is in a file this PR never touches. The branch predates master's docs-infra bump (fb86e7924); the newer installed @mui/internal-docs-infra dropped defaultOpen from UseDemoOpts. packages/react compiled clean. Rebasing on master resolves it.

Correctness analysis

  • Index derivation is equivalent across the split. All four (virtualized, indexProp present?) combinations resolve to the same index as master. findItemIndex only ever returns a number (-1 when not found), so the new indexFromFilter ?? -1 is harmless defensive code that matches master's -1.
  • hasItems semantics are identical. The new ComboboxHasItemsContext is fed the same items !== undefined value from the same render as the existing context — neutralizing the scariest theoretical regression (items seeing a stale/late hasItems).
  • The memo bail is safe. The removed context subscription was the only thing forcing non-virtualized items to re-render on unrelated keystrokes. Every genuinely-reactive value still flows through useStore (re-renders independent of memo) or the stable hasItems context, so there's no stale-render risk. Highlight/selection/index-shift re-renders are preserved via useStore and useCompositeListItem.
  • Hooks ordering is valid — the wrapper calls both hooks unconditionally before the conditional component selection.

Findings

Important

1. The virtualized-without-index fallback (ComboboxItemVirtualizedIndex) is untested and undemoed. No test in packages/react sets virtualized at all, and the documented virtualized demo always passes an explicit index — so it routes through ComboboxItemImpl directly and never exercises the new component. Because the relocated logic is byte-for-byte identical to master, this PR does not regress the path; it's a pre-existing coverage gap, so it shouldn't block merge. But since the PR restructures exactly this code and a manual "regression-style check" was already done while developing, encoding that check is the highest-value follow-up: virtualized + no index, type a query that reorders the list, ArrowDown + Enter → assert the highlighted item is the one committed.

2. Toggling virtualized at runtime now remounts the item. The wrapper returns two different component types based on virtualized && index == null. If virtualized (or an item's index-prop presence) flips at runtime, React unmounts/remounts ComboboxItemImpl, resetting refs and re-running all three effects — where master kept a single instance alive. Realistically low-risk (virtualized is static config), but it's a genuine behavioral narrowing worth a one-line code comment noting virtualized must be stable per item lifetime.

Suggestions (non-blocking)

  • Double subscription: virtualized is now read via useStore in both the wrapper and ComboboxItemImpl. Cheap, but the wrapper could pass it down as a prop.
  • Lost displayName: ComboboxItemImpl / ComboboxItemVirtualizedIndex are anonymous in React DevTools — slightly ironic given the Profiler is the validation tool for this work.
  • Comment precision (the ComboboxItemVirtualizedIndex JSDoc): "which re-render on every input change regardless" — the trigger is the parent virtualizer re-windowing as the filtered set changes, not anything in this component. Worth making explicit.
  • Pre-existing, surfaced by the diff: the // Warn if the user renders a natively focusable element... comment above tabIndex: undefined describes a dev warning that isn't implemented. Relocated, not authored, by this PR — flag for separate cleanup.

Strengths

  • Fixes a real, measured problem with a minimal, well-isolated change; verbatim-preserved logic keeps regression risk low.
  • Excellent benchmark — idiomatic (createRows/ElementTiming), a realistic typeInto driver with a documented macrotask-per-keystroke rationale, and two well-chosen scenarios (all-mounted vs. narrowing) that cleanly isolate the cost being removed.
  • New comments accurately explain the why (the indexFromFilter and typeInto JSDocs are exemplary).
  • The strong existing non-virtualized keyboard/filter/selection suite passes, backing the "no regressions" claim for the common path.

Recommended actions

  1. (Optional, highest value) Add the virtualized-without-index regression test from finding 1.
  2. (Nice) One-line comment documenting the virtualized-stability requirement (finding 2).
  3. (Nice) Tighten the JSDoc on ComboboxItemVirtualizedIndex; consider passing virtualized down instead of re-subscribing.
  4. Rebase on master so the CI typecheck passes against the bumped docs-infra package.

None of these block merge given the logic-equivalence and green test/lint/format runs.

🤖 Review generated with Claude Code

Address the GPT-5.5 and Claude review on mui#4964:

- Add a regression test for virtualized items rendered without an explicit
  `index`, the only path that still resolves its index from the filtered set
  via `ComboboxItemVirtualizedIndex`. After narrowing the list, keyboard
  highlight/Enter and pointer selection must target the correct filtered item.
- Pass `virtualized` from the wrapper down to `ComboboxItemImpl` as a prop
  instead of re-subscribing to the store in both components.
- Document that `virtualized` (and an item's explicit-`index` presence) must be
  stable per item lifetime, since the wrapper swaps component types on it.
- Tighten the `ComboboxItemVirtualizedIndex` JSDoc to attribute the per-keystroke
  re-render to the parent virtualizer re-windowing the list.
@atomiks
atomiks requested a review from jjenzz as a code owner June 17, 2026 03:20

@atomiks atomiks left a comment

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.

PR review

No blocking or actionable correctness findings. I verified the render-count regression test now guards the core optimization: it passes on the PR and fails on master with 12 item render calls after one unchanged-membership keystroke. The benchmark also measures the intended cost: with the built package, the PR reduces the 500-item Combobox typing render work from ~22-28ms to ~8ms for unchanged membership, and from ~25-31ms to ~9ms when narrowing.

Bugs (1)

1. ℹ️ Flipping virtualized or an item's index at runtime now remounts the item

Location: packages/react/src/combobox/item/ComboboxItem.tsx:281

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

Because the wrapper now selects a different component type based on virtualized and whether index is provided, toggling either at runtime remounts the item. That resets refs/effects where master would have re-rendered in place. The PR already documents this, and both inputs are expected to be stable in normal usage, so this is a heads-up rather than a defect.

Failure scenario: A consumer conditionally starts providing index after mount and sees transient item state reset.

Fix: None needed; the trade-off is documented and acceptable for this optimization.

Tests (0)

No findings.

Simplifications (0)

No findings.

Docs (0)

No findings.

Verdict

Approve — correctness is preserved, the render-count regression test now pins the performance fix, and the benchmark independently confirms the intended ~3x render-work reduction.


🤖 Review generated with Codex and Claude Code combined

@atomiks

atomiks commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

For follow-up: the win here relies on <Combobox.Item> children being referentially stable, and grouped items see no win (by default).

Combobox.Collection recreates children every keystroke. We can internalize it, but externally doing it already works:

const ProduceItem = React.memo(function ProduceItem({ item }: { item: Produce }) {
  return (
    <Combobox.Item value={item}>
      <Combobox.ItemIndicator />
      <span>{item.label}</span>
    </Combobox.Item>
  );
});

<Combobox.Collection>
  {(item) => <ProduceItem key={item.id} item={item} />}
</Combobox.Collection>

@atomiks
atomiks merged commit e972d74 into mui:master Jun 17, 2026
23 checks passed
atomiks added a commit to atomiks/base-ui that referenced this pull request Jun 17, 2026
…roke

Follow-up to mui#4964. That PR stops `<Combobox.Item>` subscribing to the
per-keystroke derived-items context, so `React.memo` can bail when an
item's props are referentially stable. That holds for flat lists with
simple string children, but not for the common grouped + rich-children
case: `<Combobox.Collection>` re-runs the user's render function for
every item on each keystroke, rebuilding a fresh `children` array (e.g.
`<ItemIndicator /> + <span>`), so the item memo can never bail.

This internalizes the win at the collection layer so users don't have to
hand-memoize their item component:

- `ComboboxCollection` renders each item through a memoized internal
  renderer. Because the collection re-renders from its own context
  subscription (not its parent), the user's render function stays a
  stable reference, so a stable item + index lets the wrapper bail and
  the user's render fn never runs.
- `AriaCombobox` preserves grouped filtered-item identities when a
  group's membership is unchanged (`areSameItems`), so the same `group`
  reference and its `items` flow down and the wrapper can bail. Includes
  reference/length fast-paths so the common cases are O(1).

Adds typing + open benchmarks (flat/simple and grouped/rich, 500 items)
and collection tests asserting the user render fn is not re-invoked on a
membership-preserving keystroke, plus removal/reorder correctness for the
index key strategy.
atomiks added a commit to atomiks/base-ui that referenced this pull request Jun 17, 2026
…roke

Follow-up to mui#4964. That PR stops `<Combobox.Item>` subscribing to the
per-keystroke derived-items context, so `React.memo` can bail when an
item's props are referentially stable. That holds for flat lists with
simple string children, but not for the common rich-children case (flat
or grouped): `<Combobox.Collection>` re-runs the user's render function
for every item on each keystroke, rebuilding a fresh `children` array
(e.g. `<ItemIndicator /> + <span>`), so the item memo can never bail.

This internalizes the win at the collection layer so users don't have to
hand-memoize their item component:

- `ComboboxCollection` caches each item's rendered element by item
  identity and returns it directly. Because the collection re-renders
  from its own context subscription (not its parent), the user's render
  function stays a stable reference, so an unchanged item reuses its
  cached element and React bails out of re-rendering that subtree without
  re-invoking the render function. Returning the element directly (not
  wrapped) keeps the user's own `key` as the reconciliation key, so
  filtering preserves surviving item subtrees instead of remounting
  whichever item shifts into a given position.
- `AriaCombobox` preserves grouped filtered-item identities when a
  group's membership is unchanged (`areSameItems`), so the same `group`
  reference and its `items` flow down and the group's cache entry is
  reused. Reference/length fast-paths keep the common cases O(1).

Adds typing + open benchmarks (flat/simple and grouped/rich, 500 items)
and collection tests: the render fn is not re-invoked on a
membership-preserving keystroke (flat + grouped), removal/reorder
correctness, and a surviving item subtree is not remounted when an
earlier item is filtered out.
@flaviendelangle
flaviendelangle deleted the perf/combobox-item-rerenders branch August 19, 2026 09:59
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.

4 participants