Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/link-prefetch-on-hover.md
Original file line number Diff line number Diff line change
@@ -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 `<Link prefetch={false}>`. A `prefetchRoute(href)` function is also exported from `spiceflow/react` for programmatic use.
35 changes: 33 additions & 2 deletions spiceflow/src/react/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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<ReturnType<typeof setTimeout> | null>(null)

return (
<a
{...props}
onMouseEnter={(e) => {
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 ||
Expand All @@ -186,7 +218,6 @@ export function Link(props: React.ComponentPropsWithRef<'a'>) {
return
}
e.preventDefault()

props.onClick?.(e)
router.push(e.currentTarget.href)
}}
Expand Down
4 changes: 3 additions & 1 deletion spiceflow/src/react/entry.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <script> tags via
// transform.ts. Chunks already pushed before this module runs are drained,
Expand Down Expand Up @@ -159,8 +160,9 @@ async function main() {
navigationAbort = new AbortController()
const url = new URL(window.location.href)
url.searchParams.set('__rsc', '')
const cached = consumePrefetch(url.pathname, url.search)
const payload = createFromFetch<ServerPayload>(
fetchFlightResponse({ url, kind: 'navigation', init: { signal: navigationAbort.signal } }),
cached ?? fetchFlightResponse({ url, kind: 'navigation', init: { signal: navigationAbort.signal } }),
Comment on lines +163 to +165

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Normalize cached prefetch responses before parsing

When a hover/focus/touch prefetch is available, navigation skips fetchFlightResponse() and feeds the cached Response straight into createFromFetch(). That bypasses the logic in this file that hard-reloads on deployment-mismatch 409s, followed redirects, and other non-Flight responses, so a prefetched click during a rollout (or any prefetched navigation that resolves to a document response) can fail inside the RSC parser instead of recovering with a browser navigation.

Useful? React with 👍 / 👎.

)
if (navigationAbort.signal.aborted) return
setPayload(payload)
Expand Down
3 changes: 2 additions & 1 deletion spiceflow/src/react/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { Link, } from './components.tsx'
export { Link } from './components.tsx'
export { prefetchRoute } from './prefetch.tsx'
export { ProgressBar } from './progress.tsx'
export { ScrollRestoration } from './scroll-restoration.tsx'
export { router } from './router.tsx'
Expand Down
48 changes: 48 additions & 0 deletions spiceflow/src/react/prefetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Prefetch cache for RSC payloads. Link triggers prefetchRoute() on hover,
// the navigation handler in entry.client.tsx consumes cached responses via consumePrefetch().
'use client'

const PREFETCH_TTL = 5_000

type PrefetchEntry = {
promise: Promise<Response>
timestamp: number
}

const cache = new Map<string, PrefetchEntry>()

export function prefetchRoute(href: string) {
if (typeof window === 'undefined') return

let url: URL
try {
url = new URL(href, window.location.origin)
} catch {
return
}
if (url.origin !== window.location.origin) return

url.searchParams.set('__rsc', '')
const key = url.pathname + url.search

if (cache.has(key)) return

const promise = fetch(url.toString()).catch(() => {
cache.delete(key)
return new Response(null, { status: 0 })
Comment on lines +30 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid constructing an invalid fallback Response on prefetch errors

If the background prefetch fails (for example, the user is offline or the server restarts), this catch block throws a RangeError because new Response(..., { status: 0 }) is invalid in the Fetch API. That turns a harmless cache miss into an unhandled rejection, and a click that consumes this cached promise can fail instead of falling back to a fresh navigation request.

Useful? React with 👍 / 👎.

})

cache.set(key, { promise, timestamp: Date.now() })
}

export function consumePrefetch(
pathname: string,
search: string,
): Promise<Response> | null {
const key = pathname + search
const entry = cache.get(key)
if (!entry) return null
cache.delete(key)
if (Date.now() - entry.timestamp > PREFETCH_TTL) return null
return entry.promise
}
Loading