fix issues with navlist static to interactive subnav state#7511
fix issues with navlist static to interactive subnav state#7511RSoeborg wants to merge 6 commits intoprimer:mainfrom
Conversation
🦋 Changeset detectedLatest commit: 92c7214 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Pull request overview
Fixes a NavList flicker where a parent item with a SubNav briefly renders in the wrong expanded/current state when transitioning from static (SSR markup) to interactive (hydrated) state.
Changes:
- Add a recursive pre-render check to detect whether a SubNav contains the current item and initialize the parent’s open state accordingly.
- Add an SSR-focused unit test to verify initial expanded rendering when a current SubNav item exists.
- Add a dev Storybook scenario that reproduces the static-to-interactive transition flicker.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| packages/react/src/NavList/NavList.tsx | Pre-computes “contains current item” before first render to avoid initial-state mismatch/flicker. |
| packages/react/src/NavList/NavList.test.tsx | Adds SSR test to validate initial expanded state when SubNav contains the current item. |
| packages/react/src/NavList/NavList.dev.stories.tsx | Adds an amplified Storybook repro by switching between static HTML and interactive render. |
There was a problem hiding this comment.
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).
| import {renderToStaticMarkup} from 'react-dom/server' | |
| import {renderToStaticMarkup} from 'react-dom/server.browser' |
There was a problem hiding this comment.
We can remove the .dev.stories after a maintainer has tested the changes locally. It's just to showcase the bug
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
Same as above, we can remove the .dev.stories after a maintainer has tested the changes locally. It's just to showcase the bug
There was a problem hiding this comment.
Thanks for the contribution @RSoeborg! The videos really helped, the flicker is immediately obvious and the before/after comparison makes the fix crystal clear. Great job explaining the issue. Let's fix a couple things and merge this :)
hasCurrentNavItem walks the React children tree
This recursively calls isValidElement, reads node.props['aria-current'], and iterates children with React.Children.toArray. I understand why, the existing querySelector approach runs in a layout effect which is too late to avoid the flash. But now we have two code paths doing the same check differently (React tree walk for initial render, DOM query in the effect), and they need to stay in sync.
Could we remove the querySelector path entirely and rely only on hasCurrentNavItem? If the React tree walk is correct at init time, it should also be correct on subsequent updates. That way there's one source of truth instead of two. If there's a reason the DOM query is still needed (e.g. children that don't have aria-current in props but do in the rendered DOM), let's document that.
Test assertion is too loose
expect(markup).toContain('aria-expanded="true"')This passes if any element in the markup has aria-expanded="true", not specifically the parent item ("Item 2"). Parse the markup into a container and assert on the specific button:
const container = document.createElement('div')
container.innerHTML = renderToStaticMarkup(<NavListWithCurrentSubNav />)
const item2Button = container.querySelector('button[aria-expanded]')
expect(item2Button).toHaveAttribute('aria-expanded', 'true')
// or even better, query by accessible name to be explicit about which itemRemove the dev story
I've seen the issue and confirmed the fix, so let's remove the dev story before merge. The renderToStaticMarkup import from react-dom/server in a browser bundle would cause build issues anyway, and the test covers the behavior.
Minor: ref type change
The subNavRef type changed from HTMLDivElement to HTMLUListElement. This looks correct but is unrelated to the fix, worth calling out in the PR description.
…v transition" This reverts commit e917a34.
…render in ItemWithSubNav
…hich element is expanded
Good point, I changed the code to use When I did this I also chose to remove the
Agreed. I updated the tests. One thing I also did, however; is this to suppress a warning specifically when we create the static markup with my console spy: |
Changelog
This PR fixes a visual flicker in NavList where a parent item with SubNav could briefly appear in the wrong state when navigating between sibling sub-items.
A small additional change when touching this code, is that the "subNavRef" had the wrong type
HTMLDivElement, I changed that toHTMLUListElementThis can be seen here (example happening on the docs site
primer.style):example.from.docs.mov
Notice how
Foundationsflicker when I change submenu.Here is a minimal example (amplified)
amplified.example.mov
I updated the dev storybook with an amplified example, that makes it easier to see whats actually happening.
Example after fix
postfix.mov
This is how it looks after the fix.
Rollout strategy
Testing & Reviewing
Just replace these parts:
With the "current" behaviour:
and test in storybook: http://localhost:6006/?path=/story/components-navlist-dev--sub-nav-static-to-interactive-transition
Merge checklist