Skip to content

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

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

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

Conversation

@hectahertz
Copy link
Contributor

@hectahertz hectahertz commented Feb 13, 2026

Summary

UnderlineNav currently uses synchronous DOM measurement APIs (getBoundingClientRect, getComputedStyle) inside useLayoutEffect and ResizeObserver callbacks to determine which items overflow. This causes forced reflows — the browser must synchronously compute layout before JavaScript can continue, 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:

  • The <ul> gets overflow: hidden so the browser natively clips items that don't fit
  • An IntersectionObserver (with the list as root) asynchronously reports which items are clipped
  • Overflowing items get aria-hidden + visibility: hidden and are duplicated in the overflow menu
  • Zero synchronous layout-forcing calls from the component

What's removed

Removed Why
useLayoutEffect in UnderlineNavItem (per-item DOM measurement) Replaced by IntersectionObserver
ResizeObserver + overflowEffect width calculation loop CSS handles overflow natively
getAnchoredPosition import from @primer/behaviors Menu positioning simplified
childWidthArray / noIconChildWidthArray state + context No width tracking needed
calculatePossibleItems / swapMenuItemWithListItem helpers Replaced by useMemo with index-based logic
styles.ts file (all inline style objects) Migrated to CSS Modules
MORE_BTN_WIDTH / getValidChildren exports Dead code removed
Duplicate icon-hiding CSS in UnderlinePanels.module.css Consolidated into shared UnderlineTabbedInterface.module.css

What's added

  • IntersectionObserver in a single useEffect on the parent
  • ResizeObserver alongside IO to detect when the list grows wider (re-enables icons after viewport returns to wide)
  • CSS overflow: hidden on the item list with padding to preserve underline indicators
  • Icon toggle loop prevention (widthWhenIconsDisabledRef) to avoid infinite show/hide cycles
  • visibility: hidden on [aria-hidden='true'] items to prevent partially-clipped text

What's changed (non-functional)

  • All inline styles migrated to CSS Modules (UnderlineNav.module.css)
  • All raw pixel values replaced with Primer design tokens (--base-size-*, --borderRadius-medium, --shadow-resting-medium, etc.)
  • Zero stylelint-disable comments needed
  • className merging fixed in UnderlineItemList — consumer-provided className is now merged via clsx instead of being overwritten by {...rest} spread
  • Normalized imports (React.useCallback → destructured useCallback)

Performance measurements

All measurements taken on the overflow story (9 nav items), no CPU/network throttling, using Chrome DevTools Protocol with initScript instrumentation (counters injected before any page scripts execute and stack-trace filtered to UnderlineNav).

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
Resize handling ResizeObserver → JS measurement → re-render Browser layout + IO callback (no JS measurement)
Icon re-show on grow JS width comparison ResizeObserver threshold check
Forced reflows per mount 72 0
Forced reflows per resize 64 0
Styling approach Inline style objects (styles.ts) CSS Modules with Primer tokens

Changed files

File Change
UnderlineNav.tsx Core rewrite: IO/RO-based overflow detection, inline styles removed
UnderlineNav.module.css Expanded with all migrated styles, Primer tokens throughout
UnderlineNavItem.tsx Simplified — aria-hidden handling for overflow items
UnderlineNavItem.module.css Added visibility: hidden for aria-hidden items
UnderlineNavContext.tsx Simplified to just loadingCounters
styles.ts Deleted — all styles now in CSS Modules
types.ts Removed ChildSize / ChildWidthArray types
UnderlineTabbedInterface.tsx className merging fix, icon-hiding CSS rule
UnderlineTabbedInterface.module.css Icon visibility rule, flex-shrink on icons
UnderlinePanels.module.css Removed duplicate icon-hiding rule
UnderlineNav.test.tsx Minor: removed import of deleted MORE_BTN_WIDTH

How did you test this change?

  • All 30 existing unit tests pass (npm test — 19 UnderlineNav + 11 UnderlinePanels)
  • Visual verification in Storybook at multiple viewport widths (1800px, 1200px, 600px, 500px):
    • Icons show at wide viewports, hide at medium, items overflow to menu at narrow
    • aria-current item swaps to stay visible when it would overflow
    • Overflow dropdown opens/closes correctly with proper items
    • Resize in both directions flows items in/out correctly
    • Icons reappear when viewport grows back from narrow to wide
  • Performance profiled with Chrome DevTools Protocol (DOM API instrumentation with initScript injection + stack-trace filtering, Performance traces for CWV metrics, viewport resize tests)

…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: 9a1a2b5

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
@hectahertz
Copy link
Contributor Author

@iansan5653 I know you've been working on a couple of different approaches to improve this, could you please have a look at this proposal? :)

@hectahertz hectahertz removed the request for review from francinelucca February 13, 2026 15:09
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR refactors UnderlineNav overflow handling to avoid forced synchronous layout reads by relying on CSS clipping (overflow: hidden) and IntersectionObserver to detect which items are clipped, while also migrating prior inline styles to CSS Modules and consolidating icon-hiding behavior with UnderlinePanels.

Changes:

  • Replaced JS width-measurement overflow logic with CSS overflow + IntersectionObserver (and a ResizeObserver assist for icon re-enable).
  • Migrated UnderlineNav styling from inline style objects to UnderlineNav.module.css using Primer tokens.
  • Simplified related types/context and consolidated shared icon-visibility styling in UnderlineTabbedInterface.module.css.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/react/src/internal/components/UnderlineTabbedInterface.tsx Fixes className merging for the list and moves icon rendering to CSS-controlled visibility.
