Skip to content

[combobox][select] Make the multiple selection anchor lookup linear - #5613

Merged
michaldudak merged 3 commits into
mui:masterfrom
michaldudak:claude/fix-quadratic-selection-anchor
Sep 2, 2026
Merged

[combobox][select] Make the multiple selection anchor lookup linear#5613
michaldudak merged 3 commits into
mui:masterfrom
michaldudak:claude/fix-quadratic-selection-anchor

Conversation

@michaldudak

Copy link
Copy Markdown
Member

The regression

#5573 changed findSelectionIndex to anchor multi-selection to the first selected item
in rendered order. Getting that ordering right meant asking, for every rendered item,
"is this one selected?" — and the selected values are a plain array, so each question
became a full scan:

itemValues.findIndex((itemValue) =>
  selectedValueIncludes(selectedValue, itemValue, comparer),
)

That is O(items × selections). In 1.7.0 the same function did
findItemIndex(itemValues, lastValue, comparer) — one O(items) pass. Both Select and
Combobox route through it (SelectRoot.tsx:244, AriaCombobox.tsx:462,1027,1115).

findIndex short-circuits on the first hit, so the common case — some rendered item is
selected, usually near the top — still costs a single comparison. The blow-up needs no
rendered item to be selected
, which is what happens when a large list has filtered every
selected value out of the rendered window.

items selections rendered selection? 1.7.0 after #5573 this PR
5,000 2,500 none rendered 0.070 ms 63.9 ms 0.185 ms
500 50 none rendered 0.003 ms 0.086 ms 0.006 ms
500 50 first item 0.000 ms 0.000 ms 0.001 ms
5,000 500 first item 0.004 ms 0.000 ms 0.006 ms
5,000 2,500 first item 0.018 ms 0.000 ms 0.044 ms

Comparison counts at 5,000 × 2,500: 1.7.0 = 5,000, current = 12,500,000, this PR = 0.

Be clear about the size of this: outside large lists it is sub-millisecond, and the
common case was never slow. This is a complexity regression worth closing, not an
emergency.

The fix

Index the selected values in a Set and probe it in constant time — but only when the
comparer is the default Object.is. A custom isItemEqualToValue can call values equal
that do not hash alike, so it cannot be indexed and keeps the existing nested scan. The
helper is module-private; no signature changed and nothing new is exported.

The last two rows above are the honest cost: the Set is built eagerly while findIndex
short-circuits, so the fast path adds up to ~0.04 ms in the common case — and only at
selection counts where the bad case costs ~64 ms. Below ~500 selections it is not
measurable. A hybrid (probe the first K items, build the index only on a miss) would
remove even that, at the price of a magic constant and two more branches; that did not
seem worth it.

Correctness constraints preserved

  1. Set membership is SameValueZero; Object.is is not. They differ only on +0
    vs -0. A Set hit on a value that is === 0 falls back to an exact Object.is
    scan. This is not pedantry: the isSelected store selectors (select/store.ts,
    combobox/store.ts) compare through Object.is, so without the re-check the anchor
    could land on an item that renders without data-selected.
  2. undefined never matches. selectedValueIncludes rejects
    selectedValue === undefined and findItemIndex rejects itemValue === undefined.
    Sparse arrays are real here — items delete their registry slot on unmount — so holes
    and explicit undefined are both filtered out while building the index.
  3. compareItemEquality routes any nullish operand to Object.is, never to the
    comparer.
    Untouched by construction: the fast path only runs when the comparer
    already is Object.is, and the slow path is unchanged.

A fourth came out of review: the index is built with forEach, not by iterating. That
matches how the some() scan it replaces consumed the values — same length snapshot,
same hole skipping, no Symbol.iterator — so an array carrying its own iterator resolves
the same anchor as before instead of a different one (or throwing, if that property is
nulled).

Tests

itemEquality.test.ts goes from 9 to 16 tests. The load-bearing one counts how many
times the selected values are read, via a Proxy, and asserts it stays linear. It was
checked against two deliberately quadratic implementations — the pre-#5573 nested scan
and a variant that rebuilds the index inside the item loop — and fails on both at 20,000
reads versus a 300 budget. (An earlier version counted Object.is calls instead; review
caught that the fast path never calls Object.is, so that assertion was satisfied by 0
and passed the quadratic variant too.)

The rest pin behavioural equivalence with the pre-fix scan: ±0 in both directions,
NaN, null, undefined, sparse holes on both sides, a custom comparer through the
multiple-mode path, and the Symbol.iterator build. All of them pass against the old
implementation as well — only the cost changed, which is the point.

