Skip to content

Commit 1201930

Browse files
authored
Merge branch 'main' into perf/dialog-has-selector
2 parents 8d95278 + a8b42b2 commit 1201930

File tree

8 files changed

+457
-85
lines changed

8 files changed

+457
-85
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@primer/react': patch
3+
---
4+
5+
perf(Autocomplete): Split context to reduce unnecessary re-renders
6+
7+
Split AutocompleteContext into separate contexts for static values, setters, and dynamic state.
8+
Components now subscribe only to the context slices they need, reducing re-renders.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@primer/react': patch
3+
---
4+
5+
perf(hasInteractiveNodes): Optimize with combined selector and early attribute checks
6+
7+
- Use combined querySelectorAll selector instead of recursive traversal
8+
- Check attribute-based states (disabled, hidden, inert) before getComputedStyle
9+
- Only call getComputedStyle when CSS-based visibility check is needed

packages/react/src/Autocomplete/Autocomplete.tsx

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type React from 'react'
2-
import {useCallback, useReducer, useRef} from 'react'
2+
import {useCallback, useDeferredValue, useMemo, useReducer, useRef} from 'react'
33
import type {ComponentProps, FCWithSlotMarker} from '../utils/types'
4-
import {AutocompleteContext} from './AutocompleteContext'
4+
import {AutocompleteContext, AutocompleteInputContext, AutocompleteDeferredInputContext} from './AutocompleteContext'
55
import AutocompleteInput from './AutocompleteInput'
66
import AutocompleteMenu from './AutocompleteMenu'
77
import AutocompleteOverlay from './AutocompleteOverlay'
@@ -69,26 +69,57 @@ const Autocomplete: FCWithSlotMarker<React.PropsWithChildren<{id?: string}>> = (
6969
}, [])
7070
const id = useId(idProp)
7171

72+
// Base context: refs, IDs, menu visibility, and callbacks
73+
// Changes when menu opens/closes or selection changes, but NOT on every keystroke
74+
const autocompleteContextValue = useMemo(
75+
() => ({
76+
activeDescendantRef,
77+
id,
78+
inputRef,
79+
scrollContainerRef,
80+
selectedItemLength,
81+
setAutocompleteSuggestion,
82+
setInputValue,
83+
setIsMenuDirectlyActivated,
84+
setShowMenu,
85+
setSelectedItemLength,
86+
showMenu,
87+
}),
88+
[
89+
id,
90+
selectedItemLength,
91+
setAutocompleteSuggestion,
92+
setInputValue,
93+
setIsMenuDirectlyActivated,
94+
setShowMenu,
95+
setSelectedItemLength,
96+
showMenu,
97+
],
98+
)
99+
100+
// Input state context: values that change on every keystroke
101+
// Split to prevent Overlay from re-rendering during typing
102+
const autocompleteInputContextValue = useMemo(
103+
() => ({
104+
autocompleteSuggestion,
105+
inputValue,
106+
isMenuDirectlyActivated,
107+
}),
108+
[autocompleteSuggestion, inputValue, isMenuDirectlyActivated],
109+
)
110+
111+
// Deferred input value for expensive operations like filtering
112+
// Menu subscribes to this instead of inputValue to avoid re-rendering on every keystroke
113+
const deferredInputValue = useDeferredValue(inputValue)
114+
const autocompleteDeferredInputContextValue = useMemo(() => ({deferredInputValue}), [deferredInputValue])
115+
72116
return (
73-
<AutocompleteContext.Provider
74-
value={{
75-
activeDescendantRef,
76-
autocompleteSuggestion,
77-
id,
78-
inputRef,
79-
inputValue,
80-
isMenuDirectlyActivated,
81-
scrollContainerRef,
82-
selectedItemLength,
83-
setAutocompleteSuggestion,
84-
setInputValue,
85-
setIsMenuDirectlyActivated,
86-
setShowMenu,
87-
setSelectedItemLength,
88-
showMenu,
89-
}}
90-
>
91-
{children}
117+
<AutocompleteContext.Provider value={autocompleteContextValue}>
118+
<AutocompleteInputContext.Provider value={autocompleteInputContextValue}>
119+
<AutocompleteDeferredInputContext.Provider value={autocompleteDeferredInputContextValue}>
120+
{children}
121+
</AutocompleteDeferredInputContext.Provider>
122+
</AutocompleteInputContext.Provider>
92123
</AutocompleteContext.Provider>
93124
)
94125
}
Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import {createContext} from 'react'
22

3+
/**
4+
* Base context containing refs, stable IDs, menu visibility state, and callbacks.
5+
* This context changes when menu opens/closes or selection changes, but NOT on every keystroke.
6+
* Consumers like AutocompleteOverlay that don't need input text should use only this context.
7+
*/
38
export const AutocompleteContext = createContext<{
49
activeDescendantRef: React.MutableRefObject<HTMLElement | null>
5-
autocompleteSuggestion: string
610
// TODO: consider changing `id` to `listboxId` because we're just using it to associate the input and combobox with the listbox
711
id: string
812
inputRef: React.MutableRefObject<HTMLInputElement | null>
9-
inputValue: string
10-
isMenuDirectlyActivated: boolean
1113
scrollContainerRef: React.MutableRefObject<HTMLElement | null>
1214
selectedItemLength: number
1315
setAutocompleteSuggestion: (value: string) => void
@@ -17,3 +19,23 @@ export const AutocompleteContext = createContext<{
1719
setShowMenu: (value: boolean) => void
1820
showMenu: boolean
1921
} | null>(null)
22+
23+
/**
24+
* Input-related state that changes on every keystroke.
25+
* Only AutocompleteInput needs this for immediate text display and suggestion highlighting.
26+
*/
27+
export const AutocompleteInputContext = createContext<{
28+
autocompleteSuggestion: string
29+
inputValue: string
30+
isMenuDirectlyActivated: boolean
31+
} | null>(null)
32+
33+
/**
34+
* Deferred input value for expensive operations like filtering.
35+
* Uses React's useDeferredValue to allow typing to remain responsive while
36+
* filtering large lists at lower priority.
37+
* AutocompleteMenu uses this to avoid blocking keystrokes during filtering.
38+
*/
39+
export const AutocompleteDeferredInputContext = createContext<{
40+
deferredInputValue: string
41+
} | null>(null)

packages/react/src/Autocomplete/AutocompleteInput.tsx

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type {ChangeEventHandler, FocusEventHandler, KeyboardEventHandler} from 'react'
22
import React, {useCallback, useContext, useEffect, useState} from 'react'
33
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
4-
import {AutocompleteContext} from './AutocompleteContext'
4+
import {AutocompleteContext, AutocompleteInputContext} from './AutocompleteContext'
55
import TextInput from '../TextInput'
66
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
77
import type {ComponentProps} from '../utils/types'
@@ -37,20 +37,12 @@ const AutocompleteInput = React.forwardRef(
3737
forwardedRef,
3838
) => {
3939
const autocompleteContext = useContext(AutocompleteContext)
40-
if (autocompleteContext === null) {
40+
const inputContext = useContext(AutocompleteInputContext)
41+
if (autocompleteContext === null || inputContext === null) {
4142
throw new Error('AutocompleteContext returned null values')
4243
}
43-
const {
44-
activeDescendantRef,
45-
autocompleteSuggestion = '',
46-
id,
47-
inputRef,
48-
inputValue = '',
49-
isMenuDirectlyActivated,
50-
setInputValue,
51-
setShowMenu,
52-
showMenu,
53-
} = autocompleteContext
44+
const {activeDescendantRef, id, inputRef, setInputValue, setShowMenu, showMenu} = autocompleteContext
45+
const {autocompleteSuggestion = '', inputValue = '', isMenuDirectlyActivated} = inputContext
5446
useRefObjectAsForwardedRef(forwardedRef, inputRef)
5547
const [highlightRemainingText, setHighlightRemainingText] = useState<boolean>(true)
5648
const {safeSetTimeout} = useSafeTimeout()

packages/react/src/Autocomplete/AutocompleteMenu.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {useFocusZone} from '../hooks/useFocusZone'
99
import type {ComponentProps, MandateProps, AriaRole} from '../utils/types'
1010
import Spinner from '../Spinner'
1111
import {useId} from '../hooks/useId'
12-
import {AutocompleteContext} from './AutocompleteContext'
12+
import {AutocompleteContext, AutocompleteDeferredInputContext} from './AutocompleteContext'
1313
import type {IconProps} from '@primer/octicons-react'
1414
import {PlusIcon} from '@primer/octicons-react'
1515
import VisuallyHidden from '../_VisuallyHidden'
@@ -132,14 +132,14 @@ const debounceAnnouncement = debounce((announcement: string) => {
132132

133133
function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMenuInternalProps<T>) {
134134
const autocompleteContext = useContext(AutocompleteContext)
135-
if (autocompleteContext === null) {
135+
const deferredInputContext = useContext(AutocompleteDeferredInputContext)
136+
if (autocompleteContext === null || deferredInputContext === null) {
136137
throw new Error('AutocompleteContext returned null values')
137138
}
138139
const {
139140
activeDescendantRef,
140141
id,
141142
inputRef,
142-
inputValue = '',
143143
scrollContainerRef,
144144
setAutocompleteSuggestion,
145145
setShowMenu,
@@ -148,6 +148,9 @@ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMe
148148
setSelectedItemLength,
149149
showMenu,
150150
} = autocompleteContext
151+
// Use deferred input value to avoid re-rendering on every keystroke
152+
// This allows filtering large lists without blocking typing
153+
const {deferredInputValue} = deferredInputContext
151154
const {
152155
items,
153156
selectedItemIds,
@@ -226,9 +229,9 @@ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMe
226229
const sortedAndFilteredItemsToRender = useMemo(
227230
() =>
228231
selectableItems
229-
.filter(filterFn ? filterFn : getDefaultItemFilter(inputValue))
232+
.filter(filterFn ? filterFn : getDefaultItemFilter(deferredInputValue))
230233
.sort((a, b) => itemSortOrderData[a.id] - itemSortOrderData[b.id]),
231-
[selectableItems, itemSortOrderData, filterFn, inputValue],
234+
[selectableItems, itemSortOrderData, filterFn, deferredInputValue],
232235
)
233236

234237
const allItemsToRender = useMemo(
@@ -311,12 +314,14 @@ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMe
311314
)
312315

313316
useEffect(() => {
314-
if (highlightedItem?.text?.startsWith(inputValue) && !selectedItemIds.includes(highlightedItem.id)) {
317+
// Use deferredInputValue to avoid running this effect on every keystroke
318+
// The Input component guards against stale suggestions
319+
if (highlightedItem?.text?.startsWith(deferredInputValue) && !selectedItemIds.includes(highlightedItem.id)) {
315320
setAutocompleteSuggestion(highlightedItem.text)
316321
} else {
317322
setAutocompleteSuggestion('')
318323
}
319-
}, [highlightedItem, inputValue, selectedItemIds, setAutocompleteSuggestion])
324+
}, [highlightedItem, deferredInputValue, selectedItemIds, setAutocompleteSuggestion])
320325

321326
useEffect(() => {
322327
const itemIdSortResult = [...sortedItemIds].sort(

0 commit comments

Comments
 (0)