Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/perf-autocomplete-memo-deps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

perf(Autocomplete): eliminate re-renders on arrow-key navigation by converting highlightedItem to a ref
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
124 changes: 77 additions & 47 deletions packages/react/src/Autocomplete/AutocompleteMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,52 @@ function getdefaultCheckedSelectionChange<T extends AutocompleteMenuItem>(

const isItemSelected = (itemId: string, selectedItemIds: Array<string>) => selectedItemIds.includes(itemId)

/**
* Memoized wrapper around ActionList.Item that skips re-rendering when props
* haven't changed. Combined with the `highlightedItem` ref (instead of state),
* arrow-key navigation no longer triggers re-renders of the item list.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const MemoizedAutocompleteItem = React.memo(function MemoizedAutocompleteItem<T extends Record<string, any>>({
item,
}: {
item: T
}) {
Comment on lines +55 to +60
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MemoizedAutocompleteItem is typed as T extends Record<string, any>, but the implementation assumes specific fields exist (id, onAction, leadingVisual, etc.). Using a concrete type (e.g. AutocompleteMenuItem plus the augmented fields added in selectableItems) would prevent accidental misuse and avoid the any escape hatch.

Copilot uses AI. Check for mistakes.
const {
id,
onAction,
children,
text,
leadingVisual: LeadingVisual,
trailingVisual: TrailingVisual,
key,
role,
...itemProps
} = item
return (
<ActionList.Item
key={(key ?? id) as string | number}
onSelect={() => onAction(item)}
{...itemProps}
Comment on lines +73 to +76
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

key on the inner ActionList.Item is unnecessary here because it isn’t part of a list at this level (the list keying is handled by the parent allItemsToRender.map). Keeping both can be misleading and suggests the inner key affects reconciliation when it doesn’t.

Copilot uses AI. Check for mistakes.
id={id}
data-id={id}
role={role as AriaRole}
>
{LeadingVisual && (
<ActionList.LeadingVisual>
{isElement(LeadingVisual) ? LeadingVisual : <LeadingVisual />}
</ActionList.LeadingVisual>
)}
{(children ?? text) as React.ReactNode}
{TrailingVisual && (
<ActionList.TrailingVisual>
{isElement(TrailingVisual) ? TrailingVisual : <TrailingVisual />}
</ActionList.TrailingVisual>
)}
</ActionList.Item>
)
})

function getItemById<T extends AutocompleteMenuItem>(itemId: string, items: T[]) {
return items.find(item => item.id === itemId)
}
Expand Down Expand Up @@ -167,7 +213,11 @@ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMe
} = props
const listContainerRef = useRef<HTMLDivElement>(null)
const allItemsToRenderRef = useRef<T[]>([])
const [highlightedItem, setHighlightedItem] = useState<T>()
// Using a ref instead of state to avoid triggering a re-render of all items
// on every arrow-key press. The active styling was already handled via DOM by
// useFocusZone (data-is-active-descendant) before this change, but the
// previous useState caused React to re-render the entire item list anyway.
const highlightedItemRef = useRef<T>()
const [sortedItemIds, setSortedItemIds] = useState<Array<string>>(items.map(({id: itemId}) => itemId))
const generatedUniqueId = useId(id)

Expand All @@ -178,7 +228,6 @@ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMe
...selectableItem,
role: 'option',
id: selectableItem.id,
active: highlightedItem?.id === selectableItem.id,
selected: selectionVariant === 'multiple' ? selectedItemIds.includes(selectableItem.id) : undefined,
onAction: (item: T) => {
const otherSelectedItemIds = selectedItemIds.filter(selectedItemId => selectedItemId !== item.id)
Expand All @@ -204,7 +253,6 @@ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMe
}
}),
[
highlightedItem,
items,
selectedItemIds,
inputRef,
Expand Down Expand Up @@ -246,7 +294,6 @@ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMe
...addNewItem,
role: 'option',
key: addNewItem.id,
active: highlightedItem?.id === addNewItem.id,
selected: selectionVariant === 'multiple' ? selectedItemIds.includes(addNewItem.id) : undefined,
leadingVisual: () => <PlusIcon />,
onAction: (item: T) => {
Expand All @@ -269,7 +316,6 @@ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMe
selectionVariant,
setInputValue,
generatedUniqueId,
highlightedItem,
selectedItemIds,
],
)
Expand All @@ -294,13 +340,28 @@ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMe
activeDescendantFocus: inputRef,
onActiveDescendantChanged: (current, _previous, directlyActivated) => {
activeDescendantRef.current = current || null

// Active styling was already handled via DOM before this change:
// useFocusZone sets `data-is-active-descendant` on the active element,
// and ActionList styles that attribute in CSS. The key change is that
// we no longer call setHighlightedItem (state), avoiding re-renders.

if (current) {
const currentLi = current.closest('li')
const selectedItem = allItemsToRenderRef.current.find(item => {
return item.id === current.closest('li')?.getAttribute('data-id')
return item.id === currentLi?.getAttribute('data-id')
})

setHighlightedItem(selectedItem)
highlightedItemRef.current = selectedItem
setIsMenuDirectlyActivated(directlyActivated)

// Update autocomplete suggestion inline (moved from the useEffect
// that previously depended on highlightedItem state)
if (selectedItem?.text?.startsWith(deferredInputValue) && !selectedItemIds.includes(selectedItem.id)) {
setAutocompleteSuggestion(selectedItem.text)
} else {
setAutocompleteSuggestion('')
}
}

if (current && customScrollContainerRef && customScrollContainerRef.current && directlyActivated) {
Expand All @@ -314,14 +375,15 @@ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMe
)

useEffect(() => {
// Use deferredInputValue to avoid running this effect on every keystroke
// The Input component guards against stale suggestions
if (highlightedItem?.text?.startsWith(deferredInputValue) && !selectedItemIds.includes(highlightedItem.id)) {
setAutocompleteSuggestion(highlightedItem.text)
// When the input value changes, update the autocomplete suggestion based
// on the currently highlighted item (tracked via ref, not state).
const highlighted = highlightedItemRef.current
if (highlighted?.text?.startsWith(deferredInputValue) && !selectedItemIds.includes(highlighted.id)) {
setAutocompleteSuggestion(highlighted.text)
} else {
setAutocompleteSuggestion('')
}
}, [highlightedItem, deferredInputValue, selectedItemIds, setAutocompleteSuggestion])
}, [deferredInputValue, selectedItemIds, setAutocompleteSuggestion])

useEffect(() => {
const itemIdSortResult = [...sortedItemIds].sort(
Expand Down Expand Up @@ -364,41 +426,9 @@ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMe
id={`${id}-listbox`}
aria-labelledby={ariaLabelledBy}
>
{allItemsToRender.map(item => {
const {
id,
onAction,
children,
text,
leadingVisual: LeadingVisual,
trailingVisual: TrailingVisual,
key,
role,
...itemProps
} = item
return (
<ActionList.Item
key={(key ?? id) as string | number}
onSelect={() => onAction(item)}
{...itemProps}
id={id}
data-id={id}
role={role as AriaRole}
>
{LeadingVisual && (
<ActionList.LeadingVisual>
{isElement(LeadingVisual) ? LeadingVisual : <LeadingVisual />}
</ActionList.LeadingVisual>
)}
{(children ?? text) as React.ReactNode}
{TrailingVisual && (
<ActionList.TrailingVisual>
{isElement(TrailingVisual) ? TrailingVisual : <TrailingVisual />}
</ActionList.TrailingVisual>
)}
</ActionList.Item>
)
})}
{allItemsToRender.map(item => (
<MemoizedAutocompleteItem key={(item.key ?? item.id) as string | number} item={item} />
))}
</ActionList>
) : emptyStateText !== false && emptyStateText !== null ? (
<div className={classes.EmptyStateWrapper}>{emptyStateText}</div>
Expand Down
Loading