Skip to content
Open
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/navlist-parent-flicker-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": patch
---

Fix NavList parent item flicker during static-to-interactive transitions when navigating between current sub-items in a SubNav.
86 changes: 86 additions & 0 deletions packages/react/src/NavList/NavList.dev.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type {Meta} from '@storybook/react-vite'
import React, {useEffect} from 'react'
import {renderToStaticMarkup} from 'react-dom/server'
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Importing renderToStaticMarkup from react-dom/server in a Storybook story (browser bundle) can break Vite/Storybook builds because react-dom/server is Node-oriented. Prefer switching this import to react-dom/server.browser (if available in your React version), or restructure the story to avoid bundling the server renderer into the client (e.g., generate the static markup outside the browser bundle / via a build-time step).

Suggested change
import {renderToStaticMarkup} from 'react-dom/server'
import {renderToStaticMarkup} from 'react-dom/server.browser'

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove the .dev.stories after a maintainer has tested the changes locally. It's just to showcase the bug

import {PageLayout} from '../PageLayout'
import {NavList} from './NavList'
import {ArrowRightIcon, ArrowLeftIcon, BookIcon, FileDirectoryIcon} from '@primer/octicons-react'
Expand Down Expand Up @@ -69,3 +71,87 @@ export const WithGroupTitleAndHeading = () => (
<PageLayout.Content></PageLayout.Content>
</PageLayout>
)

type NavigationItem = 'item1' | 'subitem1' | 'subitem2' | 'item3'

const NAVIGATION_FALLBACK_ITEM: NavigationItem = 'subitem1'

function getNavigationItemFromHash(): NavigationItem {
const hash = window.location.hash.replace('#', '')
if (hash === 'item1' || hash === 'subitem1' || hash === 'subitem2' || hash === 'item3') {
return hash
}
return NAVIGATION_FALLBACK_ITEM
}

function StaticTransitionNav({currentItem}: {currentItem: NavigationItem}) {
return (
<PageLayout>
<PageLayout.Pane position="start">
<NavList>
<NavList.Item href="#item1" aria-current={currentItem === 'item1' ? 'page' : 'false'}>
Item 1
</NavList.Item>
<NavList.Item>
Item 2
<NavList.SubNav>
<NavList.Item href="#subitem1" aria-current={currentItem === 'subitem1' ? 'page' : 'false'}>
Sub item 1
</NavList.Item>
<NavList.Item href="#subitem2" aria-current={currentItem === 'subitem2' ? 'page' : 'false'}>
Sub item 2
</NavList.Item>
</NavList.SubNav>
</NavList.Item>
<NavList.Item href="#item3" aria-current={currentItem === 'item3' ? 'page' : 'false'}>
Item 3
</NavList.Item>
</NavList>
</PageLayout.Pane>
<PageLayout.Content></PageLayout.Content>
</PageLayout>
)
}

export const SubNavStaticToInteractiveTransition = () => {
const [currentItem, setCurrentItem] = React.useState<NavigationItem>(() => getNavigationItemFromHash())
const [renderMode, setRenderMode] = React.useState<'static' | 'interactive'>('static')

const staticMarkup = React.useMemo(() => {
return renderToStaticMarkup(<StaticTransitionNav currentItem={currentItem} />)
}, [currentItem])
Comment on lines +120 to +122
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Importing renderToStaticMarkup from react-dom/server in a Storybook story (browser bundle) can break Vite/Storybook builds because react-dom/server is Node-oriented. Prefer switching this import to react-dom/server.browser (if available in your React version), or restructure the story to avoid bundling the server renderer into the client (e.g., generate the static markup outside the browser bundle / via a build-time step).

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above, we can remove the .dev.stories after a maintainer has tested the changes locally. It's just to showcase the bug


useEffect(() => {
const abortController = new AbortController()
let transitionTimer: number | null = null

const showStaticThenInteractive = () => {
setCurrentItem(getNavigationItemFromHash())
setRenderMode('static')

if (transitionTimer) {
window.clearTimeout(transitionTimer)
}

// Keep static markup visible long enough to make the collapsed frame obvious.
transitionTimer = window.setTimeout(() => {
setRenderMode('interactive')
}, 180)
}

showStaticThenInteractive()
window.addEventListener('hashchange', showStaticThenInteractive, {signal: abortController.signal})

return () => {
abortController.abort()
if (transitionTimer) {
window.clearTimeout(transitionTimer)
}
}
}, [])

if (renderMode === 'static') return <div dangerouslySetInnerHTML={{__html: staticMarkup}} />
return <StaticTransitionNav currentItem={currentItem} />
}

