Skip to content

Commit cf0859b

Browse files
committed
fix: listbox optimizations
1 parent 9e0f81c commit cf0859b

File tree

2 files changed

+61
-230
lines changed

2 files changed

+61
-230
lines changed

src/components/fields/ComboBox/ComboBox.tsx

Lines changed: 16 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Key } from '@react-types/shared';
22
import React, {
3-
Children,
43
cloneElement,
54
ForwardedRef,
65
forwardRef,
@@ -993,31 +992,11 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
993992
const listStateRef = useRef<any>(null);
994993
const focusInitAttemptsRef = useRef(0);
995994

996-
// Find item by key to get its label
997-
const findItemByKey = useCallback(
998-
(key: Key): any => {
999-
let foundItem: any = null;
1000-
1001-
const traverse = (nodes: ReactNode): void => {
1002-
Children.forEach(nodes, (child: any) => {
1003-
if (!child || typeof child !== 'object') return;
1004-
1005-
if (child.type === Item && String(child.key) === String(key)) {
1006-
foundItem = child;
1007-
}
1008-
1009-
if (child.props?.children) {
1010-
traverse(child.props.children);
1011-
}
1012-
});
1013-
};
1014-
1015-
if (children) traverse(children);
1016-
1017-
return foundItem;
1018-
},
1019-
[children],
1020-
);
995+
// Helper to get label from collection item
996+
const getItemLabel = useCallback((key: Key): string => {
997+
const item = listStateRef.current?.collection?.getItem(key);
998+
return item?.textValue || String(key);
999+
}, []);
10211000

10221001
// Selection change handler
10231002
const handleSelectionChange = useEvent((selection: Key | Key[] | null) => {
@@ -1033,13 +1012,7 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
10331012
if (key != null) {
10341013
setIsFilterActive(false);
10351014

1036-
const selectedItem = findItemByKey(key);
1037-
const label =
1038-
selectedItem?.props?.textValue ||
1039-
(typeof selectedItem?.props?.children === 'string'
1040-
? selectedItem.props.children
1041-
: '') ||
1042-
String(key);
1015+
const label = getItemLabel(key);
10431016

10441017
if (!isControlledInput) {
10451018
setInternalInputValue(label);
@@ -1102,23 +1075,18 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
11021075
// Only initialize once, in uncontrolled input mode, when defaultSelectedKey is provided
11031076
if (hasInitialized || isControlledInput || !defaultSelectedKey) return;
11041077

1105-
const selectedItem = findItemByKey(defaultSelectedKey);
1106-
if (selectedItem) {
1107-
const label =
1108-
selectedItem?.props?.textValue ||
1109-
(typeof selectedItem?.props?.children === 'string'
1110-
? selectedItem.props.children
1111-
: '') ||
1112-
String(defaultSelectedKey);
1113-
1114-
setInternalInputValue(label);
1115-
setHasInitialized(true);
1116-
}
1078+
// Wait for collection to be ready
1079+
if (!listStateRef.current?.collection) return;
1080+
1081+
const label = getItemLabel(defaultSelectedKey);
1082+
1083+
setInternalInputValue(label);
1084+
setHasInitialized(true);
11171085
}, [
11181086
hasInitialized,
11191087
isControlledInput,
11201088
defaultSelectedKey,
1121-
findItemByKey,
1089+
getItemLabel,
11221090
children,
11231091
]);
11241092

@@ -1165,15 +1133,8 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
11651133
}
11661134

11671135
// Reset input to show current selection (or empty if none)
1168-
const selectedItem =
1169-
effectiveSelectedKey != null ? findItemByKey(effectiveSelectedKey) : null;
1170-
const nextValue = selectedItem
1171-
? selectedItem.props?.textValue ||
1172-
(typeof selectedItem.props?.children === 'string'
1173-
? selectedItem.props.children
1174-
: '') ||
1175-
String(effectiveSelectedKey)
1176-
: '';
1136+
const nextValue =
1137+
effectiveSelectedKey != null ? getItemLabel(effectiveSelectedKey) : '';
11771138

11781139
if (!isControlledInput) {
11791140
setInternalInputValue(nextValue);

src/components/fields/FilterPicker/FilterPicker.tsx

Lines changed: 45 additions & 175 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
Section as BaseSection,
1919
ListState,
2020
Item as ReactAriaItem,
21+
useListState,
2122
} from 'react-stately';
2223

2324
import { useEvent } from '../../../_internal';
@@ -325,65 +326,55 @@ export const FilterPicker = forwardRef(function FilterPicker<T extends object>(
325326
return str.replace(/=2/g, ':').replace(/=0/g, '=');
326327
};
327328

329+
// Create a local collection for label extraction only (not for rendering)
330+
// This gives us immediate access to textValue without waiting for FilterListBox
331+
const localCollectionState = useListState({
332+
children: children as any,
333+
items: items as any,
334+
selectionMode: 'none' as any, // We don't need selection management
335+
});
336+
328337
// ---------------------------------------------------------------------------
329-
// Map public-facing keys (without React's "." prefix) to the actual React
330-
// element keys that appear in the collection (which usually have the `.$`
331-
// or `.` prefix added by React when children are in an array). This ensures
332-
// that the key we pass to ListBox exactly matches the keys it receives from
333-
// React Aria, so the initial selection is highlighted correctly.
338+
// Map user-provided keys to collection keys using the local collection.
339+
// The local collection handles key normalization, so we search for matching
340+
// items by comparing normalized keys.
334341
// ---------------------------------------------------------------------------
335342

336-
const findReactKey = useCallback(
343+
const findCollectionKey = useCallback(
337344
(lookup: Key): Key => {
338345
if (lookup == null) return lookup;
339346

340347
const normalizedLookup = normalizeKeyValue(lookup);
341-
let foundKey: Key = lookup;
342-
343-
const traverse = (nodes: ReactNode): void => {
344-
Children.forEach(nodes, (child: ReactNode) => {
345-
if (!child || typeof child !== 'object') return;
346-
const element = child as ReactElement;
347-
348-
if (element.key != null) {
349-
if (normalizeKeyValue(element.key) === normalizedLookup) {
350-
foundKey = element.key;
351-
}
352-
}
353-
354-
if (
355-
element.props &&
356-
typeof element.props === 'object' &&
357-
'children' in element.props
358-
) {
359-
traverse((element.props as any).children);
360-
}
361-
});
362-
};
363-
364-
if (children) traverse(children as ReactNode);
348+
for (const item of localCollectionState.collection) {
349+
if (normalizeKeyValue(item.key) === normalizedLookup) {
350+
return item.key;
351+
}
352+
}
365353

366-
return foundKey;
354+
// Fallback: return the lookup key as-is
355+
return lookup;
367356
},
368-
[children],
357+
[localCollectionState.collection],
369358
);
370359

371360
const mappedSelectedKey = useMemo(() => {
372361
if (selectionMode !== 'single') return null;
373-
return effectiveSelectedKey ? findReactKey(effectiveSelectedKey) : null;
374-
}, [selectionMode, effectiveSelectedKey, findReactKey]);
362+
return effectiveSelectedKey
363+
? findCollectionKey(effectiveSelectedKey)
364+
: null;
365+
}, [selectionMode, effectiveSelectedKey, findCollectionKey]);
375366

376367
const mappedSelectedKeys = useMemo(() => {
377368
if (selectionMode !== 'multiple') return undefined;
378369

379370
if (effectiveSelectedKeys === 'all') return 'all' as const;
380371

381372
if (Array.isArray(effectiveSelectedKeys)) {
382-
return (effectiveSelectedKeys as Key[]).map((k) => findReactKey(k));
373+
return (effectiveSelectedKeys as Key[]).map((k) => findCollectionKey(k));
383374
}
384375

385376
return effectiveSelectedKeys;
386-
}, [selectionMode, effectiveSelectedKeys, findReactKey]);
377+
}, [selectionMode, effectiveSelectedKeys, findCollectionKey]);
387378

