Skip to content

Cascade elimination nav panels#7865

Closed
mattcosta7 wants to merge 3 commits into
mainfrom
cascade-elimination-nav-panels
Closed

Cascade elimination nav panels#7865
mattcosta7 wants to merge 3 commits into
mainfrom
cascade-elimination-nav-panels

Conversation

@mattcosta7
Copy link
Copy Markdown
Contributor


Closes #

This PR removes two render cascades on mount: the per-item measurement loop in UnderlineNav and the derived-state-via-effect pattern in UnderlinePanels. Both components measure DOM after first paint to decide what to render — those measurements need to be applied before a second paint to avoid layout shift, but the way the work was structured was producing extra commits beyond the unavoidable one.

This branch is independent of #7864 and rebases cleanly onto main.

Changelog

Changed

1. UnderlineNav — collapse N×2 child setStates into a single parent-driven measurement

Files: UnderlineNav.tsx, UnderlineNavItem.tsx, UnderlineNavContext.tsx

Before: every UnderlineNav.Item ran its own useLayoutEffect that measured getBoundingClientRect() + icon dimensions and dispatched setChildrenWidth + setNoIconChildrenWidth to the parent. For N items that meant 2N setStates queued during the commit phase, all batched by React 18 into one re-commit. Then useResizeObserver's initial entry fired and called overflowEffectupdateListAndMenu, which always called setResponsiveProps with a freshly-constructed object — committing another render even when the data hadn't changed.

Net result on mount: 1 mount commit + 1 nested-update from the child-measurement batch + 1 from the observer's initial fire = ≥3 commits before paint stabilized.

After:

  1. Per-item layout effect removed from UnderlineNavItem. Each item now just adds a data-underline-nav-item attribute on its <li> wrapper so the parent can find it.
  2. UnderlineNav runs a single parent-driven useIsomorphicLayoutEffect that queries every [data-underline-nav-item] once, measures inner widths and icon dimensions in one pass, stores the results in refs (not state — they're never read during render), and calls overflowEffect synchronously with the just-measured values.
  3. updateListAndMenu now compares incoming items / menuItems to the current state by key. The observer's initial fire reports the same dimensions the parent layout effect already measured, so updateListAndMenu returns the previous state object and React bails — no extra render.
  4. UnderlineNavContext no longer needs to publish setChildrenWidth / setNoIconChildrenWidth since UnderlineNavItem doesn't dispatch widths anymore.

Net result on mount: 1 mount commit + 1 measurement-driven commit = ≤2 commits, regardless of how many items the nav contains.

The math inside overflowEffect is byte-for-byte identical to main; only the data flow into it changed.

2. UnderlinePanels — derive tabs inline, fold list-width measurement into the icon-visibility decision

Files: UnderlinePanels.tsx

Before:

  • tabs and tabPanels were held in useState and synced from children via a post-paint useEffect. Initial render had tabs = [], committed empty markup, then the effect fired setTabs(newTabs) + setTabPanels(newTabPanels) and re-rendered with real data. Same extra commit fired on every change to children / loadingCounters / iconsVisible.
  • listWidth was a separate useState whose only purpose was to feed the iconsVisible comparison inside the ResizeObserver callback. The initial measurement ran in its own useIsomorphicLayoutEffect that committed an extra setState before the observer had even fired.

After:

  • tabs and tabPanels are derived via useMemo from children / parentId / loadingCounters / iconsVisible. Initial render now produces the correct markup directly — no post-paint sync.
  • tabsHaveIcons is memoized as well.
  • listWidth state is gone. A new recomputeIconsVisible callback measures both list and wrapper at decision time and is the single source of truth used by both the layout effect and the resize observer. The callback's setIconsVisible(prev => prev === next ? prev : next) bails when the value hasn't changed.

Net result on mount: 1 commit + at most 1 measurement-driven commit when icons need to be hidden = ≤2 commits. Children / state changes elsewhere no longer produce an extra commit from the dropped useEffect.

Identity stability of tabs and tabPanels improves as a side effect — useMemo returns the same array reference when inputs are unchanged, whereas the previous effect created a new array on every run.

Removed

No public API changes. UnderlineNavContext is module-internal; its narrowing is invisible to consumers.

Rollout strategy

  • Patch release
  • Minor release
  • Major release
  • None

No public API surface changes. UnderlineNav.Item's ref forwarding behaviour is unchanged, the wrapper <li> gains a data-underline-nav-item attribute (additive), and UnderlinePanels's rendered markup is identical to main.

Testing & Reviewing

Existing test suites should continue to pass — both files only restructure the data flow into the existing overflow algorithm (UnderlineNav) and the existing tab/panel rendering (UnderlinePanels). Reviewer high-signal spots:

  • UnderlineNav.test.tsx "Keyboard Navigation" — tab order depends on responsiveProps.items being correct after measurement. If the new parent measurement misses an item, this fails.
  • UnderlineNav.test.tsx AVT/VRT runs — visually confirm the more-menu still produces the right items on resize and that swapping a menu item into the visible list works.
  • UnderlinePanels.test.tsx ids / aria-labelledby — these depend on tabs / tabPanels being derived correctly during render. The useMemo move should actually improve timing here (was post-paint, now during render).

New profiler tests added:

  • UnderlineNav.test.tsx — asserts <=2 commits when mounting a 7-item nav.
  • UnderlinePanels.test.tsx — asserts <=2 commits when mounting a 3-tab panel.

Both use React.Profiler directly (no shared harness; this PR is self-contained).

Manual verification I'd recommend before approving:

  1. In Storybook, narrow the viewport while watching the more-menu form, and confirm items flow into it and back smoothly.
  2. Click an item in the more-menu and confirm it swaps into the visible list.
  3. Open React DevTools Profiler, record mounting Components/UnderlineNav → Default on main, then on this branch. Confirm the commit count drops.

Behavioral risk callouts

  1. Measurement targets one DOM level deeper. The parent layout effect uses li.firstElementChild to get the link element. Every UnderlineNav.Item renders <li><UnderlineItem /></li>, so internal usage is fine. External consumers that wrap UnderlineNav.Item in something else are unaffected because they wrap around the item, not inside its <li>.
  2. tabs / tabPanels identity is now stable across renders. If anything downstream of UnderlinePanels (e.g. effects in slot consumers) used tabs as a referential-equality dep, those effects now fire less often. This is almost certainly a strict improvement, but worth eyeballing.
  3. Equality guard uses key-based comparison. updateListAndMenu skips a setState when both items[i].key === prev.items[i].key for all i and the lengths match. If two items have the same key but different content (a key collision that React would already warn about), the equality guard would bail when it shouldn't. The existing getValidChildren doesn't deduplicate keys, so a key collision is a pre-existing bug that this code doesn't worsen.

Merge checklist

  • Added/updated tests
  • Added/updated documentation — N/A, no API changes
  • Added/updated previews (Storybook) — N/A, no new component states
  • Changes are SSR compatible (no DOM reads during render; both layout effects bail when refs are null)
  • Tested in Chrome (CI)
  • Tested in Firefox
  • Tested in Safari
  • Tested in Edge
  • (GitHub staff only) Integration tests pass at github/github-ui

Copilot AI review requested due to automatic review settings May 21, 2026 23:11
@mattcosta7 mattcosta7 requested a review from a team as a code owner May 21, 2026 23:11
@mattcosta7 mattcosta7 requested a review from liuliu-dev May 21, 2026 23:11
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 21, 2026

🦋 Changeset detected

Latest commit: 59423af

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 marked this pull request as draft May 21, 2026 23:11
@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 May 21, 2026
@github-actions
Copy link
Copy Markdown
Contributor

⚠️ Action required

👋 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. Check the integration testing docs for step-by-step instructions. Or, apply the integration-tests: skipped manually label to skip these checks.

To publish a canary release for integration testing, apply the Canary Release label to this PR.

Copy link
Copy Markdown
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 aims to reduce mount-time render cascades in UnderlineNav and experimental UnderlinePanels by restructuring DOM measurement and derived state to avoid extra commit cycles, and pins the improvements with profiler-based tests.

Changes:

  • UnderlineNav: replaces per-item layout-effect measurement with a single parent-driven layout effect, stores measured widths in refs, and adds key-based equality guards to avoid ResizeObserver’s initial “no-op” commit.
  • UnderlinePanels: derives tabs / tabPanels inline via useMemo, removes derived-state-via-effect, and consolidates icon-visibility measurement into a shared callback used by layout effect + ResizeObserver.
  • Adds profiler tests asserting <= 2 commits on mount, plus a patch changeset.
Show a summary per file
File Description
packages/react/src/UnderlineNav/UnderlineNavItem.tsx Removes per-item measurement effect; adds data-underline-nav-item marker for parent measurement.
packages/react/src/UnderlineNav/UnderlineNavContext.tsx Narrows context shape by removing width-dispatch setters.
packages/react/src/UnderlineNav/UnderlineNav.tsx Implements parent-driven measurement; moves widths to refs; adds responsiveProps equality guard; memoizes valid children.
packages/react/src/UnderlineNav/UnderlineNav.test.tsx Adds profiler test to cap mount commit count.
packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx Replaces effect-synced state with useMemo derivation; removes listWidth state; adds shared recompute callback.
packages/react/src/experimental/UnderlinePanels/UnderlinePanels.test.tsx Adds profiler test to cap mount commit count.
.changeset/underline-nav-cascade-reduction.md Adds patch changeset documenting cascade reductions.

Copilot's findings

  • Files reviewed: 7/7 changed files
  • Comments generated: 3

Comment on lines +326 to +336
const textEl = inner.querySelector('[data-component="text"]')
const iconEl = inner.querySelector<HTMLElement>('[data-component="icon"]')
if (!textEl) continue
const text = textEl.textContent
const iconWidthWithMargin = iconEl
? iconEl.getBoundingClientRect().width +
Number(getComputedStyle(iconEl).marginRight.slice(0, -2)) +
Number(getComputedStyle(iconEl).marginLeft.slice(0, -2))
: 0
childWidths.push({text, width: rect.width})
noIconChildWidths.push({text, width: rect.width - iconWidthWithMargin})
Comment on lines +133 to +143
// Single source of truth for the icon-visibility decision, used by both the
// initial layout-effect pass and the ResizeObserver. Measuring list and
// wrapper together at decision time removes the need to hold list width in
// state.
const recomputeIconsVisible = useCallback(() => {
if (!tabsHaveIcons || !listRef.current || !wrapperRef.current) return
const wrapperWidth = wrapperRef.current.getBoundingClientRect().width
const listWidth = listRef.current.getBoundingClientRect().width
const next = wrapperWidth > listWidth
setIconsVisible(prev => (prev === next ? prev : next))
}, [tabsHaveIcons])
Comment on lines 74 to +77
forwardedRef,
) => {
const backupRef = useRef<HTMLElement>(null)
const ref = (forwardedRef ?? backupRef) as RefObject<HTMLAnchorElement>
const {setChildrenWidth, setNoIconChildrenWidth, loadingCounters, iconsVisible} = useContext(UnderlineNavContext)

useLayoutEffect(() => {
if (ref.current) {
const domRect = (ref as MutableRefObject<HTMLElement>).current.getBoundingClientRect()

const icon = Array.from((ref as MutableRefObject<HTMLElement>).current.children).find(
child => child.getAttribute('data-component') === 'icon',
)

const content = Array.from((ref as MutableRefObject<HTMLElement>).current.children).find(
child => child.getAttribute('data-component') === 'text',
) as HTMLElement
const text = content.textContent as string

const iconWidthWithMargin = icon
? icon.getBoundingClientRect().width +
Number(getComputedStyle(icon).marginRight.slice(0, -2)) +
Number(getComputedStyle(icon).marginLeft.slice(0, -2))
: 0

setChildrenWidth({text, width: domRect.width})
setNoIconChildrenWidth({text, width: domRect.width - iconWidthWithMargin})
}
}, [ref, setChildrenWidth, setNoIconChildrenWidth])
const ref = forwardedRef as RefObject<HTMLAnchorElement> | undefined
const {loadingCounters, iconsVisible} = useContext(UnderlineNavContext)
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