Skip to content

Commit eb73dee

Browse files
RSoeborgprimer[bot]hectahertzliuliu-dev
authored
fix issues with navlist static to interactive subnav state (#7511)
Co-authored-by: primer[bot] <119360173+primer[bot]@users.noreply.github.com> Co-authored-by: Hector Garcia <hectahertz@github.com> Co-authored-by: LiuLiu <liuliu-dev@github.com>
1 parent 8330aad commit eb73dee

3 files changed

Lines changed: 61 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/react": patch
3+
---
4+
5+
Fix NavList parent item flicker during static-to-interactive transitions when navigating between current sub-items in a SubNav.

packages/react/src/NavList/NavList.test.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {describe, it, expect, vi} from 'vitest'
22
import {render, fireEvent, act} from '@testing-library/react'
33
import React from 'react'
4+
import {renderToStaticMarkup} from 'react-dom/server'
45
import {NavList} from './NavList'
56
import {ReactRouterLikeLink} from '../Pagination/mocks/ReactRouterLink'
67
import {implementsClassName} from '../utils/testing'
@@ -147,6 +148,23 @@ describe('NavList.Item with NavList.SubNav', () => {
147148
expect(queryByRole('list', {name: 'Item 2'})).toBeNull()
148149
})
149150

151+
it('renders parent item expanded on initial static render when SubNav contains the current item', () => {
152+
// intentionally suppress the expected React SSR useLayoutEffect warning
153+
// this test focuses specifically on the initial SSR render
154+
const container = document.createElement('div')
155+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => null)
156+
try {
157+
container.innerHTML = renderToStaticMarkup(<NavListWithCurrentSubNav />)
158+
} finally {
159+
consoleSpy.mockRestore()
160+
}
161+
162+
const item2Button = container.querySelector('button[aria-expanded]')
163+
expect(item2Button).not.toBeNull()
164+
expect(item2Button).toHaveAttribute('aria-expanded', 'true')
165+
expect(item2Button?.textContent).toBe('Item 2')
166+
})
167+
150168
it('hides SubNav by default if SubNav does not contain the current item', () => {
151169
const {queryByRole} = render(<NavListWithSubNav />)
152170
const subNav = queryByRole('list', {name: 'Item 2'})

packages/react/src/NavList/NavList.tsx

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -137,23 +137,49 @@ const ItemWithSubNavContext = React.createContext<{buttonId: string; subNavId: s
137137
isOpen: false,
138138
})
139139

140+
function hasCurrentNavItem(node: React.ReactNode): boolean {
141+
if (
142+
!isValidElement<{
143+
children?: React.ReactNode
144+
'aria-current'?: NavListItemProps['aria-current']
145+
}>(node)
146+
) {
147+
return false
148+
}
149+
150+
const ariaCurrent = node.props['aria-current']
151+
if (Boolean(ariaCurrent) && ariaCurrent !== 'false') {
152+
return true
153+
}
154+
155+
if (!node.props.children) {
156+
return false
157+
}
158+
159+
return React.Children.toArray(node.props.children).some(hasCurrentNavItem)
160+
}
161+
140162
function ItemWithSubNav({children, subNav, depth: _depth, defaultOpen, style}: ItemWithSubNavProps) {
141163
const buttonId = useId()
142164
const subNavId = useId()
143-
const [isOpen, setIsOpen] = React.useState((defaultOpen || null) ?? false)
144-
const subNavRef = React.useRef<HTMLDivElement>(null)
145-
const [containsCurrentItem, setContainsCurrentItem] = React.useState(false)
165+
166+
// We have to use recursion to check if the current nav item is part of the subnav before initial render
167+
// which is why we can't use the querySelector on the ref as it will cause the parent item to blink during first render.
168+
const hasCurrentItem = React.useMemo(() => hasCurrentNavItem(subNav), [subNav])
169+
const [isOpen, setIsOpen] = React.useState((defaultOpen || null) ?? hasCurrentItem)
170+
const subNavRef = React.useRef<HTMLUListElement>(null)
171+
const [containsCurrentItem, setContainsCurrentItem] = React.useState(hasCurrentItem)
146172

147173
useIsomorphicLayoutEffect(() => {
148-
if (subNavRef.current) {
149-
// Check if SubNav contains current item
150-
// valid values: page, step, location, date, time, true and false
151-
const currentItem = subNavRef.current.querySelector('[aria-current]:not([aria-current=false])')
152-
153-
if (currentItem) {
154-
setContainsCurrentItem(true)
155-
setIsOpen(true)
156-
}
174+
// The React tree check handles the initial render before refs exist. The DOM fallback handles custom link
175+
// components that compute aria-current internally instead of receiving it as a prop.
176+
// valid values: page, step, location, date, time, true and false
177+
const currentItem =
178+
hasCurrentNavItem(subNav) || Boolean(subNavRef.current?.querySelector('[aria-current]:not([aria-current=false])'))
179+
setContainsCurrentItem(currentItem)
180+
181+
if (currentItem) {
182+
setIsOpen(true)
157183
}
158184
}, [subNav, buttonId])
159185

0 commit comments

Comments
 (0)