Two independent reviewers ran differential fuzzing against the pre-fix source: ~250,000
and ~45,000 randomized cases respectively, over a pool including ±0, NaN, ±Infinity,
null, undefined, BigInt, symbols, functions, boxed primitives, objects and holes.
Zero divergences.

Known remaining cost, not addressed here

resolveSelectedIndex still does O(selections) work per item in a layout effect
(ComboboxItem.tsx:121, SelectItem.tsx:83), which is also a #5573 regression. It is
left alone deliberately, and one reviewer disagreed with that call — so here is the
evidence, measured on a rendered 200-item × 100-selection multiple Select:

phase isSelected iterations resolveSelectedIndex iterations
mount, popup open 60,000 (3 passes) 20,000 (1 pass)
ArrowDown 20,200 0
controlled value change 100,600 (5) 0
click an item 80,905 (4) 0
close 140,000 (7) 0

The resolver runs one pass, at item mount only — its effect deps contain no selected
value. The isSelected store selector recomputes for every mounted item on every store
notification. #5573 did not touch either store file, so that path was already
O(items × selections); #5573 took mount from 3 passes to 4.

Fixing the resolver alone would need a membership index shared across items — a
store-level derived value or a WeakMap keyed on the value array — and would buy ~25% at
mount and 0% elsewhere while leaving the dominant pass in place. It also carries a hazard
this PR's per-call Set cannot have: isSelected's result identity drives useStore's
getSnapshot change detection, so a stale shared index would make items silently miss
re-renders. That wants to be one follow-up covering isSelected, resolveSelectedIndex
and findSelectionIndex together, with tests for the staleness and zero cases.

Two smaller things also left as-is: the ±0 fallback scan is unbounded, so the fast path
is "linear except for zeros" (it needs thousands of item slots holding ±0 with the
opposite sign selected, which no real list produces); and nothing pins that production
wiring actually reaches the fast path, which is selected by reference identity to a
default parameter — wrapping isItemEqualToValue anywhere would silently revert the
optimization with a green suite. itemCollection.ts:14 already has the same unguarded
fragility, so a shared guard would cover both.

🤖 Generated with Claude Code

`findSelectionIndex` resolved the multi-selection anchor by rescanning the whole
selected-value array for every rendered item, making the lookup
O(items x selections). Before mui#5573 it was a single O(items) pass.

`findIndex` short-circuits on the first hit, so the common case still costs one
comparison; the blow-up needs no rendered item to be selected, which happens when
a large list filters every selected value out of the rendered window. At
5,000 items x 2,500 filtered-out selections that is 12.5M comparisons and ~64ms
in one synchronous call.

Index the selected values in a `Set` when the comparer is the default
`Object.is`, so each item is a constant-time probe. A custom `isItemEqualToValue`
may call values equal that do not hash alike, so it keeps the linear scan.

Four behaviours are preserved. `Set` membership is SameValueZero and unifies
`+0` with `-0`, so a hit on a zero falls back to an exact `Object.is` scan, which
is what keeps the anchor agreeing with the `isSelected` store selectors.
`undefined` and sparse holes are filtered out of the index so they never match.
The index is built with `forEach` rather than iterated, so it snapshots the
length and skips holes exactly as the `some()` scan it replaces did, and an array
carrying its own `Symbol.iterator` resolves the same anchor as before. And the
nullish path through `compareItemEquality` is untouched, since the fast path only
runs when the comparer already is `Object.is`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pkg-pr-new

pkg-pr-new Bot commented Sep 1, 2026

Copy link
Copy Markdown

commit: fe200ea

@code-infra-dashboard

code-infra-dashboard Bot commented Sep 1, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+131B(+0.03%) 🔺+45B(+0.03%)

Details of bundle changes

Performance

Total duration: 1,071.11 ms +47.84 ms(+4.7%) | Renders: 76 (+0) | Paint: 1,723.09 ms +66.41 ms(+4.0%)

Test Duration Renders
Checkbox mount (500 instances) 76.24 ms 🔺+16.41 ms(+27.4%) 1 (+0)
Popover mount (300 instances) 47.27 ms 🔺+10.92 ms(+30.0%) 1 (+0)

13 tests within noise — details

Metric alarms

Test Metric Change
Checkbox mount (500 instances) bench:paint 🔺 +21.66 ms
Popover mount (300 instances) bench:paint 🔺 +18.46 ms

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