388379
// Given an iterable of keys (array or Set) toggle membership for duplicates
389380
const processSelectionArray = (iterable: Iterable<Key>): string[] => {
@@ -399,79 +390,18 @@ export const FilterPicker = forwardRef(function FilterPicker<T extends object>(
399390
return Array.from(resultSet);
400391
};
401392

402-
// Helper to get selected item labels for display
393+
// Helper to get selected item labels for display using local collection
403394
const getSelectedLabels = () => {
395+
const collection = localCollectionState.collection;
396+
404397
// Handle "all" selection - return all available labels
405398
if (selectionMode === 'multiple' && effectiveSelectedKeys === 'all') {
406399
const allLabels: string[] = [];
407-
408-
// Extract from items prop if available
409-
if (items) {
410-
const extractFromItems = (itemsArray: unknown[]): void => {
411-
itemsArray.forEach((item) => {
412-
if (item && typeof item === 'object') {
413-
const itemObj = item as ItemWithKey;
414-
if (Array.isArray(itemObj.children)) {
415-
// Section-like object
416-
extractFromItems(itemObj.children);
417-
} else {
418-
// Regular item - extract label
419-
const label =
420-
itemObj.textValue ||
421-
(itemObj as any).label ||
422-
(typeof (itemObj as any).children === 'string'
423-
? (itemObj as any).children
424-
: '') ||
425-
String(
426-
(itemObj as any).children ||
427-
itemObj.key ||
428-
itemObj.id ||
429-
item,
430-
);
431-
allLabels.push(label);
432-
}
433-
}
434-
});
435-
};
436-
437-
const itemsArray = Array.isArray(items)
438-
? items
439-
: Array.from(items as Iterable<unknown>);
440-
extractFromItems(itemsArray);
441-
return allLabels;
442-
}
443-
444-
// Extract from children if available
445-
if (children) {
446-
const extractAllLabels = (nodes: ReactNode): void => {
447-
if (!nodes) return;
448-
Children.forEach(nodes, (child: ReactNode) => {
449-
if (!child || typeof child !== 'object') return;
450-
const element = child as ReactElement;
451-
452-
if (element.type === ReactAriaItem) {
453-
const props = element.props as any;
454-
const label =
455-
props.textValue ||
456-
(typeof props.children === 'string' ? props.children : '') ||
457-
String(props.children || '');
458-
allLabels.push(label);
459-
}
460-
461-
if (
462-
element.props &&
463-
typeof element.props === 'object' &&
464-
'children' in element.props
465-
) {
466-
extractAllLabels((element.props as any).children);
467-
}
468-
});
469-
};
470-
471-
extractAllLabels(children as ReactNode);
472-
return allLabels;
400+
for (const item of collection) {
401+
if (item.type === 'item') {
402+
allLabels.push(item.textValue || String(item.key));
403+
}
473404
}
474-
475405
return allLabels;
476406
}
477407

@@ -486,86 +416,26 @@ export const FilterPicker = forwardRef(function FilterPicker<T extends object>(
486416
const labels: string[] = [];
487417
const processedKeys = new Set<string>();
488418

489-
// Extract from items prop if available
490-
if (items) {
491-
const extractFromItems = (itemsArray: unknown[]): void => {
492-
itemsArray.forEach((item) => {
493-
if (item && typeof item === 'object') {
494-
const itemObj = item as ItemWithKey;
495-
if (Array.isArray(itemObj.children)) {
496-
// Section-like object
497-
extractFromItems(itemObj.children);
498-
} else {
499-
// Regular item - check if selected
500-
const itemKey = itemObj.key || itemObj.id;
501-
if (
502-
itemKey != null &&
503-
selectedSet.has(normalizeKeyValue(itemKey))
504-
) {
505-
const label =
506-
itemObj.textValue ||
507-
(itemObj as any).label ||
508-
(typeof (itemObj as any).children === 'string'
509-
? (itemObj as any).children
510-
: '') ||
511-
String((itemObj as any).children || itemKey);
512-
labels.push(label);
513-
processedKeys.add(normalizeKeyValue(itemKey));
514-
}
515-
}
516-
}
517-
});
518-
};
519-
520-
const itemsArray = Array.isArray(items)
521-
? items
522-
: Array.from(items as Iterable<unknown>);
523-
extractFromItems(itemsArray);
524-
}
525-
526-
// Extract from children if available (for mixed mode or fallback)
527-
if (children) {
528-
const extractLabelsWithTracking = (nodes: ReactNode): void => {
529-
if (!nodes) return;
530-
Children.forEach(nodes, (child: ReactNode) => {
531-
if (!child || typeof child !== 'object') return;
532-
const element = child as ReactElement;
533-
534-
if (element.type === ReactAriaItem) {
535-
const childKey = String(element.key);
536-
if (selectedSet.has(normalizeKeyValue(childKey))) {
537-
const props = element.props as any;
538-
const label =
539-
props.textValue ||
540-
(typeof props.children === 'string' ? props.children : '') ||
541-
String(props.children || '');
542-
labels.push(label);
543-
processedKeys.add(normalizeKeyValue(childKey));
544-
}
545-
}
546-
547-
if (
548-
element.props &&
549-
typeof element.props === 'object' &&
550-
'children' in element.props
551-
) {
552-
extractLabelsWithTracking((element.props as any).children);
553-
}
554-
});
555-
};
556-
557-
extractLabelsWithTracking(children as ReactNode);
419+
// Use collection to get labels for selected items
420+
for (const item of collection) {
421+
if (
422+
item.type === 'item' &&
423+
selectedSet.has(normalizeKeyValue(item.key))
424+
) {
425+
labels.push(item.textValue || String(item.key));
426+
processedKeys.add(normalizeKeyValue(item.key));
427+
}
558428
}
559429

560-
// Handle custom values that don't have corresponding items/children
430+
// Handle custom values that aren't in the collection
561431
const selectedKeysArr =
562432
selectionMode === 'multiple' && effectiveSelectedKeys !== 'all'
563433
? (effectiveSelectedKeys || []).map(String)
564434
: effectiveSelectedKey != null
565435
? [String(effectiveSelectedKey)]
566436
: [];
567437

568-
// Add labels for any selected keys that weren't processed (custom values)
438+
// Add labels for any selected keys that weren't found in collection (custom values)
569439
selectedKeysArr.forEach((key) => {
570440
if (!processedKeys.has(normalizeKeyValue(key))) {
571441
// This is a custom value, use the key as the label

0 commit comments

Comments
 (0)