From 98f90a73df604f2576f6dbaa9b8ea6e22401f090 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Mon, 2 Feb 2026 17:56:57 +0000 Subject: [PATCH 1/4] Precompute selected --- .../react/src/SelectPanel/SelectPanel.tsx | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index d5c808f2cef..99b3e354dee 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -558,6 +558,24 @@ function Panel({ ) const itemsToRender = useMemo(() => { + // Pre-compute a Set of selected item IDs/references for O(1) lookups during sorting + // This avoids O(m * k) work per comparison in the sort function + const selectedOnSortSet = new Set() + for (const item of selectedOnSort) { + if (item.id !== undefined) { + selectedOnSortSet.add(item.id) + } else { + selectedOnSortSet.add(item) + } + } + + const isSelectedForSort = (item: ItemProps): boolean => { + if (item.id !== undefined) { + return selectedOnSortSet.has(item.id) + } + return selectedOnSortSet.has(item as unknown as ItemInput) + } + return items .map(item => { return { @@ -601,30 +619,13 @@ function Panel({ }) .sort((itemA, itemB) => { if (shouldOrderSelectedFirst) { - // itemA is selected (for sorting purposes) if an object in selectedOnSort matches every property of itemA, except for the selected property - const itemASelected = selectedOnSort.some(item => - Object.entries(item).every(([key, value]) => { - if (key === 'selected') { - return true - } - return itemA[key as keyof ItemProps] === value - }), - ) - - // itemB is selected (for sorting purposes) if an object in selectedOnSort matches every property of itemA, except for the selected property - const itemBSelected = selectedOnSort.some(item => - Object.entries(item).every(([key, value]) => { - if (key === 'selected') { - return true - } - return itemB[key as keyof ItemProps] === value - }), - ) + const itemASelected = isSelectedForSort(itemA) + const itemBSelected = isSelectedForSort(itemB) // order selected items first - if (itemASelected > itemBSelected) { + if (itemASelected && !itemBSelected) { return -1 - } else if (itemASelected < itemBSelected) { + } else if (!itemASelected && itemBSelected) { return 1 } } From ea0b2b898744efcc07ac7ff830a6b2589a62b770 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Mon, 2 Feb 2026 17:59:59 +0000 Subject: [PATCH 2/4] Precompute selected items, 2 --- .../react/src/SelectPanel/SelectPanel.tsx | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index 99b3e354dee..727aa6309c7 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -318,6 +318,19 @@ function Panel({ ], ) + // Pre-compute a Set of item IDs in the current filtered view for O(1) lookups + const itemsInViewSet = useMemo(() => { + const set = new Set() + for (const item of items) { + if (item.id !== undefined) { + set.add(item.id) + } else { + set.add(item) + } + } + return set + }, [items]) + const handleSelectAllChange = useCallback( (checked: boolean) => { // Exit early if not in multi-select mode @@ -328,9 +341,13 @@ function Panel({ const multiSelectOnChange = onSelectedChange as SelectPanelMultiSelection['onSelectedChange'] const selectedArray = selected as ItemInput[] - const selectedItemsNotInFilteredView = selectedArray.filter( - (selectedItem: ItemInput) => !items.some(item => areItemsEqual(item, selectedItem)), - ) + // Use Set for O(1) lookup instead of O(n) items.some() + const selectedItemsNotInFilteredView = selectedArray.filter((selectedItem: ItemInput) => { + if (selectedItem.id !== undefined) { + return !itemsInViewSet.has(selectedItem.id) + } + return !itemsInViewSet.has(selectedItem) + }) if (checked) { multiSelectOnChange([...selectedItemsNotInFilteredView, ...items]) @@ -338,7 +355,7 @@ function Panel({ multiSelectOnChange(selectedItemsNotInFilteredView) } }, - [items, onSelectedChange, selected], + [items, itemsInViewSet, onSelectedChange, selected], ) // disable body scroll when the panel is open on narrow screens @@ -536,11 +553,30 @@ function Panel({ } }, [placeholder, renderAnchor, selected]) + // Pre-compute a Set of selected item IDs/references for O(1) lookups + // This optimizes isItemCurrentlySelected from O(m) to O(1) per call + const selectedItemsSet = useMemo(() => { + const set = new Set() + if (isMultiSelectVariant(selected)) { + for (const item of selected) { + if (item.id !== undefined) { + set.add(item.id) + } else { + set.add(item) + } + } + } + return set + }, [selected]) + const isItemCurrentlySelected = useCallback( (item: ItemInput) => { - // For multi-select, we just need to check if the item is in the selected array + // For multi-select, use the pre-computed Set for O(1) lookup if (isMultiSelectVariant(selected)) { - return doesItemsIncludeItem(selected, item) + if (item.id !== undefined) { + return selectedItemsSet.has(item.id) + } + return selectedItemsSet.has(item) } // For single-select modal, there is an intermediate state when the user has selected @@ -554,7 +590,7 @@ function Panel({ // For single-select anchored, we just need to check if the item is the selected item return selected?.id !== undefined ? selected.id === item.id : selected === item }, - [selected, intermediateSelected, isSingleSelectModal], + [selected, selectedItemsSet, intermediateSelected, isSingleSelectModal], ) const itemsToRender = useMemo(() => { From 206d07556dc51f7a75e617a4dddb1f8ea7b8af8c Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Thu, 5 Feb 2026 15:35:01 +0000 Subject: [PATCH 3/4] Add changeset --- .changeset/some-zebras-roll.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/some-zebras-roll.md diff --git a/.changeset/some-zebras-roll.md b/.changeset/some-zebras-roll.md new file mode 100644 index 00000000000..0c838d06637 --- /dev/null +++ b/.changeset/some-zebras-roll.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Improve SelectPanel performance From 6b558de2d2e585cd4e5c31fa5d175885e9e5531e Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Mon, 9 Feb 2026 14:43:02 +0100 Subject: [PATCH 4/4] Remove setStates during render phase on SelectPanel (#7498) --- .changeset/funny-areas-doubt.md | 5 ++++ .../react/src/SelectPanel/SelectPanel.tsx | 28 +++++++++++-------- 2 files changed, 22 insertions(+), 11 deletions(-) create mode 100644 .changeset/funny-areas-doubt.md diff --git a/.changeset/funny-areas-doubt.md b/.changeset/funny-areas-doubt.md new file mode 100644 index 00000000000..63a6cd2c1ac --- /dev/null +++ b/.changeset/funny-areas-doubt.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Remove render phase setStates on SelectPanel diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index 727aa6309c7..8fc6ede738a 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -216,8 +216,6 @@ function Panel({ const [needsNoItemsAnnouncement, setNeedsNoItemsAnnouncement] = useState(false) const isNarrowScreenSize = useResponsiveValue({narrow: true, regular: false, wide: false}, false) const [selectedOnSort, setSelectedOnSort] = useState([]) - const [prevItems, setPrevItems] = useState([]) - const [prevOpen, setPrevOpen] = useState(open) const initialHeightRef = useRef(0) const initialScaleRef = useRef(1) const noticeRef = useRef(null) @@ -680,17 +678,25 @@ function Panel({ selectedOnSort, ]) - if (prevItems !== items) { - setPrevItems(items) - if (prevItems.length === 0 && items.length > 0) { - resetSort() + // Track previous items and reset sort when items first load + const prevItemsRef = useRef(items) + useEffect(() => { + if (prevItemsRef.current !== items) { + if (prevItemsRef.current.length === 0 && items.length > 0) { + resetSort() + } + prevItemsRef.current = items } - } + }, [items, resetSort]) - if (open !== prevOpen) { - setPrevOpen(open) - resetSort() - } + // Reset sort when panel opens + const prevOpenRef = useRef(open) + useEffect(() => { + if (prevOpenRef.current !== open) { + resetSort() + prevOpenRef.current = open + } + }, [open, resetSort]) const focusTrapSettings = { initialFocusRef: inputRef || undefined,