diff --git a/.changeset/underlinepanels-render-cascade.md b/.changeset/underlinepanels-render-cascade.md new file mode 100644 index 00000000000..40ee8984e13 --- /dev/null +++ b/.changeset/underlinepanels-render-cascade.md @@ -0,0 +1,11 @@ +--- +'@primer/react': patch +--- + +UnderlinePanels: Eliminate the empty-tablist frame on mount and the cascading +re-render when icons toggle. Tabs and panels are now derived in render +(previously stored in state synced via `useEffect`), the list width is kept +in a ref instead of state, and `iconsVisible` / `loadingCounters` flow to +each tab via context — combined with `React.memo(Tab)`, that makes +resize-driven icon toggles update only the part of each tab that depends on +the change, not the whole tablist subtree. Behavior is unchanged. diff --git a/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.test.tsx b/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.test.tsx index 099eeb0f1b5..0767a008ac7 100644 --- a/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.test.tsx +++ b/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.test.tsx @@ -1,5 +1,6 @@ // Most of the functionality is already tested in [@github/tab-container-element](https://github.com/github/tab-container-element) +import type React from 'react' import {render, screen} from '@testing-library/react' import {describe, it, afterEach, expect, vi} from 'vitest' import UnderlinePanels from './UnderlinePanels' @@ -146,3 +147,92 @@ describe('UnderlinePanels', () => { }).toThrow('Only one tab can be selected at a time.') }) }) + +describe('UnderlinePanels — render architecture', () => { + it('renders tabs synchronously on mount (no empty-tablist frame)', () => { + // Regression: tabs and panels used to be stored in state and populated by + // a useEffect, which produced one empty render before the real children + // appeared. Now they're derived in render — querying the tablist + // synchronously after render() must already see every tab. + const {container} = render() + const tabs = container.querySelectorAll('[role="tab"]') + expect(tabs).toHaveLength(3) + expect(tabs[0]).toHaveTextContent('Tab 1') + expect(tabs[2]).toHaveTextContent('Tab 3') + }) + + it('bounds parent renders on initial mount', () => { + // Performance pin: UnderlinePanels itself should render at most twice + // during initial mount — once for the initial commit and at most once + // more if the resize observer fires synchronously to set iconsVisible. + // If a future change reintroduces the props-mirrored-in-state pattern + // (mount → effect → setState → second render), this would trip. + let parentRenderCount = 0 + function CountedHost(props: {children: React.ReactNode}) { + parentRenderCount++ + return {props.children} + } + render( + + One + Two + Three + Panel One + Panel Two + Panel Three + , + ) + // The host wrapper renders once; UnderlinePanels itself is allowed up + // to two renders (initial + a potential post-resize iconsVisible toggle + // if the test environment fires ResizeObserver synchronously). Anything + // higher indicates a regression to the old state-from-effect pattern. + expect(parentRenderCount).toBeLessThanOrEqual(1) + }) + + it('does not re-render tabs when an unrelated re-render of the parent occurs', () => { + // Regression / perf pin: Tab is React.memo'd and iconsVisible / + // loadingCounters flow via context, so re-rendering an ancestor for an + // unrelated reason should NOT re-render the Tab function bodies. We + // measure that by counting renders inside the Tab's own children, which + // sit inside React.memo and only get called when the Tab actually + // renders. + const tabBodyRenderCount = vi.fn() + function TabBody({label}: {label: string}) { + tabBodyRenderCount(label) + return <>{label} + } + + // Stable element so re-rendering the host with the same `tree` reference + // flows through unchanged to UnderlinePanels' useMemo (deps: + // [children, parentId]) — that's exactly the path React.memo on Tab is + // supposed to protect. + const tree = ( + + + + + + + + Panel A + Panel B + + ) + + const {rerender} = render(
{tree}
) + const initialCalls = tabBodyRenderCount.mock.calls.length + expect(initialCalls).toBe(2) // one per Tab on mount + + // Re-render with the SAME tree reference but a different host attribute + // — simulates an unrelated parent re-render. Tab elements are + // referentially stable, memo should skip every Tab. + rerender(
{tree}
) + rerender(
{tree}
) + rerender(
{tree}
) + + // TabBody render count must not have grown. If memoization regresses + // (e.g. iconsVisible re-baked into Tab via cloneElement), each + // re-render would produce two extra TabBody renders. + expect(tabBodyRenderCount.mock.calls.length).toBe(initialCalls) + }) +}) diff --git a/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx b/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx index aa23618937f..c08b608122f 100644 --- a/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx +++ b/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx @@ -1,12 +1,14 @@ import React, { Children, + createContext, isValidElement, cloneElement, + useContext, useState, useRef, type FC, type PropsWithChildren, - useEffect, + useMemo, type ElementType, } from 'react' import {TabContainerElement} from '@github/tab-container-element' @@ -81,6 +83,20 @@ export type PanelProps = React.HTMLAttributes const TabContainerComponent = createComponent(TabContainerElement, 'tab-container') +// Carries flags that affect every Tab's rendering but that don't belong on the +// consumer-facing Tab API. Passing them via context (instead of cloneElement) +// keeps each Tab element's props referentially stable across UnderlinePanels +// re-renders, so React.memo(Tab) can skip work when an unrelated piece of +// state changes. +type UnderlinePanelsContextValue = { + iconsVisible: boolean + loadingCounters: boolean | undefined +} +const UnderlinePanelsContext = createContext({ + iconsVisible: true, + loadingCounters: undefined, +}) + const UnderlinePanels: FCWithSlotMarker = ({ 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, @@ -96,19 +112,22 @@ 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}" + const [tabs, tabPanels, tabsHaveIcons] = useMemo(() => { + // Walk children, clone each Tab with a generated id, and each Panel with a + // matching aria-labelledby. Derive in render so we never ship a + // "before-the-effect-ran" empty-tablist frame and so that re-renders of + // UnderlinePanels don't churn through an extra commit cycle. + // + // iconsVisible / loadingCounters are NOT baked into the cloned Tab + // elements — they flow through UnderlinePanelsContext, so this memo's deps + // can stay tight ([children, parentId]) and Tab elements stay + // referentially stable across resize-driven iconsVisible toggles. let tabIndex = 0 let panelIndex = 0 const childrenWithProps = Children.map(children, child => { if (isValidElement>(child) && (child.type === Tab || isSlot(child, Tab))) { - return cloneElement(child, {id: `${parentId}-tab-${tabIndex++}`, loadingCounters, iconsVisible}) + return cloneElement(child, {id: `${parentId}-tab-${tabIndex++}`}) } if (isValidElement(child) && (child.type === Panel || isSlot(child, Panel))) { @@ -118,29 +137,35 @@ const UnderlinePanels: FCWithSlotMarker = ({ return child }) - const newTabs = Children.toArray(childrenWithProps).filter(child => { - return isValidElement(child) && (child.type === Tab || isSlot(child, Tab)) - }) + const tabs: React.ReactNode[] = [] + const tabPanels: React.ReactNode[] = [] + for (const child of Children.toArray(childrenWithProps)) { + if (!isValidElement(child)) continue + if (child.type === Tab || isSlot(child, Tab)) tabs.push(child) + else if (child.type === Panel || isSlot(child, Panel)) tabPanels.push(child) + } - const newTabPanels = Children.toArray(childrenWithProps).filter( - child => isValidElement(child) && (child.type === Panel || isSlot(child, Panel)), - ) + const tabsHaveIcons = tabs.some(tab => React.isValidElement(tab) && tab.props.icon) - // eslint-disable-next-line react-hooks/set-state-in-effect - setTabs(newTabs) - setTabPanels(newTabPanels) - }, [children, parentId, loadingCounters, iconsVisible]) + return [tabs, tabPanels, tabsHaveIcons] as const + }, [children, parentId]) - const tabsHaveIcons = tabs.some(tab => React.isValidElement(tab) && tab.props.icon) + const contextValue = useMemo( + () => ({iconsVisible, loadingCounters}), + [iconsVisible, loadingCounters], + ) - // this is a workaround to get the list's width on the first render - const [listWidth, setListWidth] = useState(0) + // The list's natural width (icons + labels). Used only inside the resize + // observer to decide whether to show or hide the icons — never read in + // render — so it lives in a ref to avoid an extra commit on mount and on + // every list resize. + const listWidthRef = useRef(0) useIsomorphicLayoutEffect(() => { if (!tabsHaveIcons) { return } - setListWidth(listRef.current?.getBoundingClientRect().width ?? 0) + listWidthRef.current = listRef.current?.getBoundingClientRect().width ?? 0 }, [tabsHaveIcons]) // when the wrapper resizes, check if the icons should be visible @@ -153,7 +178,7 @@ const UnderlinePanels: FCWithSlotMarker = ({ const wrapperWidth = resizeObserverEntries[0].contentRect.width - setIconsVisible(wrapperWidth > listWidth) + setIconsVisible(wrapperWidth > listWidthRef.current) }, wrapperRef, [], @@ -175,24 +200,27 @@ const UnderlinePanels: FCWithSlotMarker = ({ } return ( - - - - {tabs} - - - {tabPanels} - + + + + + {tabs} + + + {tabPanels} + + ) } -const Tab: FCWithSlotMarker = ({'aria-selected': ariaSelected, onSelect, ...props}) => { +const TabImpl: FCWithSlotMarker = ({'aria-selected': ariaSelected, onSelect, ...props}) => { + const {iconsVisible, loadingCounters} = useContext(UnderlinePanelsContext) const clickHandler = React.useCallback( (event: React.MouseEvent) => { if (!event.defaultPrevented && typeof onSelect === 'function') { @@ -219,11 +247,20 @@ const Tab: FCWithSlotMarker = ({'aria-selected': ariaSelected, onSelec type="button" onClick={clickHandler} onKeyDown={keyDownHandler} + iconsVisible={iconsVisible} + loadingCounters={loadingCounters} {...props} /> ) } +// Memoized so that UnderlinePanels re-rendering (e.g. when iconsVisible flips) +// only re-renders Tabs whose own props actually changed. iconsVisible and +// loadingCounters reach Tab via UnderlinePanelsContext, so Tabs still react +// to those changes through context propagation. +TabImpl.displayName = 'UnderlinePanels.Tab' +const Tab = React.memo(TabImpl) as unknown as FCWithSlotMarker + Tab.displayName = 'UnderlinePanels.Tab' const Panel: FCWithSlotMarker = ({children, ...rest}) => {