diff --git a/.changeset/underlinenav-rerender-on-children-change.md b/.changeset/underlinenav-rerender-on-children-change.md new file mode 100644 index 00000000000..9260a5ccd26 --- /dev/null +++ b/.changeset/underlinenav-rerender-on-children-change.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Fix `UnderlineNav` not re-rendering when child tabs are added or removed. diff --git a/packages/react/src/UnderlineNav/UnderlineNav.features.stories.tsx b/packages/react/src/UnderlineNav/UnderlineNav.features.stories.tsx index 6b35a2c739b..39c1749ba87 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.features.stories.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.features.stories.tsx @@ -13,6 +13,7 @@ import { } from '@primer/octicons-react' import type {Meta} from '@storybook/react-vite' import {UnderlineNav} from './index' +import {Button} from '../Button' import {INITIAL_VIEWPORTS} from 'storybook/viewport' const meta = { @@ -154,3 +155,29 @@ export const VariantFlush = () => { ) } + +export const DynamicChildren = () => { + const [showItem, setShowItem] = React.useState(false) + + return ( +
+ + + + Code + + Pull requests + Actions + Projects + Wiki + {showItem && Another} + +
+ ) +} diff --git a/packages/react/src/UnderlineNav/UnderlineNav.test.tsx b/packages/react/src/UnderlineNav/UnderlineNav.test.tsx index 1ab761f8e34..ca256c8cc86 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.test.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.test.tsx @@ -1,6 +1,6 @@ import {describe, expect, it, vi} from 'vitest' -import type React from 'react' -import {render, screen} from '@testing-library/react' +import React from 'react' +import {render, screen, waitFor} from '@testing-library/react' import userEvent from '@testing-library/user-event' import { CodeIcon, @@ -240,6 +240,54 @@ describe('UnderlineNav', () => { const textSpan = item.querySelector('[data-component="text"]') expect(textSpan).toHaveAttribute('data-content', 'Simple Text') }) + + // Regression test for https://github.com/github/primer/issues/6389 + it('re-renders when a conditionally rendered child tab is toggled', async () => { + const Example = () => { + const [showItem, setShowItem] = React.useState(false) + return ( +
+ + + + Code + + Pull requests + Actions + Projects + Wiki + {showItem && Another} + +
+ ) + } + + render() + const toggle = screen.getByRole('button', {name: 'show item'}) + const user = userEvent.setup() + + // Initially the conditional tab is not rendered + expect(screen.queryByText('Another')).not.toBeInTheDocument() + + // Toggle on: the new tab should appear in the DOM (either in the visible list + // or the overflow menu) without the nav being remounted. `findByText` waits + // for the ResizeObserver-driven re-render that runs after `childrenSignature` + // changes (the callback is async in real browsers like chromium). + await user.click(toggle) + expect(await screen.findByText('Another')).toBeInTheDocument() + // Existing tabs are still present + expect(screen.getByText('Code')).toBeInTheDocument() + expect(screen.getByText('Pull requests')).toBeInTheDocument() + + // Toggle off: the tab should be removed + await user.click(screen.getByRole('button', {name: 'hide item'})) + await waitFor(() => { + expect(screen.queryByText('Another')).not.toBeInTheDocument() + }) + expect(screen.getByText('Code')).toBeInTheDocument() + }) }) describe('Keyboard Navigation', () => { diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index 21774554c2f..00bd84341a4 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -175,6 +175,14 @@ export const UnderlineNav = forwardRef( menuItems: [], }) + // Track the set of children so the overflow effect re-runs whenever children are + // added or removed (not just when the nav element resizes). Keys are sorted so that + // simply reordering existing children doesn't trigger an unnecessary recalculation. + const childrenSignature = validChildren + .map(child => String(child.key ?? '')) + .sort() + .join('|') + // Make sure to have the fresh props data for list items when children are changed (keeping aria-current up-to-date) const listItems = responsiveProps.items.map(item => { return validChildren.find(child => child.key === item.key) ?? item @@ -294,19 +302,23 @@ export const UnderlineNav = forwardRef( useOnOutsideClick({onClickOutside: closeOverlay, containerRef, ignoreClickRefs: [moreMenuBtnRef]}) - useResizeObserver((resizeObserverEntries: ResizeObserverEntry[]) => { - const navWidth = resizeObserverEntries[0].contentRect.width - const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0 - navWidth !== 0 && - overflowEffect( - navWidth, - moreMenuWidth, - validChildren, - childWidthArray, - noIconChildWidthArray, - updateListAndMenu, - ) - }, navRef as RefObject) + useResizeObserver( + (resizeObserverEntries: ResizeObserverEntry[]) => { + const navWidth = resizeObserverEntries[0].contentRect.width + const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0 + navWidth !== 0 && + overflowEffect( + navWidth, + moreMenuWidth, + validChildren, + childWidthArray, + noIconChildWidthArray, + updateListAndMenu, + ) + }, + navRef as RefObject, + [childrenSignature], + ) // Compute menuInlineStyles if needed let menuInlineStyles: React.CSSProperties = {...baseMenuInlineStyles}