"use client"; import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, type ForwardRefExoticComponent, type RefAttributes, } from "react"; import { NavigationStoreContext } from "./context.js"; import { LinkContext } from "./use-link-status.js"; import type { NavigateOptions } from "../types.js"; import { isHashOnlyNavigation } from "../link-interceptor.js"; import { isLocationStateEntry, type LocationStateEntry, resolveLocationStateEntries, } from "./location-state.js"; /** * State prop type for Link component. * - LocationStateEntry[]: Type-safe state entries via createLocationState() * - StateOrGetter: Plain state object or click-time getter function * - Record: Plain state object passed to history.pushState */ export type StateOrGetter = T | (() => T); export type LinkState = | LocationStateEntry[] | StateOrGetter>; import { prefetchDirect, prefetchQueued } from "../prefetch/fetch.js"; import { getAppVersion } from "../app-version.js"; import { observeForPrefetch, unobserveForPrefetch, } from "../prefetch/observer.js"; // Touch device detection for adaptive strategy. // Checked once at module load (Link.tsx is "use client", runs only in browser). const isTouchDevice = typeof window !== "undefined" && window.matchMedia("(hover: none)").matches; /** * Prefetch strategy for the Link component * - "hover": Prefetch on mouse enter (direct, no queue) * - "viewport": Prefetch when link enters viewport (queued, waits for idle) * - "render": Prefetch on component mount regardless of visibility (queued, waits for idle) * - "adaptive": Hover on pointer devices, viewport on touch devices * - "none": No prefetching (default) */ export type PrefetchStrategy = | "hover" | "viewport" | "render" | "adaptive" | "none"; /** * Link component props */ export interface LinkProps extends Omit< React.AnchorHTMLAttributes, "href" > { /** * The URL to navigate to (typically from router.reverse()) */ to: string; /** * Replace current history entry instead of pushing */ replace?: boolean; /** * Scroll to top after navigation (default: true) */ scroll?: boolean; /** * Force full document navigation instead of SPA */ reloadDocument?: boolean; /** * Whether to revalidate server data on navigation. * Set to `false` to skip the RSC server fetch and only update the URL. * * Only takes effect when the pathname stays the same (search param / hash changes). * If the pathname changes, this option is ignored and a full navigation occurs. * * @default true */ revalidate?: boolean; /** * Prefetch strategy for the link destination * @default "none" */ prefetch?: PrefetchStrategy; /** * Opt-in override for the prefetch cache scope. * * The default cache is source-agnostic: one shared entry per target, * keyed on Rango state + target URL. This is correct for routes whose * response shape doesn't depend on where the user navigates from. * * Set `":source"` when this Link's response would legitimately differ * based on the source page — typically when the target route (or one * of its layouts) uses a custom `revalidate()` handler that reads * `currentUrl` / `currentParams`, and the wildcard entry would * therefore serve the wrong diff to a navigation from a different * source. * * Intercept responses are auto-scoped to the source via a server-side * tag, so `":source"` is only needed for custom revalidation logic. * * @example * ```tsx * // Route uses a `revalidate()` that branches on currentUrl — opt in * // so prefetches don't bleed across source pages. * * ``` */ prefetchKey?: ":source"; /** * State to pass to history.pushState/replaceState. * Accessible via useLocationState() hook. * * @example * ```tsx * // Type-safe state with createLocationState (recommended) * const ProductState = createLocationState<{ name: string; price: number }>(); * * View * * * // Type-safe just-in-time state (getter called at click time, not render time). * // Must be in a client component -- getter can't cross the RSC boundary. * ({ name: product.name, price: product.price }))]} * > * View * * * // Multiple typed states * * Checkout * * * // Plain static state * View * * // Plain just-in-time state (called at click time, requires client component) * ({ scrollY: window.scrollY })}>View * ``` */ state?: LinkState; children: React.ReactNode; } /** * Check if URL is external (different origin) */ function isExternalUrl(href: string): boolean { // Protocol-relative URLs if (href.startsWith("//")) return true; // Absolute URLs if (href.startsWith("http://") || href.startsWith("https://")) { try { return new URL(href).origin !== window.location.origin; } catch { return false; } } // Special protocols (mailto, tel, etc.) if (/^[a-z][a-z0-9+.-]*:/i.test(href)) { return true; } return false; } /** * Type-safe Link component for SPA navigation * * Works with router.reverse() for type-safe URLs: * ```tsx * * View Product * * ``` * * Also supports regular URLs: * ```tsx * About * External * ``` */ export const Link: ForwardRefExoticComponent< LinkProps & RefAttributes > = forwardRef(function Link( { to, replace = false, scroll = true, reloadDocument = false, revalidate, prefetch = "none", prefetchKey, state, children, onClick, ...props }, ref, ) { const ctx = useContext(NavigationStoreContext); const isExternal = isExternalUrl(to); // Auto-prefix with basename for app-local paths. // Skip if external, already prefixed, or not a root-relative path. const resolvedTo = useMemo(() => { if (isExternal) return to; const bn = ctx?.basename; if (!bn || !to.startsWith("/") || to.startsWith(bn + "/") || to === bn) return to; return to === "/" ? bn : bn + to; }, [to, isExternal, ctx?.basename]); // Resolve adaptive: viewport on touch devices, hover on pointer devices const resolvedStrategy = prefetch === "adaptive" ? (isTouchDevice ? "viewport" : "hover") : prefetch; // Internal ref for viewport observation; merge with forwarded ref const internalRef = useRef(null); const setRef = useCallback( (node: HTMLAnchorElement | null) => { internalRef.current = node; if (typeof ref === "function") { ref(node); } else if (ref) { (ref as React.MutableRefObject).current = node; } }, [ref], ); // Use ref to always get the latest state/getter without adding to useCallback deps // This enables just-in-time state resolution without causing re-renders const stateRef = useRef(state); stateRef.current = state; const handleClick = useCallback( (e: React.MouseEvent) => { // Call user's onClick handler first onClick?.(e); // If user prevented default, respect that if (e.defaultPrevented) return; // External links - let browser handle normally if (isExternal) return; // Force document navigation if requested if (reloadDocument) return; // Allow modifier keys for opening in new tab/window if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; // Check for download attribute if ((e.currentTarget as HTMLAnchorElement).hasAttribute("download")) return; // Check for target attribute const target = (e.currentTarget as HTMLAnchorElement).target; if (target && target !== "_self") return; // Hash-only navigation: let the browser handle anchor scrolling natively. if (isHashOnlyNavigation(e.currentTarget as HTMLAnchorElement)) { return; } // No navigation context (outside provider): fall back to native navigation. if (!ctx?.navigate) { return; } // Prevent default and use SPA navigation e.preventDefault(); // Stop propagation to prevent link-interceptor from also handling this e.stopPropagation(); const currentState = stateRef.current; let resolvedState: unknown; if ( Array.isArray(currentState) && currentState.length > 0 && isLocationStateEntry(currentState[0]) ) { resolvedState = resolveLocationStateEntries( currentState as LocationStateEntry[], ); } else if (typeof currentState === "function") { resolvedState = currentState(); } else if (currentState != null) { resolvedState = currentState; } ctx.navigate(resolvedTo, { replace, scroll, state: resolvedState, revalidate, }); }, [ resolvedTo, isExternal, reloadDocument, replace, scroll, revalidate, ctx, onClick, ], ); const handleMouseEnter = useCallback(() => { if ( (resolvedStrategy === "hover" || resolvedStrategy === "viewport") && !isExternal && ctx?.store ) { // For "hover", this is the primary prefetch trigger. // For "viewport", this upgrades/prioritizes a potentially queued // prefetch — prefetchDirect bypasses the queue, and hasPrefetch // deduplicates if the viewport prefetch already completed. const segmentState = ctx.store.getSegmentState(); prefetchDirect( resolvedTo, segmentState.currentSegmentIds, getAppVersion(), ctx.store.getRouterId?.(), prefetchKey, ); } }, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]); // Viewport/render prefetch: waits for idle before starting, // uses concurrency-limited queue to avoid flooding. useEffect(() => { if (isExternal || !ctx?.store) return; const isViewport = resolvedStrategy === "viewport"; const isRender = resolvedStrategy === "render"; if (!isViewport && !isRender) return; let cancelled = false; let unsubIdle: (() => void) | undefined; let observedElement: Element | null = null; const triggerPrefetch = () => { if (cancelled) return; const segmentState = ctx.store.getSegmentState(); prefetchQueued( resolvedTo, segmentState.currentSegmentIds, getAppVersion(), ctx.store.getRouterId?.(), prefetchKey, ); }; // Schedule prefetch only when the app is idle (no navigation/streaming). // This avoids competing with hydration and active navigation fetches. const scheduleWhenIdle = (callback: () => void) => { const state = ctx.eventController.getState(); if (state.state === "idle" && !state.isStreaming) { callback(); return; } const unsub = ctx.eventController.subscribe(() => { const s = ctx.eventController.getState(); if (s.state === "idle" && !s.isStreaming) { unsub(); callback(); } }); unsubIdle = unsub; }; if (isRender) { scheduleWhenIdle(triggerPrefetch); } else if (isViewport) { const element = internalRef.current; if (!element) return; observedElement = element; observeForPrefetch(element, () => { scheduleWhenIdle(triggerPrefetch); }); } return () => { cancelled = true; unsubIdle?.(); if (isViewport && observedElement) { unobserveForPrefetch(observedElement); } }; }, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]); return ( {children} ); });