Skip to content

perf(UnderlineNav): replace JS DOM measurement with CSS overflow + IntersectionObserver#7541

Open
hectahertz wants to merge 23 commits intomainfrom
hectahertz/perf/underlinenav-intersection-observer-overflow
Open

perf(UnderlineNav): replace JS DOM measurement with CSS overflow + IntersectionObserver#7541
hectahertz wants to merge 23 commits intomainfrom
hectahertz/perf/underlinenav-intersection-observer-overflow

Conversation

@hectahertz
Copy link
Contributor

@hectahertz hectahertz commented Feb 13, 2026

Closes #

UnderlineNav uses synchronous DOM measurement APIs (getBoundingClientRect, getComputedStyle) inside useLayoutEffect and ResizeObserver callbacks to determine which items overflow. This causes forced reflows, blocking paint and creating jank during resize. In dotcom, this manifests as a ✨ 298ms forced reflow ✨ on pages using UnderlineNav.

This PR replaces that entire measurement strategy with CSS overflow: hidden + IntersectionObserver + CSS Anchor Positioning, eliminating all synchronous layout-forcing calls.

How it works
┌─ NavWrapper (position: relative) ──────────────────────────────────────┐
│ ┌─ OverflowList (overflow: hidden) ─────────────────────────────────┐  │
│ │ [Code] [Issues] [PRs] [Discuss] [Actions] ░░░░░░░░░░░░░░░░░░░░░░  │  │
│ │  visible items ──────────────────▶│  clipped by overflow: hidden  │  │
│ └───────────────────────────────────┼───────────────────────────────┘  │
│                                     │                                  │
│                 CSS Anchor ─────────┘                                  │
│                 Positioning        ┌──────────┐                        │
│                 (position-anchor)─▶│ More ▼   │                        │
│                                    └──────────┘                        │
└────────────────────────────────────────────────────────────────────────┘
  1. The <ul> has overflow: hidden. Items that don't fit are clipped.
  2. An IntersectionObserver (root = <ul>) detects which items are clipped. rootMargin: '0px -80px 0px 0px' reserves space for the "More" button.
  3. When overflow is detected, overflowStartIndex is set via React state. Items from that index onward get aria-hidden + tabIndex={-1} and are duplicated in the overflow dropdown.
  4. The "More" button uses CSS Anchor Positioning to sit flush after the last visible item.
Icon toggle state machine

When overflow is first detected, icons are hidden to try to fit more items. If that's not enough, the overflow menu appears. When the viewport grows, icons are retried.

┌────────────┐  overflow detected   ┌────────────────────────┐
│   normal   │─────────────────────▶│ trying-without-icons   │
│  (icons)   │                      │  (hid icons, waiting)  │
└────────────┘                      └───────────┬────────────┘
     ▲                                          │ IO re-fires
     │                              ┌───────────┴────────────┐
     │                              ▼                        ▼
     │                     still overflow?            no overflow?
     │                              │                        │
     │                              ▼                        │
     │                   set overflowStartIndex              │
     │                   (show More button)                  │
     │                              │                        │
     │                              └────────┬───────────────┘
     │                                       ▼
     │                           ┌───────────────────────┐
     │                           │       normal          │
     │                           │     (no icons)        │
     │                           └───────────┬───────────┘
     │                                       │
     │                           RO: list grew past threshold
     │                                       │
     │                                       ▼
     │                           ┌────────────────────────┐
     │                           │  trying-with-icons     │
     │                           │  (re-enabled icons)    │
     │                           └───────────┬────────────┘
     │                                       │ IO re-fires
     │                           ┌───────────┴────────────┐
     │                           ▼                        ▼
     │                     icons fit?              overflow?
     │                           │                        │
     │                           ▼                        ▼
     └──────────────── clear overflow        revert to no icons
         phase='normal'                      (back to normal, no icons)

To prevent flicker during the icon retry, overflow is NOT cleared until IO confirms the final state after icons are toggled.

Performance measurements

All measurements on the overflow story (9 nav items), no CPU/network throttling, Chrome DevTools Protocol with initScript instrumentation.

Forced reflow API calls, initial render:

Metric Before After Change
getBoundingClientRect 36 0 -100%
getComputedStyle 36 0 -100%
Total forced-layout triggers 72 0 -100%

Forced reflow API calls, resize cycle (1200px -> 600px -> 1200px):

Metric Before After Change
getBoundingClientRect 28 0 -100%
getComputedStyle 36 0 -100%
Total forced-layout triggers 64 0 -100%

Core Web Vitals (Performance trace):

