From 3f859ce376702df3e2dd999a14995247e9f1b641 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Thu, 19 Mar 2026 21:56:38 +0100 Subject: [PATCH] add RSC prefetch on Link hover Link now prefetches the RSC payload on hover (80ms debounce), focus, and touch start. The navigation handler in entry.client.tsx consumes cached responses when available, falling back to a fresh fetch on cache miss. New prefetch cache module (prefetch.ts) stores Promise keyed by pathname+search with a 5 second TTL. Cache entries are consumed (deleted) on navigation so they are never reused twice. New Link prop: prefetch (default true, set false to disable). New export: prefetchRoute(href) from spiceflow/react for programmatic use. --- .changeset/link-prefetch-on-hover.md | 5 +++ spiceflow/src/react/components.tsx | 35 ++++++++++++++++++-- spiceflow/src/react/entry.client.tsx | 4 ++- spiceflow/src/react/index.ts | 3 +- spiceflow/src/react/prefetch.ts | 48 ++++++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 .changeset/link-prefetch-on-hover.md create mode 100644 spiceflow/src/react/prefetch.ts diff --git a/.changeset/link-prefetch-on-hover.md b/.changeset/link-prefetch-on-hover.md new file mode 100644 index 00000000..a5f15434 --- /dev/null +++ b/.changeset/link-prefetch-on-hover.md @@ -0,0 +1,5 @@ +--- +'spiceflow': patch +--- + +Add RSC prefetching on Link hover. The `Link` component now fetches the RSC payload when the user hovers (with 80ms debounce), focuses, or touches a link. Cached responses are used on navigation, making client-side page transitions feel instant. Prefetch is enabled by default and can be disabled with ``. A `prefetchRoute(href)` function is also exported from `spiceflow/react` for programmatic use. diff --git a/spiceflow/src/react/components.tsx b/spiceflow/src/react/components.tsx index 47ee30f6..09ceffe6 100644 --- a/spiceflow/src/react/components.tsx +++ b/spiceflow/src/react/components.tsx @@ -7,6 +7,7 @@ import { ServerPayload } from '../spiceflow.js' import { isRedirectError, isNotFoundError, getErrorContext, contextHeaders } from './errors.js' import { useFlightData } from './context.js' import { ProgressBar } from './progress.js' +import { prefetchRoute } from './prefetch.js' export function LayoutContent(props: { id?: string }) { const data = useFlightData() @@ -170,10 +171,41 @@ export function DefaultGlobalErrorPage(props: ErrorPageProps) { ) } -export function Link(props: React.ComponentPropsWithRef<'a'>) { +export function Link({ + prefetch = true, + ...props +}: React.ComponentPropsWithRef<'a'> & { prefetch?: boolean }) { + const hoverTimer = React.useRef | null>(null) + return ( { + props.onMouseEnter?.(e) + if (!prefetch) return + const href = e.currentTarget.href + if (!href) return + hoverTimer.current = setTimeout(() => prefetchRoute(href), 80) + }} + onMouseLeave={(e) => { + props.onMouseLeave?.(e) + if (hoverTimer.current) { + clearTimeout(hoverTimer.current) + hoverTimer.current = null + } + }} + onFocus={(e) => { + props.onFocus?.(e) + if (!prefetch) return + const href = e.currentTarget.href + if (href) prefetchRoute(href) + }} + onTouchStart={(e) => { + props.onTouchStart?.(e) + if (!prefetch) return + const href = e.currentTarget.href + if (href) prefetchRoute(href) + }} onClick={(e) => { if ( e.metaKey || @@ -186,7 +218,6 @@ export function Link(props: React.ComponentPropsWithRef<'a'>) { return } e.preventDefault() - props.onClick?.(e) router.push(e.currentTarget.href) }} diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index d77e5daa..7f4b35f0 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -25,6 +25,7 @@ import { isDeploymentMismatchResponse, } from './deployment.js' import { getErrorContext } from './errors.js' +import { consumePrefetch } from './prefetch.js' // Reads the RSC flight payload that the server injected as