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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
c31933b15b
commit
a3326a0296
@@ -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}
|
||||
|
||||
@@ -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>]/)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user