Metric Before After Change
LCP (initial render) 2,131 ms 1,990 ms -7%
CLS (initial render) 0.01 0.01 0%
CLS (during resize) 0.00 0.00 0%

Architecture comparison:

Aspect Before After
Paint-blocking work useLayoutEffect (synchronous) useEffect (async)
Overflow detection JS: measure widths -> sum -> compare CSS overflow: hidden + IntersectionObserver
More button positioning In flex flow (items removed from DOM) CSS Anchor Positioning (items stay in DOM, clipped)
Resize handling ResizeObserver -> JS measurement -> re-render Browser layout + IO callback (no JS measurement)
Forced reflows per mount 72 0
Forced reflows per resize 64 0
Styling approach Inline style objects (styles.ts) CSS Modules with Primer tokens

Accessibility audit

Check Status
aria-label on <nav> Pass
Visually hidden <h2> heading for screen readers Pass
aria-current="page" on selected link Pass
Overflow items: aria-hidden="true" on <li> Pass
Overflow items: tabIndex={-1} on links Pass
More button: aria-expanded toggles correctly Pass
More button: aria-controls points to overflow menu id Pass
More button: accessible name includes visually hidden context Pass
Escape key closes menu and returns focus to More button Pass
Tab order skips overflowing items Pass
Anchor markers: aria-hidden="true" + role="presentation" Pass
aria-current item swapped into visible range when overflowing Pass
Minimum 2 items in overflow menu (never just 1) Pass

Changelog

New

N/A

Changed

  • UnderlineNav overflow detection rewritten from synchronous DOM measurement to CSS overflow: hidden + IntersectionObserver, eliminating all forced reflows
  • More button positioned via CSS Anchor Positioning instead of flex flow
  • All inline styles migrated to CSS Modules with Primer design tokens

Removed

N/A

Rollout strategy

  • Patch release
  • Minor release
  • Major release; if selected, include a written rollout or migration plan
  • None; if selected, include a brief description as to why

Testing & Reviewing

  • All 26 UnderlineNav unit tests pass (19 existing + 7 new IO/RO mock tests)
  • Visually verified in Storybook at 1280px, 1012px, 900px, 600px, 500px
  • Overflow menu opens/closes, Escape returns focus, aria-current swap works
  • Icons toggle correctly on resize (show -> hide -> re-show) with no flicker
  • No forced reflows in Chrome DevTools Performance tab
  • CSS Anchor Positioning verified working in Chrome

Merge checklist

…tersectionObserver

Replace synchronous getBoundingClientRect/getComputedStyle layout-forcing
calls with CSS overflow: hidden + IntersectionObserver for overflow detection.

- Eliminates all forced reflows from UnderlineNav (72 → 0 layout-forcing calls)
- Removes useLayoutEffect (paint-blocking) in favor of async useEffect
- Removes ResizeObserver + manual width calculation loop
- Removes getAnchoredPosition dependency
- Simplifies overflow menu item management with useMemo
- Moves More button outside the <ul> to avoid overflow: hidden clipping
- Adds visibility: hidden for aria-hidden overflow items to prevent partial clipping
- Includes icon toggle loop prevention via width-based retry threshold
@changeset-bot
Copy link

changeset-bot bot commented Feb 13, 2026

🦋 Changeset detected

Latest commit: c02d93c

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

@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 Feb 13, 2026
@github-actions github-actions bot temporarily deployed to storybook-preview-7541 February 13, 2026 14:06 Inactive
…om context

- Always render icons in DOM, hide via CSS [data-icons-visible='false']
  on the wrapper instead of conditionally rendering with React state.
  This eliminates React re-render cycles when toggling icon visibility.
- Use direct DOM attribute (setAttribute) for icon toggle in IO callback,
  so the browser relayouts and IO re-fires without a React reconciliation.
- Remove iconsVisible from UnderlineNavContext (no longer needed).
- Add flex-shrink: 0 on icon span to prevent icon compression.
- Maintain backward compatibility with UnderlinePanels (still accepts
  iconsVisible prop, uses same CSS data attribute pattern).
@hectahertz hectahertz force-pushed the hectahertz/perf/underlinenav-intersection-observer-overflow branch from b88214f to 36fdb29 Compare February 13, 2026 14:06
@github-actions github-actions bot requested a deployment to storybook-preview-7541 February 13, 2026 14:10 Abandoned
@github-actions github-actions bot temporarily deployed to storybook-preview-7541 February 13, 2026 14:19 Inactive
- Add fixed height (48px) on the nav wrapper to lock vertical dimensions
- Always render the More button container before IO has fired, hidden via
  visibility: hidden, so it reserves horizontal space from the first paint
