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}