packages/react/src/internal/components/UnderlineTabbedInterface.module.css Adds shared rule to hide icons when wrappers set data-icons-visible='false'; prevents icon shrink.
packages/react/src/experimental/UnderlinePanels/UnderlinePanels.module.css Removes duplicated icon-hiding CSS (now handled by shared module).
packages/react/src/UnderlineNav/types.ts Removes dead types used by the old width-measurement strategy.
packages/react/src/UnderlineNav/styles.ts Deletes inline style constants now replaced by CSS Modules.
packages/react/src/UnderlineNav/UnderlineNavItem.tsx Removes per-item layout measurement; adds overflow aria-hidden + focus management.
packages/react/src/UnderlineNav/UnderlineNavItem.module.css Hides overflowed list items visually while keeping them in the DOM for IO tracking.
packages/react/src/UnderlineNav/UnderlineNavContext.tsx Simplifies context to only loadingCounters.
packages/react/src/UnderlineNav/UnderlineNav.tsx Core rewrite: IO/RO overflow detection, overflow menu duplication, CSS-based icon toggling, CSS Modules usage.
packages/react/src/UnderlineNav/UnderlineNav.test.tsx Updates icon counting assertion to target the list rather than the whole nav.
packages/react/src/UnderlineNav/UnderlineNav.module.css Adds new layout + overflow/menu styling using design tokens; replaces prior inline styles.
.changeset/underlinenav-intersection-observer-overflow.md Patch changeset describing the performance-oriented UnderlineNav update.

Comment on lines +348 to +353
{menuItems.map(menuItem => {
const {children: menuItemChildren, counter, onSelect, ...menuItemProps} = menuItem.props

return (
<ActionList.LinkItem
key={menuItemChildren}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

key={menuItemChildren} is not guaranteed to be stable/unique (children can be non-string ReactNodes or repeated labels), which can cause React key warnings and incorrect reconciliation in the overflow menu. Prefer using menuItem.key (from the original child) or a stable identifier from props (e.g. id/href) and only fall back to an index if needed.

Suggested change
{menuItems.map(menuItem => {
const {children: menuItemChildren, counter, onSelect, ...menuItemProps} = menuItem.props
return (
<ActionList.LinkItem
key={menuItemChildren}
{menuItems.map((menuItem, index) => {
const {children: menuItemChildren, counter, onSelect, ...menuItemProps} = menuItem.props
return (
<ActionList.LinkItem
key={
menuItem.key ??
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(menuItemProps as {id?: string}).id ??
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(menuItemProps as {href?: string}).href ??
index
}

Copilot uses AI. Check for mistakes.
useEffect(() => {
const list = listRef.current
const wrapper = navRef.current
if (!list || typeof IntersectionObserver === 'undefined') return
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

The new overflow behavior is driven by IntersectionObserver (+ ResizeObserver), but the current UnderlineNav unit tests don't mock either observer, so this code path typically won't run in JSDOM. Consider adding tests that mock IntersectionObserver/ResizeObserver to validate overflow detection, aria-hidden/tabIndex handling, and the aria-current swap behavior.

Suggested change
if (!list || typeof IntersectionObserver === 'undefined') return
if (
!list ||
typeof IntersectionObserver === 'undefined' ||
typeof ResizeObserver === 'undefined'
) {
return
}

Copilot uses AI. Check for mistakes.
Comment on lines 67 to 71
counter,
onSelect,
'aria-current': ariaCurrent,
'aria-hidden': ariaHidden,
icon: Icon,
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

UnderlineNavItem now reads an aria-hidden prop, but UnderlineNavItemProps doesn’t declare it (and the type is exported). For better type safety (and to avoid needing any/casts in callers like React.cloneElement), consider extending UnderlineNavItemProps with an aria-hidden?: boolean | 'true' | 'false' (or moving this to an internal-only prop type).

Copilot uses AI. Check for mistakes.
Comment on lines 112 to 116
icon={leadingVisual ?? Icon}
loadingCounters={loadingCounters}
iconsVisible={iconsVisible}
tabIndex={isHidden ? -1 : undefined}
{...props}
>
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

tabIndex intended to make overflowed (aria-hidden) items non-focusable is set before {...props}. If a consumer passes tabIndex on UnderlineNav.Item, it will override the -1 value and allow focus into a visually/ARIA-hidden item. Consider spreading props first and then applying tabIndex, or explicitly omitting tabIndex from props when isHidden is true.

Copilot uses AI. Check for mistakes.
Comment on lines +244 to +248
// ResizeObserver to detect when the list grows while icons are hidden.
// IO won't re-fire when all items are already fully visible and the root
// just gets wider, so we need RO to trigger icon retry in that scenario.
const resizeObserver = new ResizeObserver(() => {
if (!iconsVisibleRef.current && iconPhaseRef.current === 'normal') {
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

ResizeObserver is instantiated unconditionally. In environments where IntersectionObserver exists but ResizeObserver does not, this will throw at runtime and break UnderlineNav. Previously this component used useResizeObserver, which guards typeof ResizeObserver === 'function' and falls back to window.resize. Consider adding the same guard here or reusing useResizeObserver for the width-growth retry logic.

Copilot uses AI. Check for mistakes.
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.

1 participant