diff --git a/.changeset/perf-autocomplete-context-split.md b/.changeset/perf-autocomplete-context-split.md new file mode 100644 index 00000000000..81745b0bbdb --- /dev/null +++ b/.changeset/perf-autocomplete-context-split.md @@ -0,0 +1,8 @@ +--- +'@primer/react': patch +--- + +perf(Autocomplete): Split context to reduce unnecessary re-renders + +Split AutocompleteContext into separate contexts for static values, setters, and dynamic state. +Components now subscribe only to the context slices they need, reducing re-renders. diff --git a/packages/react/src/Autocomplete/Autocomplete.tsx b/packages/react/src/Autocomplete/Autocomplete.tsx index 993b0fcc203..b14dc2d4ca0 100644 --- a/packages/react/src/Autocomplete/Autocomplete.tsx +++ b/packages/react/src/Autocomplete/Autocomplete.tsx @@ -1,7 +1,7 @@ import type React from 'react' -import {useCallback, useReducer, useRef} from 'react' +import {useCallback, useDeferredValue, useMemo, useReducer, useRef} from 'react' import type {ComponentProps, FCWithSlotMarker} from '../utils/types' -import {AutocompleteContext} from './AutocompleteContext' +import {AutocompleteContext, AutocompleteInputContext, AutocompleteDeferredInputContext} from './AutocompleteContext' import AutocompleteInput from './AutocompleteInput' import AutocompleteMenu from './AutocompleteMenu' import AutocompleteOverlay from './AutocompleteOverlay' @@ -69,26 +69,57 @@ const Autocomplete: FCWithSlotMarker> = ( }, []) const id = useId(idProp) + // Base context: refs, IDs, menu visibility, and callbacks + // Changes when menu opens/closes or selection changes, but NOT on every keystroke + const autocompleteContextValue = useMemo( + () => ({ + activeDescendantRef, + id, + inputRef, + scrollContainerRef, + selectedItemLength, + setAutocompleteSuggestion, + setInputValue, + setIsMenuDirectlyActivated, + setShowMenu, + setSelectedItemLength, + showMenu, + }), + [ + id, + selectedItemLength, + setAutocompleteSuggestion, + setInputValue, + setIsMenuDirectlyActivated, + setShowMenu, + setSelectedItemLength, + showMenu, + ], + ) + + // Input state context: values that change on every keystroke + // Split to prevent Overlay from re-rendering during typing + const autocompleteInputContextValue = useMemo( + () => ({ + autocompleteSuggestion, + inputValue, + isMenuDirectlyActivated, + }), + [autocompleteSuggestion, inputValue, isMenuDirectlyActivated], + ) + + // Deferred input value for expensive operations like filtering + // Menu subscribes to this instead of inputValue to avoid re-rendering on every keystroke + const deferredInputValue = useDeferredValue(inputValue) + const autocompleteDeferredInputContextValue = useMemo(() => ({deferredInputValue}), [deferredInputValue]) + return ( - - {children} + + + + {children} + + ) } diff --git a/packages/react/src/Autocomplete/AutocompleteContext.tsx b/packages/react/src/Autocomplete/AutocompleteContext.tsx index 524ae7074db..e25455664fe 100644 --- a/packages/react/src/Autocomplete/AutocompleteContext.tsx +++ b/packages/react/src/Autocomplete/AutocompleteContext.tsx @@ -1,13 +1,15 @@ import {createContext} from 'react' +/** + * Base context containing refs, stable IDs, menu visibility state, and callbacks. + * This context changes when menu opens/closes or selection changes, but NOT on every keystroke. + * Consumers like AutocompleteOverlay that don't need input text should use only this context. + */ export const AutocompleteContext = createContext<{ activeDescendantRef: React.MutableRefObject - autocompleteSuggestion: string // TODO: consider changing `id` to `listboxId` because we're just using it to associate the input and combobox with the listbox id: string inputRef: React.MutableRefObject - inputValue: string - isMenuDirectlyActivated: boolean scrollContainerRef: React.MutableRefObject selectedItemLength: number setAutocompleteSuggestion: (value: string) => void @@ -17,3 +19,23 @@ export const AutocompleteContext = createContext<{ setShowMenu: (value: boolean) => void showMenu: boolean } | null>(null) + +/** + * Input-related state that changes on every keystroke. + * Only AutocompleteInput needs this for immediate text display and suggestion highlighting. + */ +export const AutocompleteInputContext = createContext<{ + autocompleteSuggestion: string + inputValue: string + isMenuDirectlyActivated: boolean +} | null>(null) + +/** + * Deferred input value for expensive operations like filtering. + * Uses React's useDeferredValue to allow typing to remain responsive while + * filtering large lists at lower priority. + * AutocompleteMenu uses this to avoid blocking keystrokes during filtering. + */ +export const AutocompleteDeferredInputContext = createContext<{ + deferredInputValue: string +} | null>(null) diff --git a/packages/react/src/Autocomplete/AutocompleteInput.tsx b/packages/react/src/Autocomplete/AutocompleteInput.tsx index 4b0314884cc..61b2905e5ba 100644 --- a/packages/react/src/Autocomplete/AutocompleteInput.tsx +++ b/packages/react/src/Autocomplete/AutocompleteInput.tsx @@ -1,7 +1,7 @@ import type {ChangeEventHandler, FocusEventHandler, KeyboardEventHandler} from 'react' import React, {useCallback, useContext, useEffect, useState} from 'react' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' -import {AutocompleteContext} from './AutocompleteContext' +import {AutocompleteContext, AutocompleteInputContext} from './AutocompleteContext' import TextInput from '../TextInput' import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef' import type {ComponentProps} from '../utils/types' @@ -37,20 +37,12 @@ const AutocompleteInput = React.forwardRef( forwardedRef, ) => { const autocompleteContext = useContext(AutocompleteContext) - if (autocompleteContext === null) { + const inputContext = useContext(AutocompleteInputContext) + if (autocompleteContext === null || inputContext === null) { throw new Error('AutocompleteContext returned null values') } - const { - activeDescendantRef, - autocompleteSuggestion = '', - id, - inputRef, - inputValue = '', - isMenuDirectlyActivated, - setInputValue, - setShowMenu, - showMenu, - } = autocompleteContext + const {activeDescendantRef, id, inputRef, setInputValue, setShowMenu, showMenu} = autocompleteContext + const {autocompleteSuggestion = '', inputValue = '', isMenuDirectlyActivated} = inputContext useRefObjectAsForwardedRef(forwardedRef, inputRef) const [highlightRemainingText, setHighlightRemainingText] = useState(true) const {safeSetTimeout} = useSafeTimeout() diff --git a/packages/react/src/Autocomplete/AutocompleteMenu.tsx b/packages/react/src/Autocomplete/AutocompleteMenu.tsx index bd987d0068d..1e53386750b 100644 --- a/packages/react/src/Autocomplete/AutocompleteMenu.tsx +++ b/packages/react/src/Autocomplete/AutocompleteMenu.tsx @@ -9,7 +9,7 @@ import {useFocusZone} from '../hooks/useFocusZone' import type {ComponentProps, MandateProps, AriaRole} from '../utils/types' import Spinner from '../Spinner' import {useId} from '../hooks/useId' -import {AutocompleteContext} from './AutocompleteContext' +import {AutocompleteContext, AutocompleteDeferredInputContext} from './AutocompleteContext' import type {IconProps} from '@primer/octicons-react' import {PlusIcon} from '@primer/octicons-react' import VisuallyHidden from '../_VisuallyHidden' @@ -132,14 +132,14 @@ const debounceAnnouncement = debounce((announcement: string) => { function AutocompleteMenu(props: AutocompleteMenuInternalProps) { const autocompleteContext = useContext(AutocompleteContext) - if (autocompleteContext === null) { + const deferredInputContext = useContext(AutocompleteDeferredInputContext) + if (autocompleteContext === null || deferredInputContext === null) { throw new Error('AutocompleteContext returned null values') } const { activeDescendantRef, id, inputRef, - inputValue = '', scrollContainerRef, setAutocompleteSuggestion, setShowMenu, @@ -148,6 +148,9 @@ function AutocompleteMenu(props: AutocompleteMe setSelectedItemLength, showMenu, } = autocompleteContext + // Use deferred input value to avoid re-rendering on every keystroke + // This allows filtering large lists without blocking typing + const {deferredInputValue} = deferredInputContext const { items, selectedItemIds, @@ -226,9 +229,9 @@ function AutocompleteMenu(props: AutocompleteMe const sortedAndFilteredItemsToRender = useMemo( () => selectableItems - .filter(filterFn ? filterFn : getDefaultItemFilter(inputValue)) + .filter(filterFn ? filterFn : getDefaultItemFilter(deferredInputValue)) .sort((a, b) => itemSortOrderData[a.id] - itemSortOrderData[b.id]), - [selectableItems, itemSortOrderData, filterFn, inputValue], + [selectableItems, itemSortOrderData, filterFn, deferredInputValue], ) const allItemsToRender = useMemo( @@ -311,12 +314,14 @@ function AutocompleteMenu(props: AutocompleteMe ) useEffect(() => { - if (highlightedItem?.text?.startsWith(inputValue) && !selectedItemIds.includes(highlightedItem.id)) { + // 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) } else { setAutocompleteSuggestion('') } - }, [highlightedItem, inputValue, selectedItemIds, setAutocompleteSuggestion]) + }, [highlightedItem, deferredInputValue, selectedItemIds, setAutocompleteSuggestion]) useEffect(() => { const itemIdSortResult = [...sortedItemIds].sort(