"use client"; import React, { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef, use, type ReactNode, } from "react"; import { NavigationStoreContext, type NavigationStoreContextValue, } from "./context.js"; import type { NavigationStore, NavigationUpdate, NavigateOptions, NavigationBridge, } from "../types.js"; import type { EventController } from "../event-controller.js"; import { RootErrorBoundary } from "../../root-error-boundary.js"; import type { HandleData } from "../types.js"; import { ThemeProvider } from "../../theme/ThemeProvider.js"; import { NonceContext } from "./nonce-context.js"; import type { ResolvedThemeConfig, Theme } from "../../theme/types.js"; import { cancelAllPrefetches } from "../prefetch/queue.js"; import { handleNavigationEnd } from "../scroll-restoration.js"; import { createAppShellRef, type AppShellRef } from "../app-shell.js"; /** * Process handles from an async generator, updating the event controller * and cache as data streams in. * * This handles: * 1. Consuming the async generator and calling setHandleData on each yield * 2. Stopping early if user navigates away (historyKey changes) * 3. Cleaning up stale data when generator yields nothing * 4. Updating the cache after processing completes (if still on same page) */ async function processHandles( handlesGenerator: AsyncGenerator, opts: { eventController: EventController; store: NavigationStore; matched?: string[]; isPartial?: boolean; /** Server's `resolvedIds`: every segment re-resolved this request, * including null-component ones excluded from `diff`/`segments`. * Drives cleanup of stale handle buckets when a re-resolved segment * pushed nothing. */ resolvedIds?: string[]; historyKey: string; }, ): Promise { const { eventController, store, matched, isPartial, resolvedIds, historyKey, } = opts; let yieldCount = 0; for await (const handleData of handlesGenerator) { // Check if user navigated away before each update. // This prevents handle data from cancelled navigations polluting // the current route's breadcrumbs (e.g., quick popstate after clicking a link). if (historyKey !== store.getHistoryKey()) { console.log( "[NavigationProvider] Stopping handle processing - user navigated away", ); return; } yieldCount++; eventController.setHandleData(handleData, matched, isPartial, resolvedIds); } // Check again before final updates if (historyKey !== store.getHistoryKey()) { return; } // For partial updates where the generator yielded nothing (every // re-resolved handler pushed nothing), still call setHandleData so the // cleanup pass can clear out stale buckets for those segments. if (yieldCount === 0 && matched) { eventController.setHandleData({}, matched, true, resolvedIds); } // After handles processing completes, update the cache's handleData. // This fixes a race condition where commit() caches stale handleData before // the async handles processing completes. // Only update if we're still on the same page (historyKey matches). if (historyKey === store.getHistoryKey()) { const finalHandleData = eventController.getHandleState().data; store.updateCacheHandleData(historyKey, finalHandleData); } } /** * Props for NavigationProvider */ export interface NavigationProviderProps { /** * Navigation store instance (for cache/segment management) */ store: NavigationStore; /** * Event controller instance (for navigation/action state) */ eventController: EventController; /** * Initial rendered tree + metadata from server payload */ initialPayload: NavigationUpdate; /** * Navigation bridge for handling navigation */ bridge: NavigationBridge; /** * Theme configuration (null if theme not enabled) * When provided, wraps content in ThemeProvider */ themeConfig?: ResolvedThemeConfig | null; /** * Initial theme from server (from cookie) * Only used when themeConfig is provided */ initialTheme?: Theme; /** * Whether connection warmup is enabled. * When true, keeps TLS alive by sending HEAD requests after idle periods. */ warmupEnabled?: boolean; /** * App version from server payload. * Used only as a fallback when `appShellRef` is not supplied. */ version?: string; /** * URL prefix for all routes (from createRouter({ basename })). * Used only as a fallback when `appShellRef` is not supplied. */ basename?: string; /** * Live app-shell ref. When provided, the context's `basename` and `version` * properties become live getters that track app-switch updates without * invalidating the memoized context value. */ appShellRef?: AppShellRef; } /** * Navigation provider component * * Provides navigation context to the component tree and handles: * - Providing stable store and event controller references (never re-renders consumers) * - Subscribing to UI updates to re-render the tree * - Providing navigate/refresh methods (delegated to bridge) * * State subscriptions happen via useNavigation hook (via event controller), not via context. * This means context consumers don't re-render on state changes. * * @example * ```tsx * * ``` */ export function NavigationProvider({ store, eventController, initialPayload, bridge, themeConfig, initialTheme, warmupEnabled, version, basename, appShellRef, }: NavigationProviderProps): ReactNode { // Track current payload for rendering (this triggers re-renders) const [payload, setPayload] = useState(initialPayload); /** * Navigate to a URL (delegates to bridge) */ const navigate = useCallback( async (url: string, options?: NavigateOptions): Promise => { await bridge.navigate(url, options); }, [], ); /** * Refresh current route (delegates to bridge) */ const refresh = useCallback(async (): Promise => { await bridge.refresh(); }, []); // basename/version are always read through a shell ref so the context value // has a single shape: a supplied appShellRef stays live (app-switch updates // it), the standalone fallback is a frozen ref over the mount-time props. const fallbackShellRef = useRef(null); if (!fallbackShellRef.current) { fallbackShellRef.current = createAppShellRef({ basename, version }); } const shellRef = appShellRef ?? fallbackShellRef.current; const contextValue = useMemo(() => { const value = { store, eventController, navigate, refresh, } as NavigationStoreContextValue; Object.defineProperty(value, "basename", { configurable: true, enumerable: true, get: () => shellRef.get().basename, }); Object.defineProperty(value, "version", { configurable: true, enumerable: true, get: () => shellRef.get().version, }); return value; }, []); // Connection warmup: keep TLS alive after idle periods. // After 60s of no user interaction, marks connection as "cold". // On next interaction or visibility change, sends a HEAD request to warm TLS // before the user actually clicks a link. useEffect(() => { if (!warmupEnabled) return; const IDLE_TIMEOUT = 60_000; const DEBOUNCE_DELAY = 150; let idleTimer: ReturnType | undefined; let debounceTimer: ReturnType | undefined; let isCold = false; let warmupListenersAttached = false; function sendWarmup() { isCold = false; fetch("/?_rsc_warmup", { method: "HEAD" }).catch(() => {}); } function triggerWarmup() { if (!isCold) return; clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { sendWarmup(); detachWarmupListeners(); resetIdleTimer(); }, DEBOUNCE_DELAY); } function onVisibilityChange() { if (document.visibilityState === "visible" && isCold) { triggerWarmup(); } } function attachWarmupListeners() { if (warmupListenersAttached) return; warmupListenersAttached = true; document.addEventListener("visibilitychange", onVisibilityChange); document.addEventListener("mousemove", triggerWarmup, { once: true }); document.addEventListener("touchstart", triggerWarmup, { once: true }); } function detachWarmupListeners() { warmupListenersAttached = false; document.removeEventListener("visibilitychange", onVisibilityChange); document.removeEventListener("mousemove", triggerWarmup); document.removeEventListener("touchstart", triggerWarmup); } function markCold() { isCold = true; attachWarmupListeners(); } function resetIdleTimer() { clearTimeout(idleTimer); isCold = false; idleTimer = setTimeout(markCold, IDLE_TIMEOUT); } // Activity events that reset the idle timer const activityEvents = [ "mousemove", "keydown", "touchstart", "scroll", ] as const; const activityOptions: AddEventListenerOptions = { passive: true }; for (const event of activityEvents) { document.addEventListener(event, resetIdleTimer, activityOptions); } resetIdleTimer(); return () => { clearTimeout(idleTimer); clearTimeout(debounceTimer); detachWarmupListeners(); for (const event of activityEvents) { document.removeEventListener(event, resetIdleTimer); } }; }, [warmupEnabled]); // Cancel non-matching prefetches when navigation starts. // Frees connections so the navigation fetch isn't competing with // speculative prefetches. The prefetch matching the navigation target // is kept alive so it can be reused via consumeInflightPrefetch. useEffect(() => { let wasIdle = true; const unsub = eventController.subscribe(() => { const state = eventController.getState(); const isIdle = state.state === "idle" && !state.isStreaming; if (wasIdle && !isIdle) { cancelAllPrefetches(state.pendingUrl); } wasIdle = isIdle; }); return unsub; }, [eventController]); // Pending scroll action to apply after React commits const pendingScrollRef = useRef(undefined); // Apply scroll after React commits the new content to the DOM useLayoutEffect(() => { const scrollAction = pendingScrollRef.current; if (!scrollAction) return; pendingScrollRef.current = undefined; if (scrollAction.enabled === false) return; handleNavigationEnd({ restore: scrollAction.restore, scroll: scrollAction.enabled, isStreaming: scrollAction.isStreaming, }); }); // Subscribe to UI updates (for re-rendering the tree) useEffect(() => { const unsubscribe = store.onUpdate((update) => { // Capture scroll intent — it will be applied in useLayoutEffect // after React commits this state update to the DOM. // Always assign (even undefined) to clear stale scroll from prior navigations, // so server actions or error updates don't accidentally replay old scroll. pendingScrollRef.current = update.scroll; setPayload({ root: update.root, metadata: update.metadata, }); // Update route params. Only reset when the server actually sends a params // map — an absent `params` field means "no change" (e.g., legacy action // responses that omitted params). Explicit `{}` still clears correctly. if (update.metadata.params !== undefined) { eventController.setParams(update.metadata.params); } // Update handle data progressively as it streams in if (update.metadata.handles) { // Capture historyKey now - by the time async processing completes, // the user might have navigated elsewhere const historyKey = store.getHistoryKey(); processHandles(update.metadata.handles, { eventController, store, matched: update.metadata.matched, isPartial: update.metadata.isPartial, resolvedIds: update.metadata.resolvedIds, historyKey, }).catch((err) => console.error("[NavigationProvider] Error consuming handles:", err), ); } else if (update.metadata.matched) { // cachedHandleData present -> full restore (back/forward); absent -> // partial cleanup of segments no longer matched. const cached = update.metadata.cachedHandleData; eventController.setHandleData( cached ?? {}, update.metadata.matched, cached === undefined, cached === undefined ? update.metadata.resolvedIds : undefined, ); } }); return unsubscribe; }, []); // Handle promise case - use() will suspend until resolved const root = payload.root instanceof Promise ? use(payload.root) : payload.root; // Wrap content in RootErrorBoundary to catch: // 1. Errors from NetworkErrorThrower (rendered during network failures) // 2. Client component errors that occur before/outside the segment tree's error boundary // 3. Errors during promise resolution or navigation state updates // This acts as a safety net - the segment tree has its own RootErrorBoundary that // catches most errors, but this outer boundary catches anything that slips through. // Build the content tree let content = {root}; // Wrap with ThemeProvider when theme is enabled. The ThemeProvider is // document-lifetime: its config comes from the initial load and does NOT // swap on cross-app transitions, because the ThemeProvider sits above the // segment tree and a smooth (no-reload) app switch cannot safely remount // it. A new theme config only takes effect on a full document load. if (themeConfig) { content = ( {content} ); } // Match SSR tree shape: NonceContext.Provider is always present so // hydration sees the same component tree. Value is undefined on the // client — CSP nonces are a server-side HTML concern. content = ( {content} ); return ( {content} ); }