import React from "react"; import { renderSegments as baseRenderSegments, type RenderSegmentsOptions, } from "../segment-system.js"; import { createNavigationStore, generateHistoryKey, } from "./navigation-store.js"; import { createEventController } from "./event-controller.js"; import { createNavigationClient } from "./navigation-client.js"; import { createServerActionBridge } from "./server-action-bridge.js"; import { createNavigationBridge } from "./navigation-bridge.js"; import { NavigationProvider } from "./react/index.js"; import type { RscPayload, RscBrowserDependencies, ResolvedSegment, NavigationStore, NavigationBridge, } from "./types.js"; import type { EventController } from "./event-controller.js"; import type { ResolvedThemeConfig, Theme } from "../theme/types.js"; import { initRangoState } from "./rango-state.js"; import { initPrefetchCache } from "./prefetch/cache.js"; import { setAppVersion } from "./app-version.js"; import { isInterceptSegment, splitInterceptSegments, } from "./intercept-utils.js"; import { createAppShellRef } from "./app-shell.js"; // Vite HMR types are provided by vite/client /** * Options for initializing the browser app */ export interface InitBrowserAppOptions { /** * RSC stream containing the initial payload (from rsc-html-stream/client) */ rscStream: ReadableStream; /** * RSC browser dependencies from @vitejs/plugin-rsc/browser */ deps: RscBrowserDependencies; /** * Optional store configuration */ storeOptions?: { /** * Maximum number of history entries to cache * @default 10 */ cacheSize?: number; }; /** * Enable global link interception for SPA navigation. * When enabled, clicks on same-origin anchor elements are intercepted * and handled via client-side navigation instead of full page loads. * * Links rendered with the Link component handle their own navigation * regardless of this setting. * * Set to false to disable global interception and rely solely on * Link components for SPA navigation. * * @default true */ linkInterception?: boolean; /** * Theme configuration from router. * When provided, enables theme support via useTheme hook. * Pass router.themeConfig here to enable theme features. * * @example * ```tsx * import { router } from "./router.js"; * * await initBrowserApp({ * rscStream, * deps: rscBrowser, * themeConfig: router.themeConfig, * initialTheme: document.documentElement.className.includes("dark") ? "dark" : "light", * }); * ``` */ themeConfig?: ResolvedThemeConfig | null; /** * Initial theme from server (typically read from cookie). * Only used when themeConfig is provided. */ initialTheme?: Theme; } /** * Result from initializing the browser app */ export interface BrowserAppContext { store: NavigationStore; eventController: EventController; bridge: NavigationBridge; initialPayload: RscPayload; initialTree: React.ReactNode | Promise; /** Theme configuration (null if theme not enabled) */ themeConfig?: ResolvedThemeConfig | null; /** Initial theme from server */ initialTheme?: Theme; /** Whether connection warmup is enabled */ warmupEnabled?: boolean; /** App version for prefetch version mismatch detection */ version?: string; /** * Live app-shell ref. Cross-app navigations replace its contents so the * NavigationProvider and renderSegments pick up the target app's * rootLayout, basename, and version without consumer rerenders. Theme, * warmup, and prefetch TTL are document-lifetime (see AppShell). */ appShellRef?: import("./app-shell.js").AppShellRef; } // Module-level state for the initialized app let browserAppContext: BrowserAppContext | null = null; /** * Initialize the browser app. Must be called before rendering Rango. * * This function: * - Loads the initial RSC payload from the stream * - Creates the navigation store and event controller * - Sets up action and navigation bridges * - Configures HMR support */ export async function initBrowserApp( options: InitBrowserAppOptions, ): Promise { const { rscStream, deps, storeOptions, linkInterception = true, themeConfig, initialTheme, } = options; const initialPayload = await deps.createFromReadableStream(rscStream); // Extract themeConfig and initialTheme from payload if not explicitly provided // This allows virtual entries to work without importing the router const effectiveThemeConfig = themeConfig ?? initialPayload.metadata?.themeConfig ?? null; const effectiveInitialTheme = initialTheme ?? initialPayload.metadata?.initialTheme; // Get initial segments and compute history key from current URL const initialSegments = (initialPayload.metadata?.segments ?? []) as ResolvedSegment[]; const initialHistoryKey = generateHistoryKey(window.location.href); // Create navigation store with history-based caching const store = createNavigationStore({ initialLocation: window.location, initialSegmentIds: initialSegments.map((s) => s.id), initialHistoryKey, initialSegments, ...(storeOptions?.cacheSize && { cacheSize: storeOptions.cacheSize }), }); // Seed router identity from the initial SSR payload so the first // cross-app SPA navigation can detect the app switch. if (initialPayload.metadata?.routerId) { store.setRouterId?.(initialPayload.metadata.routerId); } // Create event controller for reactive state management const eventController = createEventController({ initialLocation: new URL(window.location.href), }); // Initialize event controller with segment order (even without handles) eventController.setHandleData({}, initialPayload.metadata?.matched); // Initialize route params eventController.setParams(initialPayload.metadata?.params ?? {}); // Initialize handle data from initial payload BEFORE hydration // This ensures useHandle returns correct data during hydration to avoid mismatch // The handles property is an async generator that yields on each push if (initialPayload.metadata?.handles) { const handlesGenerator = initialPayload.metadata.handles; let lastHandleData: Record> = {}; for await (const handleData of handlesGenerator) { lastHandleData = handleData; } // Initialize event controller with initial handle state before hydration. eventController.setHandleData( lastHandleData, initialPayload.metadata?.matched, ); // Update the initial cache entry with the processed handleData // The cache entry was created by createNavigationStore but without handleData store.updateCacheHandleData(initialHistoryKey, lastHandleData); } // Create composable utilities const client = createNavigationClient(deps); // Capture the per-router app-shell so cross-app navigations can replace // it atomically. rootLayout, basename, and version live here and are // read through the ref at call time rather than closed over. Theme, // warmup, and prefetch TTL are deliberately excluded — they are // document-lifetime and stay stable across smooth cross-app transitions. const version = initialPayload.metadata?.version; const appShellRef = createAppShellRef({ routerId: initialPayload.metadata?.routerId, rootLayout: initialPayload.metadata?.rootLayout, basename: initialPayload.metadata?.basename, version, }); // Initialize the localStorage state key for cache invalidation. // The build version busts cached prefetches on deploy; the routerId // namespaces the key so sibling apps on the same origin don't collide. initRangoState(version ?? "0", initialPayload.metadata?.routerId); setAppVersion(version); // Initialize the in-memory prefetch cache TTL from server config. // A value of 0 disables the cache; undefined falls back to the module default. const prefetchCacheTTL = initialPayload.metadata?.prefetchCacheTTL; if (prefetchCacheTTL !== undefined) { initPrefetchCache(prefetchCacheTTL); } // Create a bound renderSegments that reads rootLayout through the shell // ref. On app switch the ref is updated before the tree re-renders, so // the new app's Document (rootLayout) replaces the previous one. const renderSegments = ( segments: ResolvedSegment[], options?: RenderSegmentsOptions, ) => baseRenderSegments(segments, { ...options, rootLayout: appShellRef.get().rootLayout, }); // Lazy reference for navigation bridge — the action bridge is created first // but may need to trigger SPA navigation for action redirects. let navigateFn: ((url: string, options?: any) => Promise) | null = null; // Setup server action bridge const actionBridge = createServerActionBridge({ store, eventController, client, deps, onUpdate: (update) => store.emitUpdate(update), renderSegments, onNavigate: (url, options) => { if (!navigateFn) { window.location.href = url; return Promise.resolve(); } return navigateFn(url, options); }, }); actionBridge.register(); // Setup navigation bridge const navigationBridge = createNavigationBridge({ store, eventController, client, onUpdate: (update) => store.emitUpdate(update), renderSegments, version: version, appShellRef, }); // Connect action redirect → navigation bridge (now that both are initialized) navigateFn = (url, options) => navigationBridge.navigate(url, options); // Optionally enable global link interception if (linkInterception) { navigationBridge.registerLinkInterception(); } // Build initial tree with rootLayout const initialTree = renderSegments(initialPayload.metadata!.segments); // Setup HMR with debounce — burst saves (format-on-save, rapid edits) // fire many rsc:update events in quick succession. Without debouncing, // each event triggers a fetchPartial() which on slow routes can pile up // and overwhelm the worker (cross-request promise issues, 500s). if (import.meta.hot) { let hmrTimer: ReturnType | null = null; let hmrAbort: AbortController | null = null; import.meta.hot.on("rsc:update", () => { // Cancel any pending debounce timer if (hmrTimer !== null) { clearTimeout(hmrTimer); } // Abort any in-flight HMR fetch so it doesn't race with the next one if (hmrAbort) { hmrAbort.abort(); hmrAbort = null; } // Debounce: wait 200ms of quiet before fetching hmrTimer = setTimeout(async () => { hmrTimer = null; // Don't interrupt an active user navigation — startNavigation() // would abort it and refetch the old URL (window.location.href // hasn't updated yet). The user's navigation will pick up the // new server code when it completes. isNavigating covers the // full lifecycle (fetching + streaming, before commit) without // blocking on server actions. if (eventController.getState().isNavigating) { console.log("[Rango] HMR: Skipping — navigation in progress"); return; } console.log("[Rango] HMR: Server update, refetching RSC"); const abort = new AbortController(); hmrAbort = abort; const handle = eventController.startNavigation(window.location.href, { replace: true, }); const streamingToken = handle.startStreaming(); const interceptSourceUrl = store.getInterceptSourceUrl(); try { const { payload, streamComplete } = await client.fetchPartial({ targetUrl: window.location.href, segmentIds: [], previousUrl: store.getSegmentState().currentUrl, interceptSourceUrl: interceptSourceUrl || undefined, routerId: store.getRouterId?.(), hmr: true, signal: abort.signal, }); if (abort.signal.aborted) return; // If the server returned a non-RSC response (404, 500 without // error boundary), the payload won't have valid metadata. // Reload to recover rather than leaving the page stale. if (!payload.metadata) { throw new Error("HMR refetch returned invalid payload"); } // Update version BEFORE rebuilding state so that // clearHistoryCache() runs first, then the fresh segment // cache entry we create below survives. // // Compare against the bridge's live version, not the init-time // `version` const: after the first HMR bump the const is stale, so a // later update with an unchanged version would otherwise re-clear the // cache and re-broadcast across tabs/apps. The live read fires only // on a genuine version change. const newVersion = payload.metadata.version; const currentVersion = navigationBridge.getVersion(); if (newVersion && newVersion !== currentVersion) { console.log( "[Rango] HMR: version changed", currentVersion, "→", newVersion, "clearing caches", ); navigationBridge.updateVersion(newVersion); } // Apply only partial segment updates. A non-partial payload during // HMR is transient: the worker route table is still rebuilding after // the edit, so the URL momentarily resolves to not-found/catch-all. // Skip it -- the debounced follow-up refetch returns the settled // route's partial payload and renders it below. We never reload here: // a paramless document GET would run the SSR path and surface the // not-found page during that same transient. if (payload.metadata?.isPartial) { const segments = payload.metadata.segments || []; const matched = payload.metadata.matched || []; // Derive intercept state from the returned payload, not the // pre-fetch store snapshot. If the HMR edit removed intercept // behavior, the response won't contain intercept segments. const responseIsIntercept = segments.some(isInterceptSegment); // Sync store intercept state with what the server returned if (!responseIsIntercept && interceptSourceUrl) { store.setInterceptSourceUrl(null); } store.setSegmentIds(matched); store.setCurrentUrl(window.location.href); const historyKey = generateHistoryKey(window.location.href, { intercept: responseIsIntercept, }); store.setHistoryKey(historyKey); const currentHandleData = eventController.getHandleState().data; store.cacheSegmentsForHistory( historyKey, segments, currentHandleData, ); const { main, intercept } = splitInterceptSegments(segments); store.emitUpdate({ root: renderSegments(main, { interceptSegments: intercept.length > 0 ? intercept : undefined, }), metadata: payload.metadata, }); } await streamComplete; handle.complete(new URL(window.location.href)); console.log("[Rango] HMR: RSC stream complete"); } catch (err) { if (abort.signal.aborted) return; console.warn("[Rango] HMR: Refetch failed, reloading page", err); window.location.reload(); return; } finally { if (hmrAbort === abort) hmrAbort = null; streamingToken.end(); handle[Symbol.dispose](); } }, 200); }); } // Store context for Rango component const context: BrowserAppContext = { store, eventController, bridge: navigationBridge, initialPayload, initialTree, themeConfig: effectiveThemeConfig, initialTheme: effectiveInitialTheme, warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true, version, appShellRef, }; browserAppContext = context; return context; } /** * Get the browser app context. Throws if initBrowserApp hasn't been called. */ export function getBrowserAppContext(): BrowserAppContext { if (!browserAppContext) { throw new Error( "Rango: initBrowserApp() must be called before rendering Rango", ); } return browserAppContext; } /** * Reset the browser app context (for testing) */ export function resetBrowserAppContext(): void { browserAppContext = null; } /** * Props for the Rango component */ export interface RangoProps {} /** * Rango component - renders the RSC router with all internal wiring. * * Must be called after initBrowserApp() has completed. * * @example * ```tsx * import { initBrowserApp, Rango } from "rsc-router/browser"; * import { rscStream } from "rsc-html-stream/client"; * import * as rscBrowser from "@vitejs/plugin-rsc/browser"; * * async function main() { * await initBrowserApp({ rscStream, deps: rscBrowser }); * * hydrateRoot( * document, * * * * ); * } * main(); * ``` */ export function Rango(_props: RangoProps): React.ReactElement { const { store, eventController, bridge, initialPayload, initialTree, themeConfig, initialTheme, warmupEnabled, version, appShellRef, } = getBrowserAppContext(); // Signal that the React tree has hydrated. useEffect only fires after // hydration completes, so this attribute is a stable readiness marker // that does not depend on React internals like __reactFiber. React.useEffect(() => { document.documentElement.dataset.hydrated = ""; }, []); return ( ); }