Skip to content

Commit 9e89fc8

Browse files
authored
Speed up sidebar navigation on large products (#62461)
1 parent 29d8e50 commit 9e89fc8

3 files changed

Lines changed: 179 additions & 49 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { useCallback } from 'react'
2+
import type { useRouter } from 'next/router'
3+
4+
type Router = ReturnType<typeof useRouter>
5+
6+
// Session-lived de-dupe set. Module scope (not a per-component useRef) so it
7+
// survives the sidebar's per-navigation remount — otherwise the cache would
8+
// reset every nav and "de-duped per href" would only hold within a single page.
9+
// Bounded by the number of distinct internal hrefs the user hovers/focuses.
10+
const prefetchedHrefs = new Set<string>()
11+
12+
// Brand NavList/Breadcrumbs items render plain <a>s navigated via router.push,
13+
// so Next.js doesn't prefetch their destinations the way next/link would. This
14+
// hook warms a route on hover/focus.
15+
//
16+
// Scope of the benefit here is small on purpose: every article is a
17+
// getServerSideProps route, and router.prefetch only fetches the JS bundle for
18+
// those (not the getServerSideProps data), while page data is already served fast
19+
// from the Fastly edge. All articles also share one [...restPage] bundle, so after
20+
// the first navigation there's usually nothing left to fetch. It still helps the
21+
// first cold visit, and would fetch data too if a route ever moves to
22+
// getStaticProps. router.prefetch is production-only (no prefetch in dev); we
23+
// de-dupe per href so re-entering a link doesn't re-issue.
24+
export function usePrefetchOnInteraction() {
25+
const prefetch = useCallback(async (router: Router, href: string) => {
26+
if (
27+
process.env.NODE_ENV !== 'production' ||
28+
!href.startsWith('/') ||
29+
prefetchedHrefs.has(href)
30+
) {
31+
return
32+
}
33+
prefetchedHrefs.add(href)
34+
try {
35+
// hrefs already include the locale prefix (e.g. /en/...), so disable Next.js
36+
// locale handling to match the router.push calls at the click sites.
37+
await router.prefetch(href, undefined, { locale: false })
38+
} catch {
39+
// A transient chunk/network failure shouldn't permanently suppress retries;
40+
// un-mark so a later hover/focus can try again (mirrors next/link).
41+
prefetchedHrefs.delete(href)
42+
}
43+
}, [])
44+
45+
return prefetch
46+
}

src/frame/components/page-header/Breadcrumbs.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import type { MouseEvent } from 'react'
1+
import { type MouseEvent, useCallback } from 'react'
22
import { useRouter } from 'next/router'
33
import cx from 'classnames'
44
import { Breadcrumbs as BrandBreadcrumbs } from '@primer/react-brand'
55

66
import { useMainContext } from '../context/MainContext'
77
import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion'
88
import { useTranslation } from '@/languages/components/useTranslation'
9+
import { usePrefetchOnInteraction } from '@/frame/components/lib/prefetch'
910

1011
type Props = {
1112
inHeader?: boolean
@@ -26,6 +27,11 @@ export const Breadcrumbs = ({ inHeader, variant }: Props) => {
2627
const router = useRouter()
2728
const { currentVersion } = useVersion()
2829
const { t } = useTranslation('header')
30+
const prefetchHref = usePrefetchOnInteraction()
31+
32+
// Warm a breadcrumb destination on hover/focus. BrandBreadcrumbs.Item renders a
33+
// plain <a> navigated via router.push, so Next.js won't prefetch it otherwise.
34+
const prefetch = useCallback((href: string) => prefetchHref(router, href), [router, prefetchHref])
2935

3036
const placement = variant ?? (inHeader ? 'header' : 'in-article')
3137
// Only the in-article placement hides the current (last) crumb; the header and
@@ -74,6 +80,8 @@ export const Breadcrumbs = ({ inHeader, variant }: Props) => {
7480
href={homeHref}
7581
title={t('go_home')}
7682
onClick={(event) => handleClick(event, homeHref)}
83+
onMouseEnter={() => prefetch(homeHref)}
84+
onFocus={() => prefetch(homeHref)}
7785
>
7886
{t('go_home')}
7987
</BrandBreadcrumbs.Item>
@@ -96,6 +104,8 @@ export const Breadcrumbs = ({ inHeader, variant }: Props) => {
96104
href={breadcrumb.href}
97105
title={title}
98106
onClick={(event) => handleClick(event, breadcrumb.href!)}
107+
onMouseEnter={() => prefetch(breadcrumb.href!)}
108+
onFocus={() => prefetch(breadcrumb.href!)}
99109
className={cx(
100110
// Show the last breadcrumb if it's in the header/bar, but not if it's in the article.
101111
// If there's only 1 breadcrumb, show it.

src/landings/components/SidebarProduct.tsx

Lines changed: 122 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,33 @@
11
import { 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'
313
import { NavList } from '@primer/react-brand'
414

515
import { ProductTreeNode, useMainContext } from '@/frame/components/context/MainContext'
616
import { useAutomatedPageContext } from '@/automated-pipelines/components/AutomatedPageContext'
717
import { nonAutomatedRestPaths } from '@/rest/lib/config'
18+
import { usePrefetchOnInteraction } from '@/frame/components/lib/prefetch'
819
import { SidebarExpandStateProvider, useSidebarExpandState } from './useSidebarExpandState'
920
import { flattenDescendants, MAX_NAVLIST_LEVEL } from './sidebar-navlist-depth'
1021

1122
import 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+
3992
export 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

260335
function 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

Comments
 (0)