SubNavStaticToInteractiveTransition.storyName = 'SubNav static-to-interactive transition'
6 changes: 6 additions & 0 deletions packages/react/src/NavList/NavList.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {describe, it, expect, vi} from 'vitest'
import {render, fireEvent, act} from '@testing-library/react'
import React from 'react'
import {renderToStaticMarkup} from 'react-dom/server'
import {NavList} from './NavList'
import {ReactRouterLikeLink} from '../Pagination/mocks/ReactRouterLink'
import {implementsClassName} from '../utils/testing'
Expand Down Expand Up @@ -148,6 +149,11 @@ describe('NavList.Item with NavList.SubNav', () => {
expect(queryByRole('list', {name: 'Item 2'})).toBeNull()
})

it('renders parent item expanded on initial static render when SubNav contains the current item', () => {
const markup = renderToStaticMarkup(<NavListWithCurrentSubNav />)
expect(markup).toContain('aria-expanded="true"')
})
Comment on lines +152 to +155
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion isn’t scoped to the parent item that should be expanded (it will pass if any aria-expanded="true" exists anywhere in the markup). Consider parsing the markup into a DOM container and asserting aria-expanded on the specific control associated with “Item 2” (e.g., query the toggle button by accessible name / nearby text, then assert its aria-expanded value).

Copilot uses AI. Check for mistakes.
Copy link
Author

@RSoeborg RSoeborg Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about it, but the test works to test what we want atm. I don't want to overcomplicate the test too much. An additional thought about this, is that maybe we should supress this warning:

Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes.%s 
    at ItemWithSubNav (http://localhost:63315/src/NavList/NavList.tsx:210:13)
    at http://localhost:63315/src/NavList/NavList.tsx:75:13
    at ul
    at UnwrappedList (http://localhost:63315/src/ActionList/List.tsx:15:9)
    at nav
    at http://localhost:63315/src/NavList/NavList.tsx:17:13
    at NavListWithCurrentSubNav (http://localhost:63315/Users/rso/repos/personal/primer-react/packages/react/src/NavList/NavList.test.tsx?import&browserv=1770504860076:189:15)

Using a consoleSpy and just ignoring the output like vi.spyOn(console, 'error').mockImplementation(() => null) ? I didn't do it because I'm not entirely sure if its bad practise or not.


it('hides SubNav by default if SubNav does not contain the current item', () => {
const {queryByRole} = render(<NavListWithSubNav />)
const subNav = queryByRole('list', {name: 'Item 2'})
Expand Down
34 changes: 30 additions & 4 deletions packages/react/src/NavList/NavList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,21 +123,47 @@ const ItemWithSubNavContext = React.createContext<{buttonId: string; subNavId: s
isOpen: false,
})

function hasCurrentNavItem(node: React.ReactNode): boolean {
if (
!isValidElement<{
children?: React.ReactNode
'aria-current'?: NavListItemProps['aria-current']
}>(node)
) {
return false
}

const ariaCurrent = node.props['aria-current']
if (Boolean(ariaCurrent) && ariaCurrent !== 'false') {
return true
}

if (!node.props.children) {
return false
}

return React.Children.toArray(node.props.children).some(hasCurrentNavItem)
}

function ItemWithSubNav({children, subNav, depth: _depth, defaultOpen, style}: ItemWithSubNavProps) {
const buttonId = useId()
const subNavId = useId()
const [isOpen, setIsOpen] = React.useState((defaultOpen || null) ?? false)
const subNavRef = React.useRef<HTMLDivElement>(null)
const [containsCurrentItem, setContainsCurrentItem] = React.useState(false)

// We have to use recursion to check if the current nav item is part of the subnav before initial render
// which is why we can't use the querySelector on the ref as it will cause the parent item to blink during first render.
const hasCurrentItem = React.useMemo(() => hasCurrentNavItem(subNav), [subNav])
const [isOpen, setIsOpen] = React.useState((defaultOpen || null) ?? hasCurrentItem)
const subNavRef = React.useRef<HTMLUListElement>(null)
const [containsCurrentItem, setContainsCurrentItem] = React.useState(hasCurrentItem)

useIsomorphicLayoutEffect(() => {
if (subNavRef.current) {
// Check if SubNav contains current item
// valid values: page, step, location, date, time, true and false
const currentItem = subNavRef.current.querySelector('[aria-current]:not([aria-current=false])')
setContainsCurrentItem(Boolean(currentItem))

if (currentItem) {
setContainsCurrentItem(true)
setIsOpen(true)
}
}
Expand Down