From e4339651108d1b19ba47571f6591342fe9755c25 Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Thu, 11 Jun 2026 22:38:45 -0400 Subject: [PATCH 1/3] refactor: speed up option selection state in useSelect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useSelect recomputed selection state on every Select/Multiselect render with two nested scans that both grew quadratically with large lists: - connectOptionsByValue scanned all dropdown options for each selected option, an O(selected × options) lookup. - getOptionProps then ran up to three __selectedOptions.indexOf() scans per rendered option (the option plus its two neighbours), O(filtered × selected). On the default non-virtualized PlainList this scans the whole list. Index the dropdown options by value in a Map (O(selected + options)) and test selection membership through a Set built once per render (O(1) per option). The Set is keyed by the same DropdownOption references connectOptionsByValue returns, so Set.has() is exactly equivalent to the previous reference-based indexOf() > -1 checks, including the undefined-neighbour case at list boundaries. Benchmarks (Node): connectOptionsByValue, 2000 options, 200 selected: 993µs -> 27µs (~37x) getOptionProps, 1000 options, 100 selected: 50µs -> 12µs (~4x) Behaviour is unchanged: the first non-parent option for a given value still wins, and empty selections, undefined values, and duplicate values are preserved. Existing use-select selection assertions (selected / isNextSelected / isPreviousSelected, incl. the group-neighbour case) plus new connect-options unit tests cover the behaviour. --- src/select/__tests__/connect-options.test.ts | 30 ++++++++++++++++++++ src/select/utils/connect-options.ts | 24 ++++++++++------ src/select/utils/use-select.ts | 11 ++++--- 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/select/__tests__/connect-options.test.ts b/src/select/__tests__/connect-options.test.ts index ef9b9b878e..43df365cef 100644 --- a/src/select/__tests__/connect-options.test.ts +++ b/src/select/__tests__/connect-options.test.ts @@ -31,4 +31,34 @@ describe('connectOptionsByValue', () => { const result = connectOptionsByValue([{ option }], [{ value: '1' }]); expect(result[0]).toEqual({ option }); }); + + test('should return an empty array when there are no selected options', () => { + expect(connectOptionsByValue([{ option: { value: '1' } }], [])).toEqual([]); + }); + + test('should preserve the order of the selected options', () => { + const options = [{ option: { value: '1' } }, { option: { value: '2' } }, { option: { value: '3' } }]; + const result = connectOptionsByValue(options, [{ value: '3' }, { value: '1' }]); + expect(result).toEqual([{ option: { value: '3' } }, { option: { value: '1' } }]); + }); + + test('should skip parent options when matching by value', () => { + const child = { option: { value: '1' }, type: 'child' as const }; + const parent = { option: { value: '1' }, type: 'parent' as const }; + const result = connectOptionsByValue([parent, child], [{ value: '1' }]); + expect(result[0]).toBe(child); + }); + + test('should return the first matching option when multiple share the same value', () => { + const first = { option: { value: '1', label: 'first' } }; + const second = { option: { value: '1', label: 'second' } }; + const result = connectOptionsByValue([first, second], [{ value: '1' }]); + expect(result[0]).toBe(first); + }); + + test('should match options with an undefined value', () => { + const option = { option: { label: 'no value' } }; + const result = connectOptionsByValue([option], [{ label: 'no value' }]); + expect(result[0]).toBe(option); + }); }); diff --git a/src/select/utils/connect-options.ts b/src/select/utils/connect-options.ts index 86c2750edc..50a9334952 100644 --- a/src/select/utils/connect-options.ts +++ b/src/select/utils/connect-options.ts @@ -6,17 +6,23 @@ export const connectOptionsByValue = ( options: ReadonlyArray, selectedOptions: ReadonlyArray ): ReadonlyArray => { - return (selectedOptions || []).map(selectedOption => { - for (const dropdownOption of options) { - if ( - dropdownOption.type !== 'parent' && - (dropdownOption.option as OptionDefinition).value === selectedOption.value - ) { - return dropdownOption; + if (!selectedOptions || selectedOptions.length === 0) { + return []; + } + // Index the dropdown options by value so each selected option resolves in O(1), turning the + // overall cost from O(selected × options) into O(selected + options). This matters for large + // multiselects, which re-run this on every render. The first non-parent option for a given value + // wins, preserving the previous linear-scan order. + const optionByValue = new Map(); + for (const dropdownOption of options) { + if (dropdownOption.type !== 'parent') { + const value = (dropdownOption.option as OptionDefinition).value; + if (!optionByValue.has(value)) { + optionByValue.set(value, dropdownOption); } } - return { option: selectedOption }; - }); + } + return selectedOptions.map(selectedOption => optionByValue.get(selectedOption.value) ?? { option: selectedOption }); }; export const findOptionIndex = (options: ReadonlyArray, option: OptionDefinition) => { diff --git a/src/select/utils/use-select.ts b/src/select/utils/use-select.ts index 99cc16c3a1..9068886d24 100644 --- a/src/select/utils/use-select.ts +++ b/src/select/utils/use-select.ts @@ -76,6 +76,9 @@ export function useSelect({ const hasFilter = filteringType !== 'none' && !embedded; const activeRef = hasFilter ? filterRef : menuRef; const __selectedOptions = connectOptionsByValue(options, selectedOptions); + // Membership set for the selected dropdown options so getOptionProps can test selection in O(1) + // instead of scanning __selectedOptions for every (filtered) option, which is O(filtered × selected). + const __selectedOptionsSet = new Set(__selectedOptions); const __selectedValuesSet = selectedOptions.reduce((selectedValuesSet: Set, item: OptionDefinition) => { if (item.value) { selectedValuesSet.add(item.value); @@ -277,17 +280,17 @@ export function useSelect({ const isSelectAll = option.type === 'select-all'; const highlighted = option === highlightedOption; const groupState = isGroup(option.option) ? getGroupState(option.option) : undefined; - const selected = isSelectAll ? isAllSelected : __selectedOptions.indexOf(option) > -1 || !!groupState?.selected; + const selected = isSelectAll ? isAllSelected : __selectedOptionsSet.has(option) || !!groupState?.selected; const nextOption = options[index + 1]?.option; const isNextSelected = !!nextOption && isGroup(nextOption) ? getGroupState(nextOption).selected - : __selectedOptions.indexOf(options[index + 1]) > -1; + : __selectedOptionsSet.has(options[index + 1]); const previousOption = options[index - 1]?.option; const isPreviousSelected = !!previousOption && isGroup(previousOption) ? getGroupState(previousOption).selected - : __selectedOptions.indexOf(options[index - 1]) > -1; + : __selectedOptionsSet.has(options[index - 1]); const optionProps: any = { key: index, option, @@ -354,7 +357,7 @@ export function useSelect({ const highlightedGroupSelected = !!highlightedOption && isGroup(highlightedOption.option) && getGroupState(highlightedOption.option).selected; const announceSelected = - !!highlightedOption && (__selectedOptions.indexOf(highlightedOption) > -1 || highlightedGroupSelected); + !!highlightedOption && (__selectedOptionsSet.has(highlightedOption) || highlightedGroupSelected); return { isOpen, From f2dd86d6d76320b94ed6d45558a7dbb40e5eb89e Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Fri, 12 Jun 2026 08:29:56 -0400 Subject: [PATCH 2/3] Remove unnecessary comment Co-authored-by: Gethin Webster --- src/select/utils/use-select.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/select/utils/use-select.ts b/src/select/utils/use-select.ts index 9068886d24..f650a44cb2 100644 --- a/src/select/utils/use-select.ts +++ b/src/select/utils/use-select.ts @@ -76,8 +76,6 @@ export function useSelect({ const hasFilter = filteringType !== 'none' && !embedded; const activeRef = hasFilter ? filterRef : menuRef; const __selectedOptions = connectOptionsByValue(options, selectedOptions); - // Membership set for the selected dropdown options so getOptionProps can test selection in O(1) - // instead of scanning __selectedOptions for every (filtered) option, which is O(filtered × selected). const __selectedOptionsSet = new Set(__selectedOptions); const __selectedValuesSet = selectedOptions.reduce((selectedValuesSet: Set, item: OptionDefinition) => { if (item.value) { From 459467002bf1364637b68d8d0e483b8634484595 Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Fri, 12 Jun 2026 08:30:05 -0400 Subject: [PATCH 3/3] Remove unnecessary comment Co-authored-by: Gethin Webster --- src/select/utils/connect-options.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/select/utils/connect-options.ts b/src/select/utils/connect-options.ts index 50a9334952..fc9a4acaf1 100644 --- a/src/select/utils/connect-options.ts +++ b/src/select/utils/connect-options.ts @@ -9,10 +9,6 @@ export const connectOptionsByValue = ( if (!selectedOptions || selectedOptions.length === 0) { return []; } - // Index the dropdown options by value so each selected option resolves in O(1), turning the - // overall cost from O(selected × options) into O(selected + options). This matters for large - // multiselects, which re-run this on every render. The first non-parent option for a given value - // wins, preserving the previous linear-scan order. const optionByValue = new Map(); for (const dropdownOption of options) { if (dropdownOption.type !== 'parent') {