perf(Autocomplete): eliminate unnecessary re-renders on arrow-key navigation#7549
perf(Autocomplete): eliminate unnecessary re-renders on arrow-key navigation#7549hectahertz wants to merge 3 commits intomainfrom
Conversation
🦋 Changeset detectedLatest commit: d7c3e90 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
👋 Hi, this pull request contains changes to the source code that github/github-ui depends on. If you are GitHub staff, test these changes with github/github-ui using the integration workflow. Or, apply the |
757105c to
adc970a
Compare
adc970a to
95257f6
Compare
95257f6 to
d128a81
Compare
There was a problem hiding this comment.
Pull request overview
This PR improves AutocompleteMenu performance by preventing expensive React re-renders during keyboard (active-descendant) navigation, relying on useFocusZone’s DOM-driven highlight styling instead of React state.
Changes:
- Convert the highlighted item from React state to a ref and remove it from memo dependencies to avoid recomputing/remapping items on arrow-key navigation.
- Add a memoized item wrapper to reduce per-item renders during parent/menu re-renders.
- Update Playwright VRT snapshots to reflect the intentional styling change (removal of
active/bold-on-keyboard-highlight).
Reviewed changes
Copilot reviewed 2 out of 83 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/react/src/Autocomplete/AutocompleteMenu.tsx | Removes highlighted-item state updates from the render pipeline; adds a memoized item component and moves suggestion updates into the active-descendant callback/effect. |
| .changeset/perf-autocomplete-memo-deps.md | Adds a patch changeset describing the performance improvement. |
| .playwright/snapshots/components/Autocomplete.test.ts-snapshots/Autocomplete-Rendering-The-Menu-Outside-An-Overlay-light-linux.png | Updates VRT snapshot for light theme baseline. |
| .playwright/snapshots/components/Autocomplete.test.ts-snapshots/Autocomplete-Rendering-The-Menu-Outside-An-Overlay-light-tritanopia-linux.png | Updates VRT snapshot for light theme (tritanopia). |
| .playwright/snapshots/components/Autocomplete.test.ts-snapshots/Autocomplete-Rendering-The-Menu-Outside-An-Overlay-light-high-contrast-linux.png | Updates VRT snapshot for light theme (high contrast). |
| .playwright/snapshots/components/Autocomplete.test.ts-snapshots/Autocomplete-Rendering-The-Menu-Outside-An-Overlay-light-colorblind-linux.png | Updates VRT snapshot for light theme (colorblind). |
| .playwright/snapshots/components/Autocomplete.test.ts-snapshots/Autocomplete-Rendering-The-Menu-Outside-An-Overlay-dark-linux.png | Updates VRT snapshot for dark theme baseline. |
| .playwright/snapshots/components/Autocomplete.test.ts-snapshots/Autocomplete-Rendering-The-Menu-Outside-An-Overlay-dark-tritanopia-linux.png | Updates VRT snapshot for dark theme (tritanopia). |
| .playwright/snapshots/components/Autocomplete.test.ts-snapshots/Autocomplete-Rendering-The-Menu-Outside-An-Overlay-dark-high-contrast-linux.png | Updates VRT snapshot for dark theme (high contrast). |
| .playwright/snapshots/components/Autocomplete.test.ts-snapshots/Autocomplete-Rendering-The-Menu-Outside-An-Overlay-dark-dimmed-linux.png | Updates VRT snapshot for dark dimmed theme baseline. |
| .playwright/snapshots/components/Autocomplete.test.ts-snapshots/Autocomplete-Rendering-The-Menu-Outside-An-Overlay-dark-colorblind-linux.png | Updates VRT snapshot for dark theme (colorblind). |
| <ActionList.Item | ||
| key={(key ?? id) as string | number} | ||
| onSelect={() => onAction(item)} | ||
| {...itemProps} |
There was a problem hiding this comment.
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.
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const MemoizedAutocompleteItem = React.memo(function MemoizedAutocompleteItem<T extends Record<string, any>>({ | ||
| item, | ||
| }: { | ||
| item: T | ||
| }) { |
There was a problem hiding this comment.
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.
| } | ||
| }, | ||
| }, | ||
| [loading], | ||
| ) |
There was a problem hiding this comment.
useFocusZone is only re-initialized when the dependencies array changes, and the onActiveDescendantChanged callback now depends on deferredInputValue and selectedItemIds (for suggestion updates). With [loading] as the only dependency, the callback can become stale and compute suggestions using outdated input/selection state. Include the relevant values in the dependencies array, or read them from refs that are kept up to date each render to avoid reinitializing focusZone too often.
Closes #
The active highlight in Autocomplete was already applied via DOM by
useFocusZone(which setsdata-is-active-descendant, styled by ActionList CSS). However,AutocompleteMenualso tracked the highlighted item as React state, causing a full re-render of all 1000+ items on every arrow-key press even though the visual highlight was already handled.Three changes:
Remove
highlightedItemfromuseMemodependencies. TheselectableItemsandallItemsToRendermemos depended onhighlightedItem, causing the entire item mapping/filtering/sorting pipeline to re-run on every arrow-key press.Replace
highlightedItemstate with a ref.setHighlightedItem()triggered a React re-render of the entire menu on every arrow-key press. Since the visual highlight is handled byuseFocusZonevia DOM, we only need the highlighted item data for autocomplete suggestion logic, which can read from a ref.Wrap items in
React.memo. For re-renders that do happen (e.g. typing to filter), unchanged items skip rendering.Visual change:
activeprop removed from keyboard-highlighted itemsThe old code passed
active: highlightedItem?.id === selectableItem.idto eachActionList.Item, applying[data-active]CSS which included semibold font-weight plus a color override, in addition to the background highlight and blue accent line.Since the keyboard highlight is already styled via
[data-is-active-descendant](set byuseFocusZone), theactiveprop was redundant for background/accent but was also applying bold text thatdata-is-active-descendantdoes not.This is the correct behavior. The
[data-active]style (bold text, color override) is for "current page/selection" state (aria-current), not transient keyboard navigation. Autocomplete was the only component applying bold text during keyboard navigation. After this PR, it matches SelectPanel and FilteredActionList.Comparison across all components using active-descendant CSS
aria-currentdata-activedata-is-active-descendantdata-is-active-descendant:focus-visibledata-is-active-descendant+data-activedata-is-active-descendantVRT snapshots need updating to reflect this intentional correction.
Performance measurements
Measured on the default Autocomplete story modified to 1000 items. Interaction: type "i" to filter (all 1000 items match), then press ArrowDown 5 times.
The remaining 88ms processing is from
useFocusZoneDOM traversal,scrollIntoView, and the autocomplete suggestion state update (which only re-renders the input, not the list).Related: #7547 addresses a separate forced reflow issue in
useScrollFlash, which also affects Autocomplete viaFilteredActionList.Changelog
New
N/A
Changed
AutocompleteMenuno longer re-renders on arrow-key navigation.highlightedItemconverted from state to a ref since the visual highlight was already handled byuseFocusZonevia DOM.useEffect(dependent on highlight state) to theonActiveDescendantChangedcallback.data-is-active-descendant(normal weight) for keyboard navigation. The bolddata-activestyle is reserved foraria-current(e.g. NavList current page).Removed
N/A
Rollout strategy
Testing & Reviewing
All 23 Autocomplete tests pass.
Merge checklist