11import { useRouter } from 'next/router'
2- import { type MouseEvent , type ReactNode , useEffect , useState } from 'react'
2+ import {
3+ createContext ,
4+ memo ,
5+ type MouseEvent ,
6+ type ReactNode ,
7+ useCallback ,
8+ useContext ,
9+ useEffect ,
10+ useMemo ,
11+ useState ,
12+ } from 'react'
313import { NavList } from '@primer/react-brand'
414
515import { ProductTreeNode , useMainContext } from '@/frame/components/context/MainContext'
616import { useAutomatedPageContext } from '@/automated-pipelines/components/AutomatedPageContext'
717import { nonAutomatedRestPaths } from '@/rest/lib/config'
18+ import { usePrefetchOnInteraction } from '@/frame/components/lib/prefetch'
819import { SidebarExpandStateProvider , useSidebarExpandState } from './useSidebarExpandState'
920import { flattenDescendants , MAX_NAVLIST_LEVEL } from './sidebar-navlist-depth'
1021
1122import styles from './SidebarProduct.module.scss'
1223
24+ type Router = ReturnType < typeof useRouter >
25+
1326// Brand NavList.Item renders a plain <a> (its `as` prop only accepts 'a' | 'button',
1427// not next/link), so intercept clicks to restore next/link-style client-side
1528// navigation. Modifier/middle clicks fall through to the browser so open-in-new-tab
1629// still works, and the <a href> keeps links crawlable for SSR. Mirrors Breadcrumbs.tsx.
17- function handleNavClick (
18- router : ReturnType < typeof useRouter > ,
19- event : MouseEvent < HTMLElement > ,
20- href : string ,
21- ) {
30+ function handleNavClick ( router : Router , event : MouseEvent < HTMLElement > , href : string ) {
2231 if (
2332 event . defaultPrevented ||
2433 event . button !== 0 ||
@@ -36,6 +45,50 @@ function handleNavClick(
3645 router . push ( href , undefined , { locale : false } )
3746}
3847
48+ // The sidebar renders the full product tree (hundreds of nodes) and fully remounts
49+ // on every navigation (key={asPath} in SidebarNav). To keep per-item cost down we
50+ // subscribe to the router ONCE here and hand items a stable routePath plus stable
51+ // navigate/prefetch callbacks, instead of every item calling useRouter itself.
52+ type SidebarNavValue = {
53+ routePath : string
54+ navigate : ( event : MouseEvent < HTMLElement > , href : string ) => void
55+ prefetch : ( href : string ) => void
56+ }
57+ const SidebarNavContext = createContext < SidebarNavValue | null > ( null )
58+
59+ function useSidebarNav ( ) : SidebarNavValue {
60+ const value = useContext ( SidebarNavContext )
61+ if ( ! value ) {
62+ throw new Error ( 'useSidebarNav must be used within SidebarProduct' )
63+ }
64+ return value
65+ }
66+
67+ // Separate context for the REST-only scroll-spy state (full asPath with query+hash,
68+ // and query). Kept out of SidebarNavValue so its per-navigation identity churn
69+ // doesn't invalidate the memoized common items — only RestNavListItem consumes it.
70+ type RestNavValue = {
71+ asPath : string
72+ query : ReturnType < typeof useRouter > [ 'query' ]
73+ }
74+ const RestNavContext = createContext < RestNavValue | null > ( null )
75+
76+ function useRestNav ( ) : RestNavValue {
77+ const value = useContext ( RestNavContext )
78+ if ( ! value ) {
79+ throw new Error ( 'useRestNav must be used within SidebarProduct REST section' )
80+ }
81+ return value
82+ }
83+
84+ // Hover/focus handlers for a leaf link: warm the destination so the click is fast.
85+ function prefetchHandlers ( prefetch : ( href : string ) => void , href : string ) {
86+ return {
87+ onMouseEnter : ( ) => prefetch ( href ) ,
88+ onFocus : ( ) => prefetch ( href ) ,
89+ }
90+ }
91+
3992export const SidebarProduct = ( ) => {
4093 const router = useRouter ( )
4194 const {
@@ -47,6 +100,22 @@ export const SidebarProduct = () => {
47100 } = useMainContext ( )
48101 const isRestPage = currentProduct && currentProduct . id === 'rest'
49102
103+ const { asPath, locale, query } = router
104+ const routePath = `/${ locale } ${ asPath . split ( '?' ) [ 0 ] . split ( '#' ) [ 0 ] } `
105+
106+ const prefetchHref = usePrefetchOnInteraction ( )
107+ // Stable callbacks so memoized items don't re-render on unrelated changes.
108+ const navigate = useCallback (
109+ ( event : MouseEvent < HTMLElement > , href : string ) => handleNavClick ( router , event , href ) ,
110+ [ router ] ,
111+ )
112+ const prefetch = useCallback ( ( href : string ) => prefetchHref ( router , href ) , [ router , prefetchHref ] )
113+ const navValue = useMemo < SidebarNavValue > (
114+ ( ) => ( { routePath, navigate, prefetch } ) ,
115+ [ routePath , navigate , prefetch ] ,
116+ )
117+ const restNavValue = useMemo < RestNavValue > ( ( ) => ( { asPath, query } ) , [ asPath , query ] )
118+
50119 useEffect ( ( ) => {
51120 // Brand NavList auto-expands the whole ancestor chain of the active item, so
52121 // scroll to the item marked aria-current="page" (the active article) rather
@@ -84,31 +153,35 @@ export const SidebarProduct = () => {
84153 nonAutomatedRestPaths . every ( ( item : string ) => ! page . href . includes ( item ) ) ,
85154 )
86155 return (
87- < div >
88- < NavList aria-label = "REST sidebar overview articles" >
89- { navListLevelSentinel ( ) }
90- { conceptualPages . map ( ( childPage ) => (
91- < NavListItem key = { childPage . href } childPage = { childPage } />
92- ) ) }
93- </ NavList >
156+ < RestNavContext . Provider value = { restNavValue } >
157+ < div >
158+ < NavList aria-label = "REST sidebar overview articles" >
159+ { navListLevelSentinel ( ) }
160+ { conceptualPages . map ( ( childPage ) => (
161+ < NavListItem key = { childPage . href } childPage = { childPage } />
162+ ) ) }
163+ </ NavList >
94164
95- < hr data-testid = "rest-sidebar-reference" className = "m-2" />
165+ < hr data-testid = "rest-sidebar-reference" className = "m-2" />
96166
97- < NavList aria-label = "REST sidebar reference pages" >
98- { navListLevelSentinel ( ) }
99- { restPages . map ( ( category ) => (
100- < RestNavListItem key = { category . href } category = { category } />
101- ) ) }
102- </ NavList >
103- </ div >
167+ < NavList aria-label = "REST sidebar reference pages" >
168+ { navListLevelSentinel ( ) }
169+ { restPages . map ( ( category ) => (
170+ < RestNavListItem key = { category . href } category = { category } />
171+ ) ) }
172+ </ NavList >
173+ </ div >
174+ </ RestNavContext . Provider >
104175 )
105176 }
106177
107178 return (
108179 < div data-testid = "sidebar" className = { styles . sidebar } >
109- < SidebarExpandStateProvider initial = { sidebarExpanded } >
110- { isRestPage ? restSection ( ) : productSection ( ) }
111- </ SidebarExpandStateProvider >
180+ < SidebarNavContext . Provider value = { navValue } >
181+ < SidebarExpandStateProvider initial = { sidebarExpanded } >
182+ { isRestPage ? restSection ( ) : productSection ( ) }
183+ </ SidebarExpandStateProvider >
184+ </ SidebarNavContext . Provider >
112185 </ div >
113186 )
114187}
@@ -169,26 +242,30 @@ function navListLevelSentinel() {
169242 )
170243}
171244
172- function LeafLink ( { node } : { node : ProductTreeNode } ) {
173- const router = useRouter ( )
174- const { asPath, locale } = router
175- const routePath = `/${ locale } ${ asPath . split ( '?' ) [ 0 ] . split ( '#' ) [ 0 ] } `
245+ const LeafLink = memo ( function LeafLink ( { node } : { node : ProductTreeNode } ) {
246+ const { routePath, navigate, prefetch } = useSidebarNav ( )
176247 return (
177248 < NavList . Item
178249 as = "a"
179250 href = { node . href }
180251 aria-current = { routePath === node . href ? 'page' : false }
181- onClick = { ( event : MouseEvent < HTMLElement > ) => handleNavClick ( router , event , node . href ) }
252+ onClick = { ( event : MouseEvent < HTMLElement > ) => navigate ( event , node . href ) }
253+ { ...prefetchHandlers ( prefetch , node . href ) }
182254 >
183255 { node . title }
184256 </ NavList . Item >
185257 )
186- }
258+ } )
187259
188- function NavListItem ( { childPage, level = 1 } : { childPage : ProductTreeNode ; level ?: number } ) {
189- const router = useRouter ( )
190- const { asPath, locale } = router
191- const routePath = `/${ locale } ${ asPath . split ( '?' ) [ 0 ] . split ( '#' ) [ 0 ] } `
260+ const NavListItem = memo ( function NavListItem ( {
261+ childPage,
262+ level = 1 ,
263+ } : {
264+ childPage : ProductTreeNode
265+ level ?: number
266+ } ) {
267+ const { routePath, navigate, prefetch } = useSidebarNav ( )
268+ const locale = routePath . split ( '/' ) [ 1 ]
192269 const isActive = routePath === childPage . href
193270 const hasChildren = childPage . childPages . length > 0
194271 const specialCategory = childPage . layout === 'category-landing'
@@ -231,9 +308,8 @@ function NavListItem({ childPage, level = 1 }: { childPage: ProductTreeNode; lev
231308 as = "a"
232309 href = { sidebarLinkHref }
233310 aria-current = { routePath === sidebarLinkHref ? 'page' : false }
234- onClick = { ( event : MouseEvent < HTMLElement > ) =>
235- handleNavClick ( router , event , sidebarLinkHref )
236- }
311+ onClick = { ( event : MouseEvent < HTMLElement > ) => navigate ( event , sidebarLinkHref ) }
312+ { ...prefetchHandlers ( prefetch , sidebarLinkHref ) }
237313 >
238314 { childPage . sidebarLink . text }
239315 </ NavList . Item >
@@ -243,9 +319,8 @@ function NavListItem({ childPage, level = 1 }: { childPage: ProductTreeNode; lev
243319 as = "a"
244320 href = { childPage . href }
245321 aria-current = { isActive ? 'page' : false }
246- onClick = { ( event : MouseEvent < HTMLElement > ) =>
247- handleNavClick ( router , event , childPage . href )
248- }
322+ onClick = { ( event : MouseEvent < HTMLElement > ) => navigate ( event , childPage . href ) }
323+ { ...prefetchHandlers ( prefetch , childPage . href ) }
249324 >
250325 { childPage . title }
251326 </ NavList . Item >
@@ -255,13 +330,12 @@ function NavListItem({ childPage, level = 1 }: { childPage: ProductTreeNode; lev
255330 ) ) }
256331 </ ExpandableItem >
257332 )
258- }
333+ } )
259334
260335function RestNavListItem ( { category } : { category : ProductTreeNode } ) {
261- const router = useRouter ( )
262- const { query , asPath, locale } = router
336+ const { routePath , navigate , prefetch } = useSidebarNav ( )
337+ const { asPath, query } = useRestNav ( )
263338 const [ visibleAnchor , setVisibleAnchor ] = useState ( '' )
264- const routePath = `/${ locale } ${ asPath . split ( '?' ) [ 0 ] . split ( '#' ) [ 0 ] } `
265339 const miniTocItems =
266340 query . productId === 'rest' ||
267341 // These pages need the Article Page mini tocs instead of the Rest Pages
@@ -305,7 +379,8 @@ function RestNavListItem({ category }: { category: ProductTreeNode }) {
305379 as = "a"
306380 href = { category . href }
307381 aria-current = { routePath === category . href ? 'page' : false }
308- onClick = { ( event : MouseEvent < HTMLElement > ) => handleNavClick ( router , event , category . href ) }
382+ onClick = { ( event : MouseEvent < HTMLElement > ) => navigate ( event , category . href ) }
383+ { ...prefetchHandlers ( prefetch , category . href ) }
309384 >
310385 { category . title }
311386 </ NavList . Item >
@@ -355,9 +430,8 @@ function RestNavListItem({ category }: { category: ProductTreeNode }) {
355430 as = "a"
356431 href = { childPage . href }
357432 aria-current = { routePath === childPage . href ? 'page' : false }
358- onClick = { ( event : MouseEvent < HTMLElement > ) =>
359- handleNavClick ( router , event , childPage . href )
360- }
433+ onClick = { ( event : MouseEvent < HTMLElement > ) => navigate ( event , childPage . href ) }
434+ { ...prefetchHandlers ( prefetch , childPage . href ) }
361435 >
362436 { childPage . title }
363437 </ NavList . Item >
0 commit comments