Files
accounted/components/dashboard/NavLink.tsx
T
Jakob Wennberg a3326a0296 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>
2026-08-26 14:01:37 +02:00

58 lines
2.0 KiB
TypeScript

'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>
)
}