@netlify

netlify Bot commented Sep 1, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

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

@michaldudak michaldudak added performance type: regression A bug, but worse, it used to behave as expected. component: combobox Changes related to the combobox component. component: select Changes related to the select component. labels Sep 1, 2026
@atomiks

atomiks commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

items | selections | rendered selection? | 1.7.0 | after #5573 | this PR
-- | -- | -- | -- | -- | --
5,000 | 2,500 | none rendered | 0.070 ms | 63.9 ms | 0.185 ms

In the PR, I figured this was not a realistic or worthwhile scenario given the mount cost of 5,000 items dominates

Edit: nevermind, it actually affects virtualized lists as well

@michaldudak
michaldudak marked this pull request as ready for review September 2, 2026 06:37
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-02T06:54:08.710551Z f063588 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

michaldudak and others added 2 commits September 2, 2026 08:51
Shorten the comments around the indexed anchor lookup and drop the references
to how the lookup used to be written, which a reader arriving at this file has
no way to check.

Comments and one test comment only; no behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

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

Independent measurement:

Scenario Base A1 / A2 PR head
5,000 items × 2,500 filtered-out selections 21.88 / 21.97 ms 0.115 ms
500 items × 50 filtered-out selections 0.0508 / 0.0539 ms 0.00469 ms
5,000 items × 2,500 selections, first-item hit <0.001 ms 0.0420 ms

@michaldudak
michaldudak merged commit 2e1bddd into mui:master Sep 2, 2026
24 checks passed
@michaldudak
michaldudak deleted the claude/fix-quadratic-selection-anchor branch September 2, 2026 07:21
michaldudak added a commit to michaldudak/base-ui that referenced this pull request Sep 2, 2026
Brings in 13 commits up to 2e1bddd, notably mui#5613 ([combobox][select] Make
the multiple selection anchor lookup linear), which overlaps this branch's
Select and Combobox selection logic.

Conflicts resolved:
- combobox/root/AriaCombobox.tsx — kept mui#5613's toggled-index anchor lookup,
  wrapped in this branch's `updateActiveIndexState` helper; both the new
  `clearedBySelection` branch and this branch's disabled-aware always-highlight
  branch preserved.
- combobox/item/ComboboxItem.tsx — combined mui#5613's `resolveSelectedIndex`
  import with this branch's virtualization imports.
- internals/resolveValueLabel.test.ts — combined this branch's added exports
  with upstream's vitest-import refactor.

Merge follow-ups:
- select/root/SelectRoot.tsx — the import auto-merge dropped `findItemIndex`
  (still used by the virtualized prune) in favor of mui#5613's `findSelectionIndex`;
  both are now imported.
- Added vitest global imports across the branch's test files and hoisted one
  env-conditional expect out of an if/else — upstream tightened
  `vitest/prefer-importing-vitest-globals` and `vitest/no-conditional-expect`.

Gates green: typecheck, eslint, prettier; jsdom Select/Virtualizer/Combobox/
Autocomplete/resolveValueLabel; Chromium Select (only the pre-existing focus
flake), Combobox, Virtualizer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
michaldudak added a commit to michaldudak/base-ui that referenced this pull request Sep 2, 2026
Brings in 13 commits up to 2e1bddd, notably mui#5613 ([combobox][select] Make
the multiple selection anchor lookup linear), which overlaps this branch's
Select and Combobox selection logic.

Conflicts resolved:
- combobox/root/AriaCombobox.tsx — kept mui#5613's toggled-index anchor lookup,
  wrapped in this branch's `updateActiveIndexState` helper; both the new
  `clearedBySelection` branch and this branch's disabled-aware always-highlight
  branch preserved.
- combobox/item/ComboboxItem.tsx — combined mui#5613's `resolveSelectedIndex`
  import with this branch's virtualization imports.
- internals/resolveValueLabel.test.ts — combined this branch's added exports
  with upstream's vitest-import refactor.
- select/root/SelectRoot.tsx — the import auto-merge dropped `findItemIndex`
  (still used by the virtualized prune) in favor of mui#5613's
  `findSelectionIndex`; both are imported.
- Vitest global imports added across this branch's test files and one
  env-conditional expect hoisted out of an if/else, for upstream's tightened
  `vitest/prefer-importing-vitest-globals` and `vitest/no-conditional-expect`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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: select Changes related to the select component. performance type: regression A bug, but worse, it used to behave as expected.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants