Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2a9d1ea
perf: fix expensive CSS :has() selectors for INP optimization
mattcosta7 Dec 13, 2025
e013110
perf: tighten :has() selector scope for better INP
mattcosta7 Dec 13, 2025
0616cb3
perf: scope PageHeader :has() selectors to direct children
mattcosta7 Dec 13, 2025
02d9f6d
perf: scope :has() selectors to direct children across components
mattcosta7 Dec 13, 2025
78d425b
update copilot instructions
mattcosta7 Dec 13, 2025
b99df04
fix(ActionList): use only data-disabled for hover state selector
mattcosta7 Dec 13, 2025
90f3b57
backout a bit
mattcosta7 Dec 14, 2025
86897e9
backout a bit
mattcosta7 Dec 14, 2025
2ef0cc6
perf(TreeView): cache tree items in typeahead to improve INP
mattcosta7 Dec 14, 2025
8c4c70b
perf: additional :has() optimizations and documentation
mattcosta7 Dec 14, 2025
cc131e3
minor updates to scripts
mattcosta7 Dec 14, 2025
d4857e0
useResizeObserver throttling
mattcosta7 Dec 14, 2025
4eacb4b
useResizeObserver updates
mattcosta7 Dec 14, 2025
4c7db34
perf: optimize useResizeObserver and useAnchoredPosition hooks
mattcosta7 Dec 14, 2025
633fe97
chore: add changeset for INP performance optimizations
mattcosta7 Dec 14, 2025
d0fc079
changeset
mattcosta7 Dec 14, 2025
b9ceb4a
revert: remove hook optimizations that broke tests
mattcosta7 Dec 14, 2025
985573f
perf: add first-immediate throttling to useResizeObserver and useOver…
mattcosta7 Dec 14, 2025
80e449a
Update packages/react/src/UnderlineNav/UnderlineNav.tsx
mattcosta7 Dec 14, 2025
6b83e79
context splits + useAnchoredPosition observer
mattcosta7 Dec 15, 2025
539cf3f
update feedback
mattcosta7 Dec 15, 2025
48ee449
avoid duplicate work and observers (#7321)
mattcosta7 Dec 15, 2025
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
14 changes: 14 additions & 0 deletions .changeset/performance-inp-optimizations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@primer/react": patch
---

Performance: Optimize CSS `:has()` selectors and JavaScript hooks for improved INP

- Scope CSS `:has()` selectors to direct children across Dialog, PageHeader, ActionList, Banner, ButtonGroup, AvatarStack, Breadcrumbs, SegmentedControl, TreeView, and SelectPanel
- Add requestAnimationFrame throttling to `useResizeObserver` (first callback immediate, subsequent throttled)
- Add anchor element ResizeObserver to `useAnchoredPosition` for repositioning on anchor resize
- Add `useTreeItemCache` hook for caching TreeView DOM queries with MutationObserver invalidation
- Throttle MutationObserver callbacks in AvatarStack and Announce components
- Add rAF throttling to `useOverflow` hook
- Optimize `hasInteractiveNodes` utility with querySelectorAll and attribute-first checks
- Split Autocomplete context to prevent Overlay re-renders during typing
71 changes: 70 additions & 1 deletion .github/instructions/css.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,73 @@ If the file ends with `*.module.css`, it is a CSS Module.

## General Conventions

- After making a change to a file, use `npx stylelint -q --rd --fix` with a path to the file changed to make sure the code lints
- After making a change to a file, use `npx stylelint -q --rd --fix` with a path to the file changed to make sure the code lints.

## Performance and Web Vitals Analysis (Required)

When creating or modifying CSS, you must **explicitly analyze and account for impact on Core Web Vitals**. Treat this as a first-class requirement, not an afterthought.

### Metrics to Consider

Evaluate changes against the following metrics and call out any risks:

- **LCP (Largest Contentful Paint)**

- Risk factors: render-blocking styles, large above-the-fold background images, heavy font usage, complex selectors on critical elements.

- **CLS (Cumulative Layout Shift)**

- Risk factors: late-loading fonts, size-less images/media, conditional styles that affect layout after initial render, JS-driven class toggles that change dimensions.

- **INP (Interaction to Next Paint)**

- Risk factors: expensive selector matching, `:has()` on large subtrees, frequent style recalculation triggers, deep descendant selectors on interactive paths.

### Required Analysis Checklist

For each meaningful CSS change, reason through and validate the following:

- **Selector Cost**

- Prefer class selectors over tag or attribute selectors.
- Avoid deep descendant chains (`.a .b .c .d`) in large or frequently updated subtrees.
- Use `:has()` only when strictly necessary; assume worst-case cost on large DOMs and justify usage.

- **Style Recalculation Scope**

- Consider which DOM nodes are affected when classes or attributes change.
- Avoid selectors that match large portions of the tree when toggling state (e.g., `[data-*] .child`).

- **Layout and Paint Stability**

- Do not introduce layout-affecting properties (`width`, `height`, `margin`, `padding`, `display`) that may change after hydration or user interaction unless explicitly intended.
- Prefer transform/opacity for interaction-driven effects.
- Ensure predictable sizing for media, containers, and dynamic content to prevent CLS.

- **Critical Rendering Path**

- Avoid adding styles that block first paint or LCP for above-the-fold content.
- Be cautious with large background images, filters, and shadows on critical elements.

### CSS Modules–Specific Guidance

- Assume CSS Modules may be used across multiple React roots and hydration boundaries.
- Avoid styles that depend on global cascade ordering or implicit inheritance from non-module CSS.
- Do not rely on runtime-injected styles to correct layout shifts introduced by base styles.

### Documentation Requirement

When a change has **any non-trivial performance implication**, include a brief inline comment or PR description that states:

- Which Web Vital(s) may be affected.
- Why the change is safe, or what tradeoff is being made.
- Any assumptions about DOM size, interaction frequency, or rendering phase (SSR vs client).

### Examples of High-Risk Patterns (Avoid Unless Justified)

- `:has()` selectors applied to containers with large or frequently changing subtrees.
- Attribute selectors used as global state flags on high-level containers.
- Layout changes driven by hover, focus, or JS state on large components.
- Font or image-dependent sizing without explicit fallbacks.

Performance regressions are considered correctness issues. If a change cannot be justified as Web Vitals–safe, it should not be merged without explicit sign-off.
1 change: 1 addition & 0 deletions packages/react/src/ActionBar/ActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = prop
const moreMenuBtnRef = useRef<HTMLButtonElement>(null)
const containerRef = React.useRef<HTMLUListElement>(null)

// ResizeObserver is throttled by default (rAF) for better INP
useResizeObserver((resizeObserverEntries: ResizeObserverEntry[]) => {
const navWidth = resizeObserverEntries[0].contentRect.width
const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0
Expand Down
39 changes: 29 additions & 10 deletions packages/react/src/ActionList/ActionList.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,14 @@
display: none;
}

/* if a list has a mix of items with and without descriptions, reset the label font-weight to normal */
/*
* If a list has a mix of items with and without descriptions, reset the label font-weight.
* NOTE: Uses double descendant :has() - traverses list twice per recalc.
* This is acceptable because:
* 1. ActionLists are typically small (10-50 items)
* 2. Alternative would require JS to detect mixed state and set data attribute
* 3. The visual impact is subtle (font-weight change), not critical styling
*/
&:has([data-has-description='true']):has([data-has-description='false']) {
& .ItemLabel {
font-weight: var(--base-text-weight-normal);
Expand Down Expand Up @@ -121,7 +128,8 @@
}
}

&:not(:has([aria-disabled], [disabled]), [data-has-subitem='true']) {
/* PERFORMANCE: Use data-disabled on <li> instead of :has([aria-disabled], [disabled]) which scans descendants */
&:not([aria-disabled], [data-disabled='true'], [data-has-subitem='true']) {
@media (hover: hover) {
&:hover,
&:active {
Expand Down Expand Up @@ -276,8 +284,8 @@
}
}

&:where([data-loading='true']),
&:has([data-loading='true']) {
/* PERFORMANCE: data-loading is set on the <li> by JS, avoiding :has() descendant scan */
&:where([data-loading='true']) {
& * {
color: var(--fgColor-muted);
}
Expand Down Expand Up @@ -322,10 +330,9 @@
}
}

/* disabled */

/* PERFORMANCE: data-disabled is set on the <li> by JS, avoiding :has() descendant scan */
&[aria-disabled='true'],
&:has([aria-disabled='true'], [disabled]) {
&[data-disabled='true'] {
& .ActionListContent * {
color: var(--control-fgColor-disabled);
}
Expand Down Expand Up @@ -366,7 +373,8 @@
}

/* When TrailingAction is in loading state, keep labels and descriptions accessible */
&:has(.TrailingAction [data-loading='true']):not([aria-disabled='true']) {
/* PERFORMANCE: scoped to direct child TrailingAction */
&:has(> .TrailingAction[data-loading='true']):not([aria-disabled='true']) {
/* Ensure labels and descriptions maintain accessibility contrast */
& .ItemLabel {
color: var(--fgColor-default);
Expand Down Expand Up @@ -540,7 +548,11 @@
display: none;
}

/* show active indicator on parent collapse if child is active */
/*
* Show active indicator on parent collapse if child is active.
* NOTE: Uses adjacent sibling + descendant :has() - SubGroup is typically small (few nav items).
* This is acceptable because scope is limited to the collapsed SubGroup's children.
*/
&:has(+ .SubGroup [data-active='true']) {
background: var(--control-transparent-bgColor-selected);

Expand Down Expand Up @@ -637,6 +649,7 @@ default block */
word-break: normal;
}

/* NOTE: Uses descendant :has() - scope is just this item's children (label + description). Acceptable. */
&:has([data-truncate='true']) {
& .ItemLabel {
flex: 1 0 auto;
Expand Down Expand Up @@ -713,7 +726,8 @@ span wrapping svg or text */
height: 100%;

/* Preserve width consistency when loading state is active for text buttons only */
&[data-loading='true']:has([data-component='buttonContent']) {
/* PERFORMANCE: scoped to direct child for O(1) lookup */
&[data-loading='true']:has(> [data-component='buttonContent']) {
/* Double the left padding to compensate for missing right padding */
padding: 0 0 0 calc(var(--base-size-12) * 2);

Expand All @@ -731,6 +745,11 @@ span wrapping svg or text */
}
}

/*
* NOTE: Uses descendant :has() - VisualComponent is nested inside Tooltip > button,
* which is 3 levels deep (> * > * > .TrailingVisual). This is acceptable since
* the search is scoped to this small wrapper element's subtree.
*/
.InactiveButtonWrap {
&:has(.TrailingVisual) {
grid-area: trailingVisual;
Expand Down
6 changes: 5 additions & 1 deletion packages/react/src/ActionList/Group.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
&:not(:first-child) {
margin-block-start: var(--base-size-8);

/* If somebody tries to pass the `title` prop AND a `NavList.GroupHeading` as a child, hide the `ActionList.GroupHeading */
/*
* If somebody tries to pass the `title` prop AND a `NavList.GroupHeading` as a child, hide the `ActionList.GroupHeading`.
* NOTE: Uses descendant :has() - this is an edge case handler for developer errors.
* Scope is limited to group's children, not entire list. Acceptable performance cost.
*/
/* stylelint-disable-next-line selector-max-specificity */
&:has(.GroupHeadingWrap + ul > .GroupHeadingWrap) {
/* stylelint-disable-next-line selector-max-specificity */
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/ActionList/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
data-variant={variant === 'danger' ? variant : undefined}
data-active={active ? true : undefined}
data-inactive={inactiveText ? true : undefined}
data-loading={loading && !inactive ? true : undefined}
data-disabled={disabled ? true : undefined}
data-has-subitem={slots.subItem ? true : undefined}
data-has-description={slots.description ? true : false}
className={clsx(classes.ActionListItem, className)}
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/ActionList/TrailingAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export type ActionListTrailingActionProps = ElementProps & {
export const TrailingAction = forwardRef(
({as = 'button', icon, label, href = null, className, style, loading, ...props}, forwardedRef) => {
return (
<span className={clsx(className, classes.TrailingAction)} style={style}>
<span className={clsx(className, classes.TrailingAction)} style={style} data-loading={loading ? true : undefined}>
{icon ? (
<IconButton
as={as}
Expand Down
73 changes: 52 additions & 21 deletions packages/react/src/Autocomplete/Autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -69,26 +69,57 @@ const Autocomplete: FCWithSlotMarker<React.PropsWithChildren<{id?: string}>> = (
}, [])
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 (
<AutocompleteContext.Provider
value={{
activeDescendantRef,
autocompleteSuggestion,
id,
inputRef,
inputValue,
isMenuDirectlyActivated,
scrollContainerRef,
selectedItemLength,
setAutocompleteSuggestion,
setInputValue,
setIsMenuDirectlyActivated,
setShowMenu,
setSelectedItemLength,
showMenu,
}}
>
{children}
<AutocompleteContext.Provider value={autocompleteContextValue}>
<AutocompleteInputContext.Provider value={autocompleteInputContextValue}>
<AutocompleteDeferredInputContext.Provider value={autocompleteDeferredInputContextValue}>
{children}
</AutocompleteDeferredInputContext.Provider>
</AutocompleteInputContext.Provider>
</AutocompleteContext.Provider>
)
}
Expand Down
28 changes: 25 additions & 3 deletions packages/react/src/Autocomplete/AutocompleteContext.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>
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<HTMLInputElement | null>
inputValue: string
isMenuDirectlyActivated: boolean
scrollContainerRef: React.MutableRefObject<HTMLElement | null>
selectedItemLength: number
setAutocompleteSuggestion: (value: string) => void
Expand All @@ -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)
18 changes: 5 additions & 13 deletions packages/react/src/Autocomplete/AutocompleteInput.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<boolean>(true)
const {safeSetTimeout} = useSafeTimeout()
Expand Down
Loading
Loading