@@ -49,6 +49,10 @@ import { NOOP } from '../../internals/noop';
4949import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups' ;
5050import { mergeProps } from '../../merge-props' ;
5151import {
52+ findItemIndexByValue ,
53+ inferItemValue ,
54+ resolveLabelString ,
55+ resolveRenderedValue ,
5256 stringifyAsLabel ,
5357 stringifyAsValue ,
5458 Group ,
@@ -175,6 +179,12 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
175179 const single = selectionMode === 'single' ;
176180 const hasInputValue = inputValueProp !== undefined || defaultInputValueProp !== undefined ;
177181 const hasItems = items !== undefined ;
182+
183+ // Resolving a primitive selected value to its `{ value, label }` item's label is only
184+ // supported for non-virtualized lists (virtualized selection sync matches whole items),
185+ // so virtualized lists resolve labels from the value's own shape to keep the displayed
186+ // label consistent with what can actually be highlighted.
187+ const labelItems = virtualized ? undefined : items ;
178188 const hasFilteredItemsProp = filteredItemsProp !== undefined ;
179189
180190 let autoHighlightMode : false | 'input-change' | 'always' ;
@@ -212,7 +222,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
212222 return defaultInputValueProp ?? '' ;
213223 }
214224 if ( single ) {
215- return stringifyAsLabel ( selectedValue , itemToStringLabel ) ;
225+ return resolveLabelString ( selectedValue , labelItems , itemToStringLabel , isItemEqualToValue ) ;
216226 }
217227 return '' ;
218228 } ,
@@ -235,7 +245,15 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
235245 const isGrouped = isGroupedItems ( items ) ;
236246 const query = closeQuery ?? ( inputValue === '' ? '' : String ( inputValue ) . trim ( ) ) ;
237247
238- const selectedLabelString = single ? stringifyAsLabel ( selectedValue , itemToStringLabel ) : '' ;
248+ // Resolving the label can scan `items`, so memoize to avoid repeating it on every
249+ // render (e.g. each keystroke) when the selection and items are unchanged.
250+ const selectedLabelString = React . useMemo (
251+ ( ) =>
252+ single
253+ ? resolveLabelString ( selectedValue , labelItems , itemToStringLabel , isItemEqualToValue )
254+ : '' ,
255+ [ single , selectedValue , labelItems , itemToStringLabel , isItemEqualToValue ] ,
256+ ) ;
239257
240258 const shouldBypassFiltering =
241259 single &&
@@ -306,12 +324,11 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
306324 if ( filterQuery === '' ) {
307325 return limit > - 1
308326 ? flatItems . slice ( 0 , limit )
309- : // The cast here is done as `flatItems` is readonly.
310- // valuesRef.current, a mutable ref, can be set to `flatFilteredItems`, which may
311- // reference this exact readonly value, creating a mutation risk.
312- // However, <Combobox.Item> can never mutate this value as the mutating effect
313- // bails early when `items` is provided, and this is only ever returned
314- // when `items` is provided due to the early return at the top of this hook.
327+ : // The cast here is done as `flatItems` is readonly. Reusing the prop's array is
328+ // safe: in the non-virtualized path `valuesRef` is a distinct array (mounted
329+ // items write their own slots and the root effect fills the rest), and in the
330+ // virtualized path `valuesRef` aliases this array but is only ever read by index,
331+ // never mutated.
315332 ( flatItems as Value [ ] ) ;
316333 }
317334
@@ -461,12 +478,15 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
461478 nameProp ,
462479 ) ;
463480
464- const forceMount = useStableCallback ( ( ) => {
481+ const forceMount = useStableCallback ( ( renderItems = false ) => {
465482 if ( items ) {
466483 // Ensure typeahead works on a closed list.
467484 labelsRef . current = flatFilteredItems . map ( ( item ) =>
468485 stringifyAsLabel ( item , itemToStringLabel ) ,
469486 ) ;
487+ if ( renderItems && ! virtualized ) {
488+ store . set ( 'forceMounted' , true ) ;
489+ }
470490 } else {
471491 store . set ( 'forceMounted' , true ) ;
472492 }
@@ -647,7 +667,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
647667
648668 if ( shouldFillInput ) {
649669 setInputValue (
650- stringifyAsLabel ( nextValue , itemToStringLabel ) ,
670+ resolveLabelString ( nextValue , labelItems , itemToStringLabel , isItemEqualToValue ) ,
651671 createChangeEventDetails ( eventDetails . reason , eventDetails . event ) ,
652672 ) ;
653673 }
@@ -771,7 +791,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
771791 setInputValue ( '' , createChangeEventDetails ( REASONS . inputClear ) ) ;
772792 }
773793 } else {
774- const stringVal = stringifyAsLabel ( selectedValue , itemToStringLabel ) ;
794+ const stringVal = selectedLabelString ;
775795 if ( inputRef . current && inputRef . current . value !== stringVal ) {
776796 // If no selection was made, treat this as clearing the typed filter.
777797 const reason = stringVal === '' ? REASONS . inputClear : REASONS . none ;
@@ -805,42 +825,74 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
805825
806826 React . useImperativeHandle ( props . actionsRef , ( ) => ( { unmount : handleUnmount } ) , [ handleUnmount ] ) ;
807827
828+ const navigationValue =
829+ multiple && Array . isArray ( selectedValue )
830+ ? selectedValue [ selectedValue . length - 1 ]
831+ : selectedValue ;
832+
833+ // Derive the selected value's index in the (unfiltered) `items`. Non-virtualized
834+ // lists can match the full item (object `value={item}`) or its inferred value
835+ // (primitive `value` against a `{ value, label }` entry). Only the closed state is
836+ // consumed (the syncing effect bails while open), so skip the scan while open to
837+ // avoid rescanning large lists on every selection change during open interactions.
838+ const itemsSelectedIndex = React . useMemo ( ( ) => {
839+ if ( open || selectionMode === 'none' || ! items ) {
840+ return - 1 ;
841+ }
842+ if ( virtualized ) {
843+ return findItemIndex ( flatItems , navigationValue , isItemEqualToValue ) ;
844+ }
845+ return findItemIndexByValue ( flatItems , navigationValue , isItemEqualToValue ) ;
846+ } , [ open , selectionMode , items , virtualized , flatItems , navigationValue , isItemEqualToValue ] ) ;
847+
808848 useIsoLayoutEffect (
809849 function syncSelectedIndex ( ) {
850+ // Snapshot the selected index only while the popup is closed, so selecting,
851+ // deselecting, clearing, or filtering while open doesn't move the highlight off
852+ // the active item. Drives initial highlight and scroll-into-view on open.
810853 if ( open || selectionMode === 'none' ) {
811854 return ;
812855 }
813856
814- const registry = items ? flatItems : allValuesRef . current ;
815-
816- if ( multiple ) {
817- const currentValue = Array . isArray ( selectedValue ) ? selectedValue : [ ] ;
818- const lastValue = currentValue [ currentValue . length - 1 ] ;
819- const lastIndex = findItemIndex ( registry , lastValue , isItemEqualToValue ) ;
820- setIndices ( { selectedIndex : lastIndex === - 1 ? null : lastIndex } ) ;
821- } else {
822- const index = findItemIndex ( registry , selectedValue , isItemEqualToValue ) ;
823- setIndices ( { selectedIndex : index === - 1 ? null : index } ) ;
824- }
857+ // With `items` the index is the derived value above; without `items`, match
858+ // against the values registered by mounted items.
859+ const index = items
860+ ? itemsSelectedIndex
861+ : findItemIndex ( allValuesRef . current , navigationValue , isItemEqualToValue ) ;
862+ setIndices ( { selectedIndex : index === - 1 ? null : index } ) ;
825863 } ,
826864 [
827865 open ,
828- selectedValue ,
829- items ,
830866 selectionMode ,
831- flatItems ,
832- multiple ,
867+ items ,
868+ itemsSelectedIndex ,
869+ navigationValue ,
833870 isItemEqualToValue ,
834871 setIndices ,
835872 ] ,
836873 ) ;
837874
838875 useIsoLayoutEffect ( ( ) => {
839876 if ( items ) {
840- valuesRef . current = flatFilteredItems ;
877+ if ( virtualized ) {
878+ valuesRef . current = flatFilteredItems ;
879+ listRef . current . length = flatFilteredItems . length ;
880+ return ;
881+ }
882+
883+ // Mounted items register their own rendered value (preserving its exact shape).
884+ // Fill any remaining slots in place with an inferred value so the registry stays
885+ // complete without clobbering those writes.
886+ const visibleMap = valuesRef . current ;
887+ visibleMap . length = flatFilteredItems . length ;
888+ for ( let index = 0 ; index < flatFilteredItems . length ; index += 1 ) {
889+ if ( visibleMap [ index ] === undefined ) {
890+ visibleMap [ index ] = inferItemValue ( flatFilteredItems [ index ] ) ;
891+ }
892+ }
841893 listRef . current . length = flatFilteredItems . length ;
842894 }
843- } , [ items , flatFilteredItems ] ) ;
895+ } , [ items , virtualized , flatFilteredItems ] ) ;
844896
845897 useIsoLayoutEffect ( ( ) => {
846898 const pendingHighlight = pendingQueryHighlightRef . current ;
@@ -890,7 +942,13 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
890942 return ;
891943 }
892944
893- const itemValue = candidateItems [ storeActiveIndex ] ;
945+ const candidateItem = candidateItems [ storeActiveIndex ] ;
946+ // Surface the rendered item value so highlight callbacks report the same shape used
947+ // for selection. Non-virtualized lists can fall back to the inferred value for any
948+ // slot not registered by a mounted item; virtualized lists keep the item shape.
949+ const itemValue = hasItems
950+ ? resolveRenderedValue ( valuesRef . current [ storeActiveIndex ] , candidateItem , virtualized )
951+ : candidateItem ;
894952 const previouslyHighlightedItemValue = lastHighlightRef . current . value ;
895953 const isSameItem =
896954 previouslyHighlightedItemValue !== NO_ACTIVE_VALUE &&
@@ -916,6 +974,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
916974 inline ,
917975 open ,
918976 store ,
977+ virtualized ,
919978 ] ) ;
920979
921980 useIsoLayoutEffect ( ( ) => {
@@ -966,7 +1025,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
9661025 validation . change ( selectedValue ) ;
9671026
9681027 if ( single && ! hasInputValue && ! inputInsidePopup ) {
969- const nextInputValue = stringifyAsLabel ( selectedValue , itemToStringLabel ) ;
1028+ const nextInputValue = selectedLabelString ;
9701029
9711030 if ( inputValue !== nextInputValue ) {
9721031 setInputValue ( nextInputValue , createChangeEventDetails ( REASONS . none ) ) ;
@@ -990,7 +1049,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
9901049 return ;
9911050 }
9921051
993- const nextInputValue = stringifyAsLabel ( selectedValue , itemToStringLabel ) ;
1052+ const nextInputValue = selectedLabelString ;
9941053
9951054 if ( inputValue !== nextInputValue ) {
9961055 setInputValue ( nextInputValue , createChangeEventDetails ( REASONS . none ) ) ;
@@ -1529,6 +1588,12 @@ interface ComboboxRootProps<ItemValue> {
15291588 /**
15301589 * The items to be displayed in the list.
15311590 * Can be either a flat array of items or an array of groups with items.
1591+ * In non-virtualized lists, when items use the `{ value, label }` shape, a primitive
1592+ * `<Combobox.Item value>` resolves its text label from the matching item. In single
1593+ * selection mode `<Combobox.Value>` and the input render that label instead of the raw
1594+ * value; in multiple selection mode the input stays the query field, so only
1595+ * `<Combobox.Value>` renders the selected labels.
1596+ * Virtualized lists do not support this primitive value inference.
15321597 */
15331598 items ?: readonly any [ ] | readonly Group < any > [ ] | undefined ;
15341599 /**
0 commit comments