[combobox] Support primitive item values#4985
Conversation
16898c7 to
914e245
Compare
commit: |
✅ Deploy Preview for base-ui ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Bundle size
PerformanceTotal duration: 1,074.98 ms -126.62 ms(-10.5%) | Renders: 78 (+0) | Paint: 1,691.22 ms -208.89 ms(-11.0%)
14 tests within noise — details 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. |
88cfc19 to
ce8c0fb
Compare
flaviendelangle
left a comment
There was a problem hiding this comment.
Not sure if you added the renderItems param for a future change or if Claude Code is just wrong 😆
PR review
This PR adds primitive-value inference for { value, label } combobox items. The core change — routing label/index/highlight resolution through the new resolveValueLabel helpers — is well-structured, backward-compatible (whole-item and inline-children paths are preserved), and SSR-safe. Nothing is merge-blocking. The one actionable item is dead code left over from an earlier approach; the test matrix is strong and the docs are accurate. Verification was static (I did not run the suite against the working tree to avoid disturbing unrelated uncommitted changes).
Bugs (1)
1. ℹ️ In-place valuesRef mutation assumes virtualized never toggles at runtime
Location: packages/react/src/combobox/root/AriaCombobox.tsx:878-894
if (virtualized) {
valuesRef.current = flatFilteredItems; // aliases the readonly items/prop array
...
return;
}
// non-virtualized: mutate valuesRef.current in place
const visibleMap = valuesRef.current;
visibleMap.length = flatFilteredItems.length;
for (...) { if (visibleMap[index] === undefined) visibleMap[index] = inferItemValue(...); }The new non-virtualized branch mutates valuesRef.current in place, relying on the comment's claim that it is "a distinct array." That holds as long as virtualized is stable. If virtualized flips true → false during the component's life, valuesRef.current would still be the readonly items/flatFilteredItems array aliased by the virtualized branch, and the next non-virtualized commit would mutate the user's prop array (visibleMap.length = …, visibleMap[index] = …).
Failure scenario: A consumer that toggles virtualized at runtime gets their items array length/contents mutated in place. This is a narrow, effectively-unsupported pattern (structural prop), so it's a heads-up rather than a blocker — but the safety comment the PR adds (lines 326-331) doesn't cover it.
Fix: If toggling virtualized is not supported, no code change is needed — just narrow the comment to say the distinct-array invariant depends on virtualized being stable. Otherwise, reset valuesRef.current = [] when entering the non-virtualized branch before filling it.
Tests (0)
No findings.
Simplifications (1)
1. 🟡 Dead renderItems parameter on combobox forceMount
Location: packages/react/src/combobox/root/AriaCombobox.tsx:481-489
const forceMount = useStableCallback((renderItems = false) => {
if (items) {
labelsRef.current = flatFilteredItems.map((item) => stringifyAsLabel(item, itemToStringLabel));
if (renderItems && !virtualized) {
store.set('forceMounted', true); // never reached
}
} else {
store.set('forceMounted', true);
}
});forceMount gained a renderItems parameter and the store type was widened to forceMount: (renderItems?: boolean) => void (store.ts:75), but no call site ever passes true. The three call sites (AriaCombobox.tsx:499, :1413, ComboboxTrigger.tsx:222) call forceMount(), and focusTimeout.start(0, store.state.forceMount) (ComboboxTrigger.tsx:195) invokes it with no arguments. So the if (renderItems && !virtualized) branch is unreachable.
Failure scenario: Unused public-surface (store type) and a dead branch ship as maintenance debt; a future reader has to reverse-engineer whether the closed-list typeahead path is meant to force-mount items (it isn't — valuesRef is filled by the root effect via inferItemValue, so mounting is unnecessary).
Fix: Remove the renderItems parameter and the dead branch, and revert the store.ts type to forceMount: () => void. (If trigger-focus force-mounting was intended, wire focusTimeout.start(0, () => store.state.forceMount(true)) instead — but the current valuesRef fill makes it redundant.)
Docs (0)
No findings. The page.mdx, types.md, and items JSDoc updates accurately describe the new non-virtualized-only inference and the single- vs multiple-selection rendering behavior.
Verdict
Approve after nits — no correctness regressions found; the only real cleanup is the dead renderItems parameter, and the valuesRef mutation note is a narrow heads-up.
🤖 Review generated with Claude Code
7edec3e to
a601bc3
Compare
d474ed3 to
7263295
Compare
a91b762 to
fb444ed
Compare
Alternative to #4686. Partially addresses #3594. Related to #4419 and #4735.
Adds an
itemToValueprop to<Combobox.Root>so objects initemscan use a different value for selection:https://deploy-preview-4985--base-ui.netlify.app/react/components/combobox#item-values
itemToValueis needed because the root uses item values before<Combobox.Item>mounts, including for filtering, closed-state typeahead, autofill, and virtualized lists. Reading the value from<Combobox.Item>happens too late, depends on mount timing and object identity, and cannot cover virtualized items that never mount.The other alternatives do not work here. Requiring items and values to have the same shape removes this use case, standardizing on
{ value, label }is too restrictive, and putting the getter on a descendant still leaves the root without the value when it needs it.Filtering and rendering continue to use the source item; selection state, callbacks, and comparators use the projected value. The source item type is inferred separately from the selected value type, and the same behavior applies to virtualized lists.