Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/underlinenav-rerender-on-children-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Fix `UnderlineNav` not re-rendering when child tabs are added or removed.
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -154,3 +155,29 @@ export const VariantFlush = () => {
</UnderlineNav>
)
}

export const DynamicChildren = () => {
const [showItem, setShowItem] = React.useState(false)

return (
<div>
<Button
onClick={() => {
setShowItem(prev => !prev)
}}
>
{showItem ? 'Hide item' : 'Show item'}
</Button>
<UnderlineNav aria-label="Repository">
<UnderlineNav.Item href="#" aria-current="page">
Code
</UnderlineNav.Item>
<UnderlineNav.Item href="#">Pull requests</UnderlineNav.Item>
<UnderlineNav.Item href="#">Actions</UnderlineNav.Item>
<UnderlineNav.Item href="#">Projects</UnderlineNav.Item>
<UnderlineNav.Item href="#">Wiki</UnderlineNav.Item>
{showItem && <UnderlineNav.Item href="#">Another</UnderlineNav.Item>}
</UnderlineNav>
</div>
)
}
52 changes: 50 additions & 2 deletions packages/react/src/UnderlineNav/UnderlineNav.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 (
<div>
<button type="button" onClick={() => setShowItem(prev => !prev)}>
{showItem ? 'hide item' : 'show item'}
</button>
<UnderlineNav aria-label="Repository">
<UnderlineNav.Item href="#" aria-current="page">
Code
</UnderlineNav.Item>
<UnderlineNav.Item href="#">Pull requests</UnderlineNav.Item>
<UnderlineNav.Item href="#">Actions</UnderlineNav.Item>
<UnderlineNav.Item href="#">Projects</UnderlineNav.Item>
<UnderlineNav.Item href="#">Wiki</UnderlineNav.Item>
{showItem && <UnderlineNav.Item href="#">Another</UnderlineNav.Item>}
</UnderlineNav>
</div>
)
}

render(<Example />)
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', () => {
Expand Down
38 changes: 25 additions & 13 deletions packages/react/src/UnderlineNav/UnderlineNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<HTMLElement>)
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<HTMLElement>,
[childrenSignature],
)

// Compute menuInlineStyles if needed
let menuInlineStyles: React.CSSProperties = {...baseMenuInlineStyles}
Expand Down
Loading