Skip to content

Conversation

@mattcosta7
Copy link
Contributor

@mattcosta7 mattcosta7 commented Dec 13, 2025

related to https://github.com/github/react-platform/issues/1398

CSS :has() Selector & JavaScript Performance Optimizations

Summary

This PR optimizes CSS :has() selectors and JavaScript patterns across multiple components to improve Interaction to Next Paint (INP) and overall Web Vitals performance. The changes target expensive style recalculations and DOM queries that block the main thread during user interactions.

Changes Overview

CSS Optimizations (11 files)

  • Dialog: Body class instead of :has() selector for scroll lock
  • PageHeader: 17 selectors scoped to direct children (>)
  • ActionList: Data attributes (data-disabled, data-loading) instead of :has() for disabled/loading states
  • Banner, ButtonGroup, AvatarStack, Breadcrumbs, SegmentedControl: Scoped :has() selectors to direct children
  • BaseStyles: Removed expensive :has([data-color-mode]) selectors (already handled by global selectors)

JavaScript Hook Optimizations

useResizeObserver - First-Immediate Throttling

Pattern Behavior
First callback Immediate - fires synchronously on observe()
Subsequent callbacks Throttled with rAF (~60fps)

This pattern preserves test compatibility (tests expect immediate first callback) while reducing layout thrashing during rapid resize events like window dragging.

useAnchoredPosition - Indirect Benefit

useAnchoredPosition calls useResizeObserver twice internally:

  1. Once for window resize (document element)
  2. Once for floating element resize

Both now benefit from first-immediate throttling automatically. During rapid resize events (e.g., window dragging with an open dropdown), position recalculations are coalesced to ~60fps instead of firing on every resize event.

useOverflow - Same Pattern

First callback immediate, subsequent callbacks throttled with requestAnimationFrame.

Component-Local Optimizations

Component Change
AvatarStack MutationObserver callback throttled with rAF
ActionBar ResizeObserver callback throttled (component-local)
UnderlineNav ResizeObserver callback throttled (component-local)

useTreeItemCache (New Hook)

Caches querySelectorAll results for TreeView typeahead with MutationObserver invalidation.

  • Before: 10-50ms per keystroke (DOM query on every key)
  • After: ~0.01ms per keystroke (cached, invalidates only on structure change)

Other Optimizations

Utility Change
hasInteractiveNodes Uses querySelectorAll with combined selector + attribute-first checks
Breadcrumbs Batch layout reads (offsetWidth) in single pass
UnderlineNavItem Batch getBoundingClientRect and getComputedStyle reads

The Problem

How :has() affects INP

When a browser evaluates a CSS selector like:

.parent:has(.descendant) { ... }

It must scan all descendants of .parent on every style recalculation to determine if any match .descendant. This is an O(n) operation where n is the number of descendants.

Complexity Analysis

Pattern Complexity Description
:has(.descendant) O(n) Scans all n descendants
:has(> .child) O(1) Checks only direct children
body:has(.element) O(N) Scans entire document (N = total DOM nodes)
[data-attr] O(1) Attribute lookup on element itself

Performance Expectations

Estimated Improvements

Scenario Before (est.) After (est.) Improvement
Dialog open on page with 10K DOM nodes 2-5ms style recalc 0.01ms 99%+
ActionList hover (100 items) 0.5-1ms per hover 0.01ms 95%+
PageHeader style recalc 17 × O(n) scans 17 × O(1) checks 95%+
TreeView typeahead (1000 items) 10-50ms per keystroke 0.01ms 99%+
Window resize with open overlay Every resize triggers position calc First immediate, rest ~60fps 80%+

Web Vitals Analysis

Metric Status Notes
INP ✅ Optimized CSS :has() scans eliminated, JS hooks throttled
LoAF ✅ Improved Same optimizations reduce long animation frames
CLS ✅ No impact No layout changes, visual appearance unchanged
LCP ✅ No impact No render-blocking changes

Testing

  • ✅ All 2047 unit tests pass (139 test files)
  • ✅ Visual regression tests pass (62 snapshots updated for CSS module hash changes)
  • ✅ TypeScript compilation succeeds
  • ✅ Linting passes

Files Changed

CSS Files (11+ files)

  • ActionList.module.css, Group.module.css, AvatarStack.module.css, Banner.module.css
  • BaseStyles.module.css, Breadcrumbs.module.css, ButtonBase.module.css, ButtonGroup.module.css
  • Dialog.module.css, PageHeader.module.css, SegmentedControl.module.css
  • Timeline.module.css, TreeView.module.css, SelectPanel.module.css

