perf(nav): hover-intent prefetch for the dashboard nav + 30 s client router cache (#1943)

* perf(nav): prefetch dashboard routes on hover intent, not on viewport

DashboardNav renders ~45 links, all dynamic routes with a loading
boundary, so Next prefetched every one of them as soon as the nav mounted.
Each prefetch is a full request through the auth proxy (Supabase Auth
round trip, active-company RPC, MFA check) whose only payload is the
shared loading skeleton; prod logs showed 1,000 to 1,300 such hits per nav
route per day.

NavLink wraps next/link with prefetch={false} and an explicit
router.prefetch on mouseenter/focus/touchstart (Link's own hover prefetch
is disabled together with viewport prefetch, so the warm-up must be
explicit). The link to the current route and non-routes are skipped
(shouldWarmNavRoute, tested). A source-shape test pins that DashboardNav
has no bare next/link left.

Cost: an un-hovered click shows the route's loading skeleton ~50-100 ms
later than before; the skeleton is all a dynamic prefetch ever carried.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(router): keep dynamic routes in the client router cache for 30 s

experimental.staleTimes.dynamic was 0: every back/forward or repeated nav
click re-requested the RSC payload through the auth proxy. 30 s covers the
click-around pattern the customer described while the 16 router.refresh()
sites after mutations keep the pages that must not go stale fresh.
Separate commit so it can be dropped on its own if stale numbers are
reported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-26 14:01:37 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent c31933b15b
commit a3326a0296
5 changed files with 131 additions and 21 deletions
+21 -21
View File
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState, useRef } from 'react'
import Link from 'next/link'
import { NavLink } from './NavLink'
import Image from 'next/image'
import { usePathname, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
@@ -605,9 +605,9 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
: 'text-muted-foreground/40 cursor-not-allowed',
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
<NavLink key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
</NavLink>
) : (
<div
key={item.href}
@@ -683,9 +683,9 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
: 'opacity-40 cursor-not-allowed',
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass} title={label} aria-label={label}>
<NavLink key={item.href} href={item.href} className={baseClass} title={label} aria-label={label}>
{inner}
</Link>
</NavLink>
) : (
<div key={item.href} className={baseClass} title={label} aria-disabled="true">
{inner}
@@ -712,7 +712,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
: 'justify-between pl-5 pr-3',
)}
>
<Link
<NavLink
href="/"
aria-label={getBranding().appName}
className="flex items-center rounded-lg"
@@ -724,7 +724,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
height={26}
className="h-[26px] w-[26px] rounded-lg"
/>
</Link>
</NavLink>
<button
type="button"
onClick={toggleCollapsed}
@@ -767,7 +767,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const label = labelTranslationKey ? tNav(labelTranslationKey) : item.label
const active = isActive(item.href)
return (
<Link
<NavLink
key={item.href}
href={item.href}
title={label}
@@ -783,7 +783,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground',
)}
/>
</Link>
</NavLink>
)
})}
</nav>
@@ -857,9 +857,9 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
: 'text-muted-foreground/40 cursor-not-allowed',
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
<NavLink key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
</NavLink>
) : (
<div
key={item.href}
@@ -943,9 +943,9 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
<NavLink key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
</NavLink>
) : (
<div key={item.href} className={baseClass} aria-disabled="true">
{content}
@@ -1043,14 +1043,14 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
: 'text-muted-foreground/40'
)
return enabled ? (
<Link
<NavLink
key={item.href}
href={item.href}
onClick={closeMobileMenu}
className={baseClass}
>
{content}
</Link>
</NavLink>
) : (
<div key={item.href} className={baseClass} aria-disabled="true">
{content}
@@ -1100,14 +1100,14 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
: 'text-muted-foreground/40'
)
return enabled ? (
<Link
<NavLink
key={item.href}
href={item.href}
onClick={closeMobileMenu}
className={baseClass}
>
{content}
</Link>
</NavLink>
) : (
<div key={item.href} className={baseClass} aria-disabled="true">
{content}
@@ -1150,14 +1150,14 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
: 'text-muted-foreground/40'
)
return enabled ? (
<Link
<NavLink
key={item.href}
href={item.href}
onClick={closeMobileMenu}
className={baseClass}
>
{content}
</Link>
</NavLink>
) : (
<div key={item.href} className={baseClass} aria-disabled="true">
{content}
@@ -1204,14 +1204,14 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
: 'text-muted-foreground/40'
)
return enabled ? (
<Link
<NavLink
key={item.href}
href={item.href}
onClick={closeMobileMenu}
className={baseClass}
>
{content}
</Link>
</NavLink>
) : (
<div key={item.href} className={baseClass} aria-disabled="true">
{content}
+57
View File
@@ -0,0 +1,57 @@
'use client'
import Link, { type LinkProps } from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useCallback, type ComponentProps } from 'react'
import { shouldWarmNavRoute } from './nav-prefetch'
type Props = Omit<ComponentProps<typeof Link>, 'prefetch'> & { href: LinkProps['href'] }
/**
* Sidebar / rail / mobile nav link with hover-intent prefetching.
*
* Why not the default viewport prefetch: the dashboard nav renders ~45
* links, every one of which is a dynamic route with a loading boundary, so
* Next prefetched all of them the moment the nav mounted. Each prefetch is
* a full request through the auth proxy (Supabase Auth round trip, the
* active-company RPC, the MFA check) whose only payload is the shared
* loading skeleton. Prod logs showed 1,000 to 1,300 such hits per nav route
* per day. Warming on hover/focus/touch keeps the perceived-instant click
* for the one or two links a user is about to use and drops the other ~43.
*
* `prefetch={false}` also disables Link's own hover prefetch (next/link only
* hover-prefetches when viewport prefetch is enabled), so the warm-up is an
* explicit router.prefetch. onFocus covers keyboard users; onTouchStart
* gives the mobile bottom nav a ~100 ms head start before the tap lands.
*/
export function NavLink({ href, onMouseEnter, onFocus, onTouchStart, children, ...rest }: Props) {
const router = useRouter()
const pathname = usePathname()
const hrefString = typeof href === 'string' ? href : (href.pathname ?? '')
const warm = useCallback(() => {
if (shouldWarmNavRoute(hrefString, pathname)) router.prefetch(hrefString)
}, [hrefString, pathname, router])
return (
<Link
href={href}
prefetch={false}
onMouseEnter={(e) => {
warm()
onMouseEnter?.(e)
}}
onFocus={(e) => {
warm()
onFocus?.(e)
}}
onTouchStart={(e) => {
warm()
onTouchStart?.(e)
}}
{...rest}
>
{children}
</Link>
)
}
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { shouldWarmNavRoute } from '../nav-prefetch'
describe('shouldWarmNavRoute', () => {
it('warms other internal routes, ignoring query and hash', () => {
expect(shouldWarmNavRoute('/invoices', '/transactions')).toBe(true)
expect(shouldWarmNavRoute('/invoices?status=draft', '/transactions')).toBe(true)
expect(shouldWarmNavRoute('/reports#top', '/transactions')).toBe(true)
})
it('does not warm the current route or non-routes', () => {
expect(shouldWarmNavRoute('/invoices', '/invoices')).toBe(false)
expect(shouldWarmNavRoute('/invoices?x=1', '/invoices')).toBe(false)
expect(shouldWarmNavRoute('https://example.com', '/')).toBe(false)
expect(shouldWarmNavRoute('//evil.example', '/')).toBe(false)
expect(shouldWarmNavRoute('#section', '/')).toBe(false)
})
it('warms when the pathname is unknown (first render)', () => {
expect(shouldWarmNavRoute('/invoices', null)).toBe(true)
})
})
describe('DashboardNav uses NavLink for every link', () => {
it('has no bare next/link usage left, so no nav link prefetches on viewport', () => {
const source = fs.readFileSync(path.resolve(__dirname, '..', 'DashboardNav.tsx'), 'utf8')
expect(source).not.toMatch(/from 'next\/link'/)
expect(source).not.toMatch(/<Link[\s>]/)
expect(source).toMatch(/<NavLink[\s>]/)
})
})
+12
View File
@@ -0,0 +1,12 @@
/**
* Whether hovering/focusing a nav link should warm its route.
*
* The link to the page you are already on has nothing to warm, and a
* hash-only or external href is not a route. Pure so it can be unit tested.
*/
export function shouldWarmNavRoute(href: string, currentPathname: string | null): boolean {
if (!href.startsWith('/')) return false
if (href.startsWith('//')) return false
const path = href.split(/[?#]/)[0]
return path !== currentPathname
}
+8
View File
@@ -94,6 +94,14 @@ const nextConfig: NextConfig = {
skipTrailingSlashRedirect: true,
experimental: {
optimizePackageImports: ['recharts', 'date-fns', 'framer-motion'],
// Client router cache for dynamic routes: a page visited in the last
// 30 s (back/forward, re-clicking a nav item) re-renders from the cached
// RSC payload instead of a new server request through the auth proxy.
// Mutation flows already call router.refresh() where a stale server
// render would mislead (16 sites); the client-side reference-data cache
// (lib/reference-data) is independent of this and refreshes on its own.
// Default was 0 (always refetch). Static routes keep the 5 min default.
staleTimes: { dynamic: 30, static: 300 },
},
// PostHog reverse proxy. Keeping analytics same-origin buys three things:
// the strict CSP below needs NO posthog hosts (`connect-src 'self'` already