- Once IO determines overflow state, the More button becomes visible
  (overflow) or is removed entirely (no overflow) with no layout shift
- Fix test to scope SVG count to the list element (More button's SVG is
  now always in the DOM during overflow)
- Remove dead export MORE_BTN_WIDTH (unused outside UnderlineNav)
- Remove dead export getValidChildren (only used internally)
- Migrate all remaining inline style objects to CSS Modules classes:
  overflowListStyles → .OverflowList
  dividerStyles → .Divider
  baseMenuInlineStyles → .OverflowMenu
  menuItemStyles → .MenuItem
  MORE_BTN_HEIGHT → removed (container auto-sizes)
  isWidgetOpen display toggle → .OverflowMenuOpen
- Delete styles.ts (all styles now in CSS Modules)
- Use Primer design token --shadow-resting-medium for overlay shadow
- Remove duplicate icon-hiding CSS from UnderlinePanels.module.css
  (already handled by shared UnderlineTabbedInterface.module.css)
- Fix UnderlineItemList to merge className props via clsx instead of
  overwriting (was causing flex layout to break when consumer passed
  className)
@hectahertz hectahertz force-pushed the hectahertz/perf/underlinenav-intersection-observer-overflow branch from b5d0c87 to 33731b8 Compare February 13, 2026 14:37
@github-actions github-actions bot requested a deployment to storybook-preview-7541 February 13, 2026 14:41 Abandoned
IntersectionObserver won't re-fire when all items are already fully
visible and the root container just gets wider. This meant icons hidden
during overflow would never reappear when resizing the viewport wider.

Add a ResizeObserver on the list to detect when it grows past the width
at which icons were disabled, triggering the 'trying-with-icons' phase
to re-evaluate whether icons fit.
- Remove unused moreMenuRef (assigned but never read)
- Normalize React.useCallback/React.useRef to destructured imports
- Remove unnecessary setIsWidgetOpen dep from closeOverlay
- Rename containerRef → menuListRef for clarity
@github-actions github-actions bot requested a deployment to storybook-preview-7541 February 13, 2026 14:47 Abandoned
- Use Primer token var(--base-size-24) instead of raw 24px for divider height
- Use padding shorthand instead of four individual padding properties
- Simplify flex: 1 1 0% → flex: 1
@github-actions github-actions bot requested a deployment to storybook-preview-7541 February 13, 2026 14:53 Abandoned
@github-actions github-actions bot temporarily deployed to storybook-preview-7541 February 13, 2026 15:03 Inactive
@hectahertz hectahertz marked this pull request as ready for review February 13, 2026 15:07
@hectahertz hectahertz requested a review from a team as a code owner February 13, 2026 15:07
@github-actions github-actions bot requested a deployment to storybook-preview-7541 February 14, 2026 21:17 Abandoned
@github-actions github-actions bot temporarily deployed to storybook-preview-7541 February 14, 2026 21:26 Inactive
@francinelucca francinelucca added update snapshots 🤖 Command that updates VRT snapshots on the pull request and removed update snapshots 🤖 Command that updates VRT snapshots on the pull request labels Feb 18, 2026

return (
<li className={classes.UnderlineNavItem}>
<li className={classes.UnderlineNavItem} aria-hidden={isHidden || undefined}>
Copy link
Member

Choose a reason for hiding this comment

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

what does this do? 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The aria-hidden prop can be either a boolean (true) or a string ("true"), since React and HTML handle them differently. This normalizes both into a single boolean so we can use it to:

  1. Set aria-hidden on the <li> to hide overflow items from the accessibility tree
  2. Set tabIndex={-1} on the inner element so overflowed items can't be focused with keyboard

Previously overflow was handled by removing/adding DOM nodes. Now overflowed items stay in the DOM but are hidden from assistive tech and non-focusable.

Comment on lines +82 to +85
// Destructure iconsVisible to prevent it from spreading onto the DOM via ...rest.
// Icon visibility is now controlled via CSS (data-icons-visible on wrapper).
// eslint-disable-next-line @typescript-eslint/no-unused-vars
iconsVisible: _iconsVisible,
Copy link
Member

Choose a reason for hiding this comment

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

does that mean this prop should be deprecated?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, iconsVisible is still actively used by UnderlinePanels (which also consumes UnderlineItem), so it needs to stay in the shared type.

For UnderlineNav specifically, icon visibility is now controlled via CSS using a data-icons-visible attribute on the wrapper, so UnderlineNav no longer passes it. But it can't be removed from the shared component because UnderlinePanels still relies on it.

The destructure here just prevents it from spreading onto the DOM as an invalid HTML attribute via ...rest.

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

Comments