JavaScript/TypeScript Files

  • hooks/useResizeObserver.ts - First-immediate throttling
  • hooks/useOverflow.ts - First-immediate throttling
  • hooks/useAnchoredPosition.ts - Benefits from useResizeObserver changes (no code changes needed)
  • TreeView/useTreeItemCache.ts - New cache hook
  • TreeView/useTypeahead.ts - Uses cached tree items
  • AvatarStack/AvatarStack.tsx - MutationObserver throttling
  • ActionBar/ActionBar.tsx, UnderlineNav/UnderlineNav.tsx - Comments for throttling
  • Dialog/Dialog.tsx - Body class for scroll lock
  • ActionList/Item.tsx, ActionList/TrailingAction.tsx - Data attributes
  • internal/utils/hasInteractiveNodes.ts - Optimized algorithm
  • Breadcrumbs/Breadcrumbs.tsx, UnderlineNavItem.tsx - Batch layout reads

Breaking Changes

None. All changes are backward compatible:

  • All visual appearances unchanged
  • API signatures unchanged
  • 62 snapshot updates are only CSS module hash changes (no visual diff)

Changelog

New

  • useTreeItemCache hook for caching TreeView DOM queries
  • data-disabled and data-loading attributes on ActionList items
  • CSS instructions for Web Vitals analysis requirements

Changed

  • CSS :has() selectors optimized across 11+ component CSS files
  • Dialog scroll lock uses direct body class instead of CSS :has()
  • useResizeObserver uses first-immediate, subsequent-throttled pattern
  • useOverflow uses first-immediate, subsequent-throttled pattern
  • useAnchoredPosition benefits from useResizeObserver throttling (used by ActionMenu, SelectPanel, Autocomplete, etc.)
  • MutationObserver callback throttled in AvatarStack
  • hasInteractiveNodes uses querySelectorAll with attribute-first checks

Rollout strategy

  • Patch release

Merge checklist

  • Added/updated tests
  • Added/updated documentation
  • Changes are SSR compatible
  • Tested in Chrome

Replace expensive CSS :has() selectors that scan the DOM with data
attributes set in JavaScript. This improves Interaction to Next Paint
(INP) by avoiding costly style recalculations.

Changes:
- Dialog: Replace body:has(.Dialog.DisableScroll) with direct class
  toggle on body element. The :has() selector was scanning the entire
  DOM on every style recalc.

- ActionList: Replace expensive descendant-scanning :has() selectors:
  - Mixed descriptions: Replace double :has() that scanned all items
    twice with a JS-computed data-has-mixed-descriptions attribute
  - Disabled state: Replace :has([aria-disabled], [disabled]) with
    data-disabled attribute on the <li>
  - Loading state: Replace :has([data-loading]) with data-loading
    attribute on the <li>

Selectors left unchanged (cheap/already scoped):
- &:has(> .TrailingAction) - direct child, O(1)
- .Dialog:has(.Footer) - single element container
- Adjacent sibling selectors in other components
Scope remaining :has() selectors to direct children where possible,
reducing DOM traversal depth from O(n) to O(1) or O(children).

Button:
- Replace :has(.Visual) with data-no-visuals attribute check
- Scope icon-only counter :has() to direct child path

ActionList:
- Scope TrailingAction loading :has() to direct child
- Replace :has([data-truncate]) with self-selector (attr is on same element)
- Scope TrailingActionButton :has() to direct child
- Scope InactiveButtonWrap :has() to shallow path
- Add data-loading to TrailingAction wrapper span
All 17 :has() selectors in PageHeader now use the direct child combinator (>)
since TitleArea and Navigation are rendered as direct children of PageHeader.
This changes the lookup from O(n) descendant scan to O(1) direct child check.
Scope :has() selectors to use direct child combinators (>) or shallow paths
(> * >) for O(1) lookups instead of descendant scans:

- Dialog: :has(.Footer) → :has(> .Footer)
- SegmentedControl: :has(:focus-visible) → :has(> :focus-visible)
- TreeView: :has(.TreeViewItemSkeleton) → :has(> .TreeViewItemSkeleton)
- Breadcrumbs: :has(.MenuOverlay) → :has(> .MenuDetails .MenuOverlay)
- ButtonGroup: :has(div:last-child:empty) → :has(> div:last-child:empty)
- Banner: :has(.BannerActions) → :has(> * > .BannerActions)
- AvatarStack: :has([data-square]) → :has(> [data-square])
- SelectPanel: :has(input:placeholder-shown) → :has(> input:placeholder-shown)
@changeset-bot
Copy link

