From 64d8929318c06c1e53ed142e2b2b99a4ac419c66 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Thu, 21 May 2026 23:09:52 +0000 Subject: [PATCH 1/2] avoid cascading updates in underline bits' --- .changeset/underline-nav-cascade-reduction.md | 21 +++++ .../src/UnderlineNav/UnderlineNav.test.tsx | 26 +++++- .../react/src/UnderlineNav/UnderlineNav.tsx | 91 ++++++++++++++----- .../src/UnderlineNav/UnderlineNavContext.tsx | 5 - .../src/UnderlineNav/UnderlineNavItem.tsx | 39 ++------ .../UnderlinePanels/UnderlinePanels.test.tsx | 22 +++++ .../UnderlinePanels/UnderlinePanels.tsx | 64 +++++++------ 7 files changed, 174 insertions(+), 94 deletions(-) create mode 100644 .changeset/underline-nav-cascade-reduction.md diff --git a/.changeset/underline-nav-cascade-reduction.md b/.changeset/underline-nav-cascade-reduction.md new file mode 100644 index 00000000000..700a5ecd2f0 --- /dev/null +++ b/.changeset/underline-nav-cascade-reduction.md @@ -0,0 +1,21 @@ +--- +'@primer/react': patch +--- + +Reduce render cascades in `UnderlineNav` and `UnderlinePanels` (experimental): + +- `UnderlineNav.Item` no longer runs a per-item layout effect that dispatches + `setChildrenWidth` + `setNoIconChildrenWidth` to the parent. The parent + `UnderlineNav` now measures all items in a single layout effect and stores + widths in refs (not state). For N items, that's one batched setState + instead of 2N. The hook's `updateListAndMenu` now compares items/menuItems + by key before committing, so the `ResizeObserver`'s initial fire (which + reports the same dimensions the parent layout effect just measured) bails + out instead of producing an extra render. +- `UnderlinePanels` now derives `tabs` and `tabPanels` inline via `useMemo` + instead of holding them in state and syncing through a post-paint + `useEffect`. The `listWidth` state is gone — the layout effect and resize + observer compute icon visibility by measuring both list and wrapper + together at decision time. + +No public API changes. diff --git a/packages/react/src/UnderlineNav/UnderlineNav.test.tsx b/packages/react/src/UnderlineNav/UnderlineNav.test.tsx index 6039ec2ad8a..887518c5a48 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.test.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.test.tsx @@ -1,5 +1,5 @@ import {describe, expect, it, vi} from 'vitest' -import type React from 'react' +import React from 'react' import {render, screen} from '@testing-library/react' import userEvent from '@testing-library/user-event' import { @@ -238,6 +238,30 @@ describe('UnderlineNav', () => { const textSpan = item.querySelector('[data-component="text"]') expect(textSpan).toHaveAttribute('data-content', 'Simple Text') }) + + it('does not cascade renders on mount (parent-driven measurement)', async () => { + // Each `UnderlineNav.Item` previously ran its own layout effect that + // dispatched two setState calls to the parent — producing one batched + // re-commit on mount in addition to the ResizeObserver's initial fire. + // The parent-driven measurement collapses that to a single setState, and + // the equality-guarded `updateListAndMenu` causes the ResizeObserver's + // first entry to bail out instead of committing identical responsiveProps. + // + // Asserting an exact upper bound here pins the improvement: with the old + // code the counter saw >=3 commits on mount; with the new code it sees + // at most 2 (initial + 1 measurement-driven update). + let commitCount = 0 + render( + commitCount++}> + + , + ) + + // Allow the ResizeObserver's initial entry to flush. + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(commitCount).toBeLessThanOrEqual(2) + }) }) describe('Keyboard Navigation', () => { diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index 14c52ee9683..fc875257425 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -1,9 +1,9 @@ import type {RefObject} from 'react' -import React, {useRef, forwardRef, useCallback, useState, useEffect} from 'react' +import React, {useRef, forwardRef, useCallback, useState, useEffect, useMemo} from 'react' import {UnderlineNavContext} from './UnderlineNavContext' import type {ResizeObserverEntry} from '../hooks/useResizeObserver' import {useResizeObserver} from '../hooks/useResizeObserver' -import type {ChildWidthArray, ResponsiveProps, ChildSize} from './types' +import type {ChildWidthArray, ResponsiveProps} from './types' import VisuallyHidden from '../_VisuallyHidden' import {dividerStyles, menuItemStyles, baseMenuMinWidth} from './styles' import {UnderlineItemList, UnderlineWrapper, LoadingCounter, GAP} from '../internal/components/UnderlineTabbedInterface' @@ -17,6 +17,7 @@ import CounterLabel from '../CounterLabel' import {invariant} from '../utils/invariant' import classes from './UnderlineNav.module.css' import {getAnchoredPosition} from '@primer/behaviors' +import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect' export type UnderlineNavProps = { children: React.ReactNode @@ -165,12 +166,17 @@ export const UnderlineNav = forwardRef( const [isWidgetOpen, setIsWidgetOpen] = useState(false) const [iconsVisible, setIconsVisible] = useState(true) - const [childWidthArray, setChildWidthArray] = useState([]) - const [noIconChildWidthArray, setNoIconChildWidthArray] = useState([]) + // Item widths are stored in refs (not state) because they're internal to + // the overflow computation — never read during JSX render. Keeping them + // out of state means measuring children no longer triggers a re-render. + const childWidthArrayRef = useRef([]) + const noIconChildWidthArrayRef = useRef([]) // Track whether the initial overflow calculation is complete to prevent CLS const [isOverflowMeasured, setIsOverflowMeasured] = useState(false) - const validChildren = getValidChildren(children) + // Memoize so a re-render with unchanged children doesn't invalidate the + // measurement layout effect's deps. + const validChildren = useMemo(() => getValidChildren(children), [children]) // Responsive props object manages which items are in the list and which items are in the menu. const [responsiveProps, setResponsiveProps] = useState({ @@ -204,7 +210,7 @@ export const UnderlineNav = forwardRef( } function getItemsWidth(itemText: string): number { - return noIconChildWidthArray.find(item => item.text === itemText)?.width ?? 0 + return noIconChildWidthArrayRef.current.find(item => item.text === itemText)?.width ?? 0 } const swapMenuItemWithListItem = ( @@ -250,7 +256,18 @@ export const UnderlineNav = forwardRef( const updateListAndMenu = useCallback( (props: ResponsiveProps, displayIcons: boolean, overflowMeasured: boolean) => { - setResponsiveProps(props) + // Equality guards: the ResizeObserver fires an initial entry on + // observe() with the same dimensions the parent layout effect + // already measured. Without these guards that initial fire + // commits an extra render with identical responsiveProps. + setResponsiveProps(prev => { + const sameItems = + prev.items.length === props.items.length && prev.items.every((item, i) => item.key === props.items[i].key) + const sameMenu = + prev.menuItems.length === props.menuItems.length && + prev.menuItems.every((item, i) => item.key === props.menuItems[i].key) + return sameItems && sameMenu ? prev : props + }) setIconsVisible(displayIcons) if (overflowMeasured) { @@ -259,19 +276,6 @@ export const UnderlineNav = forwardRef( }, [], ) - const setChildrenWidth = useCallback((size: ChildSize) => { - setChildWidthArray(arr => { - const newArr = [...arr, size] - return newArr - }) - }, []) - - const setNoIconChildrenWidth = useCallback((size: ChildSize) => { - setNoIconChildWidthArray(arr => { - const newArr = [...arr, size] - return newArr - }) - }, []) const closeOverlay = React.useCallback(() => { setIsWidgetOpen(false) @@ -301,6 +305,47 @@ export const UnderlineNav = forwardRef( useOnOutsideClick({onClickOutside: closeOverlay, containerRef, ignoreClickRefs: [moreMenuBtnRef]}) + // Measure all items in one parent-driven layout effect instead of having + // each UnderlineNavItem dispatch its own setState. Two wins: + // 1. N children → 1 setState (was 2N), so far fewer batched updates queued. + // 2. The measurements feed `overflowEffect` synchronously here, so the + // ResizeObserver's initial fire becomes a no-op (equality-guarded). + useIsomorphicLayoutEffect(() => { + if (!listRef.current || !navRef.current) return + + const itemEls = listRef.current.querySelectorAll('[data-underline-nav-item]') + if (itemEls.length === 0) return + + const childWidths: ChildWidthArray = [] + const noIconChildWidths: ChildWidthArray = [] + + for (const li of itemEls) { + const inner = li.firstElementChild as HTMLElement | null + if (!inner) continue + const rect = inner.getBoundingClientRect() + const textEl = inner.querySelector('[data-component="text"]') + const iconEl = inner.querySelector('[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}) + } + + childWidthArrayRef.current = childWidths + noIconChildWidthArrayRef.current = noIconChildWidths + + const navWidth = navRef.current.getBoundingClientRect().width + const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0 + if (navWidth !== 0) { + overflowEffect(navWidth, moreMenuWidth, validChildren, childWidths, noIconChildWidths, updateListAndMenu) + } + }, [validChildren, updateListAndMenu]) + useResizeObserver((resizeObserverEntries: ResizeObserverEntry[]) => { const navWidth = resizeObserverEntries[0].contentRect.width const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0 @@ -309,8 +354,8 @@ export const UnderlineNav = forwardRef( navWidth, moreMenuWidth, validChildren, - childWidthArray, - noIconChildWidthArray, + childWidthArrayRef.current, + noIconChildWidthArrayRef.current, updateListAndMenu, ) }, navRef as RefObject) @@ -333,8 +378,6 @@ export const UnderlineNav = forwardRef( return ( - setNoIconChildrenWidth: React.Dispatch<{text: string; width: number}> loadingCounters: boolean iconsVisible: boolean }>({ - setChildrenWidth: () => null, - setNoIconChildrenWidth: () => null, loadingCounters: false, iconsVisible: true, }) diff --git a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx index 3341f677e11..c33808aeffe 100644 --- a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx @@ -1,9 +1,8 @@ -import type {MutableRefObject, RefObject} from 'react' -import React, {forwardRef, useRef, useContext} from 'react' +import type {RefObject} from 'react' +import React, {forwardRef, useContext} from 'react' import type {IconProps} from '@primer/octicons-react' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' import {UnderlineNavContext} from './UnderlineNavContext' -import useLayoutEffect from '../utils/useIsomorphicLayoutEffect' import {UnderlineItem} from '../internal/components/UnderlineTabbedInterface' import classes from './UnderlineNavItem.module.css' @@ -74,33 +73,8 @@ export const UnderlineNavItem = forwardRef( }, forwardedRef, ) => { - const backupRef = useRef(null) - const ref = (forwardedRef ?? backupRef) as RefObject - const {setChildrenWidth, setNoIconChildrenWidth, loadingCounters, iconsVisible} = useContext(UnderlineNavContext) - - useLayoutEffect(() => { - if (ref.current) { - const domRect = (ref as MutableRefObject).current.getBoundingClientRect() - - const icon = Array.from((ref as MutableRefObject).current.children).find( - child => child.getAttribute('data-component') === 'icon', - ) - - const content = Array.from((ref as MutableRefObject).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 | undefined + const {loadingCounters, iconsVisible} = useContext(UnderlineNavContext) const keyDownHandler = React.useCallback( (event: React.KeyboardEvent) => { @@ -120,7 +94,10 @@ export const UnderlineNavItem = forwardRef( ) return ( -
  • + // `data-underline-nav-item` lets the parent UnderlineNav measure all + // items in a single layout effect instead of each item dispatching its + // own setState. See UnderlineNav.tsx. +
  • { ) }).toThrow('Only one tab can be selected at a time.') }) + + it('does not cascade renders on mount (tabs derived inline, no list-width state)', async () => { + // Previously the component held `tabs`/`tabPanels` in state and synced + // them via a post-paint `useEffect`, producing one extra render every + // time children or `iconsVisible` changed. It also held the list width + // in state, with the initial measurement triggering a separate + // layout-effect commit. Both are now derived inline (useMemo + folded + // into the iconsVisible decision), so mount produces at most one + // measurement-driven update on top of the initial commit. + let commitCount = 0 + render( + commitCount++}> + + , + ) + + // Allow ResizeObserver's initial entry to flush. + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(commitCount).toBeLessThanOrEqual(2) + }) }) diff --git a/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx b/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx index aa23618937f..285d104317c 100644 --- a/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx +++ b/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx @@ -4,9 +4,10 @@ import React, { cloneElement, useState, useRef, + useMemo, + useCallback, type FC, type PropsWithChildren, - useEffect, type ElementType, } from 'react' import {TabContainerElement} from '@github/tab-container-element' @@ -20,7 +21,7 @@ import { } from '../../internal/components/UnderlineTabbedInterface' import {useId} from '../../hooks' import {invariant} from '../../utils/invariant' -import {useResizeObserver, type ResizeObserverEntry} from '../../hooks/useResizeObserver' +import {useResizeObserver} from '../../hooks/useResizeObserver' import useIsomorphicLayoutEffect from '../../utils/useIsomorphicLayoutEffect' import classes from './UnderlinePanels.module.css' import {clsx} from 'clsx' @@ -96,13 +97,11 @@ const UnderlinePanels: FCWithSlotMarker = ({ // called in the exact same order in every component render const parentId = useId(props.id) - const [tabs, setTabs] = useState([]) - const [tabPanels, setTabPanels] = useState([]) - - // Make sure we have fresh prop data whenever the tabs or panels are updated (keep aria-selected current) - useEffect(() => { - // Loop through the chidren, if it's a tab, then add id="{id}-tab-{index}" - // If it's a panel, then add aria-labelledby="{id}-tab-{index}" + // Derive tabs / tabPanels during render via useMemo instead of syncing + // to state through a post-paint useEffect. The previous pattern committed + // an extra render every time children or `iconsVisible` changed; this + // version produces the correct values on the very first render. + const {tabs, tabPanels} = useMemo(() => { let tabIndex = 0 let panelIndex = 0 @@ -118,42 +117,41 @@ const UnderlinePanels: FCWithSlotMarker = ({ return child }) - const newTabs = Children.toArray(childrenWithProps).filter(child => { + const nextTabs = Children.toArray(childrenWithProps).filter(child => { return isValidElement(child) && (child.type === Tab || isSlot(child, Tab)) }) - const newTabPanels = Children.toArray(childrenWithProps).filter( + const nextTabPanels = Children.toArray(childrenWithProps).filter( child => isValidElement(child) && (child.type === Panel || isSlot(child, Panel)), ) - // eslint-disable-next-line react-hooks/set-state-in-effect - setTabs(newTabs) - setTabPanels(newTabPanels) + return {tabs: nextTabs, tabPanels: nextTabPanels} }, [children, parentId, loadingCounters, iconsVisible]) - const tabsHaveIcons = tabs.some(tab => React.isValidElement(tab) && tab.props.icon) - - // this is a workaround to get the list's width on the first render - const [listWidth, setListWidth] = useState(0) - useIsomorphicLayoutEffect(() => { - if (!tabsHaveIcons) { - return - } + const tabsHaveIcons = useMemo( + () => tabs.some(tab => React.isValidElement(tab) && tab.props.icon), + [tabs], + ) - setListWidth(listRef.current?.getBoundingClientRect().width ?? 0) + // 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]) - // when the wrapper resizes, check if the icons should be visible - // by comparing the wrapper width to the list width - useResizeObserver( - (resizeObserverEntries: ResizeObserverEntry[]) => { - if (!tabsHaveIcons) { - return - } - - const wrapperWidth = resizeObserverEntries[0].contentRect.width + useIsomorphicLayoutEffect(() => { + recomputeIconsVisible() + }, [recomputeIconsVisible]) - setIconsVisible(wrapperWidth > listWidth) + useResizeObserver( + () => { + recomputeIconsVisible() }, wrapperRef, [], From 38f7ada32a08075d7a6a16dde3cefaf839a054a4 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Thu, 21 May 2026 23:10:02 +0000 Subject: [PATCH 2/2] avoid cascading updates in underline bits' --- .../src/experimental/UnderlinePanels/UnderlinePanels.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx b/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx index 285d104317c..770e4bbaa14 100644 --- a/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx +++ b/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx @@ -128,10 +128,7 @@ const UnderlinePanels: FCWithSlotMarker = ({ return {tabs: nextTabs, tabPanels: nextTabPanels} }, [children, parentId, loadingCounters, iconsVisible]) - const tabsHaveIcons = useMemo( - () => tabs.some(tab => React.isValidElement(tab) && tab.props.icon), - [tabs], - ) + const tabsHaveIcons = useMemo(() => tabs.some(tab => React.isValidElement(tab) && tab.props.icon), [tabs]) // Single source of truth for the icon-visibility decision, used by both the // initial layout-effect pass and the ResizeObserver. Measuring list and