changeset-bot bot commented Dec 13, 2025

🦋 Changeset detected

Latest commit: 48ee449

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@primer/react Patch

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

@mattcosta7 mattcosta7 changed the title Css inp performance minimal Optimize costly css selectors Dec 13, 2025
@github-actions
Copy link
Contributor

👋 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 integration-tests: skipped manually label to skip these checks.

@github-actions github-actions bot added the integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm label Dec 13, 2025
* :has([data-has-description='true']):has([data-has-description='false'])
* The double :has() scans all items twice on every style recalc - O(2n).
*/
&[data-has-mixed-descriptions='true'] {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

copilot and I couldn't find a solution outside of doing some javascript for this - but maybe O(2n) scans are ok here?

@mattcosta7 mattcosta7 force-pushed the css-inp-performance-minimal branch from 7287a23 to 78d425b Compare December 13, 2025 22:03
@github-actions github-actions bot requested a deployment to storybook-preview-7312 December 13, 2025 22:06 Abandoned
@github-actions github-actions bot temporarily deployed to storybook-preview-7312 December 13, 2025 22:16 Inactive
The hover state selector was checking [aria-disabled] on the <li> element,
but aria-disabled may be on a child element (ItemWrapper) depending on the
_PrivateItemWrapper and listSemantics flags. Using data-disabled alone is
correct because it's explicitly set on the <li> based on the disabled prop.
mattcosta7 added a commit that referenced this pull request Dec 15, 2025
…elector

- Replace body:has(.Dialog.DisableScroll) with direct body.DialogScrollDisabled class
- Scope :has(.Footer) to direct child with > combinator for O(1) lookup

Part of #7312
mattcosta7 added a commit that referenced this pull request Dec 15, 2025
All TitleArea and Navigation selectors are now scoped to direct children with > combinator.

Part of #7312
mattcosta7 added a commit that referenced this pull request Dec 15, 2025
mattcosta7 added a commit that referenced this pull request Dec 15, 2025
Added documentation explaining why the :has(input:placeholder-shown) selector is acceptable.

Part of #7312
mattcosta7 added a commit that referenced this pull request Dec 15, 2025
mattcosta7 added a commit that referenced this pull request Dec 15, 2025
- Add useTreeItemCache hook to cache DOM queries for tree items
- Update useRovingTabIndex and useTypeahead to use cached items
- Add documentation for acceptable :has() selector usage

Part of #7312
mattcosta7 added a commit that referenced this pull request Dec 15, 2025
…useOverflow

- useResizeObserver fires callback immediately on first observation, then throttles with rAF
- useOverflow uses the same pattern to avoid initial flash of incorrect overflow state
- Added isFirstCallback ref pattern to skip throttling on initial mount

Part of #7312
mattcosta7 added a commit that referenced this pull request Dec 15, 2025
…s and throttle updates

- Use window resize listener instead of ResizeObserver on documentElement
- Add ResizeObserver for floating element with first-immediate throttling
- Use updatePositionRef to avoid callback identity changes
- Deduplicate observer setup to avoid redundant work

Part of #7312
mattcosta7 added a commit that referenced this pull request Dec 15, 2025
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.

Part of #7312
mattcosta7 added a commit that referenced this pull request Dec 15, 2025
Combine display and visibility checks into a single getComputedStyle call.

Part of #7312
mattcosta7 added a commit that referenced this pull request Dec 15, 2025
…server throttling

- UnderlineNavItem: Batch getBoundingClientRect and getComputedStyle reads
- UnderlineNav: Add comment noting ResizeObserver callbacks are now throttled

Part of #7312
mattcosta7 added a commit that referenced this pull request Dec 15, 2025
Remove unnecessary array dependency parameter from useResizeObserver call.

Part of #7312
mattcosta7 added a commit that referenced this pull request Dec 15, 2025
Document that useResizeObserver is throttled by default with rAF for better INP.

Part of #7312
mattcosta7 added a commit that referenced this pull request Dec 15, 2025
…attribute checks

- Use combined querySelectorAll selector instead of recursive traversal
- Check attribute-based states (disabled, hidden, inert) before getComputedStyle
- Only call getComputedStyle when CSS-based visibility check is needed

Part of #7312
@mattcosta7 mattcosta7 closed this Dec 23, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants