UNPKG

rwsdk

Version:

Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime

150 lines (149 loc) 4.98 kB
import { onNavigationCommit, preloadFromLinkTags, } from "./navigationCache.js"; export function validateClickEvent(event, target) { // should this only work for left click? if (event.button !== 0) { return false; } if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { return false; } const link = target.closest("a"); if (!link) { return false; } const href = link.getAttribute("href"); if (!href) { return false; } if (href.includes("#")) { return false; } // Skip if target="_blank" or similar if (link.target && link.target !== "_self") { return false; } if (href.startsWith("http")) { return false; } // Skip if download attribute if (link.hasAttribute("download")) { return false; } return true; } let IS_CLIENT_NAVIGATION = false; export async function navigate(href, options = { history: "push" }) { if (!IS_CLIENT_NAVIGATION) { window.location.href = href; return; } saveScrollPosition(window.scrollX, window.scrollY); const url = window.location.origin + href; if (options.history === "push") { window.history.pushState({ path: href }, "", url); } else { window.history.replaceState({ path: href }, "", url); } await globalThis.__rsc_callServer(null, null, "navigation"); const scrollToTop = options.info?.scrollToTop ?? true; const scrollBehavior = options.info?.scrollBehavior ?? "instant"; if (scrollToTop && history.scrollRestoration === "auto") { window.scrollTo({ top: 0, left: 0, behavior: scrollBehavior, }); saveScrollPosition(0, 0); } } function saveScrollPosition(x, y) { window.history.replaceState({ ...window.history.state, scrollX: x, scrollY: y, }, "", window.location.href); } /** * Initializes client-side navigation for Single Page App (SPA) behavior. * * Intercepts clicks on internal links and fetches page content without full-page reloads. * Returns a handleResponse function to pass to initClient. * * @param opts.scrollToTop - Scroll to top after navigation (default: true) * @param opts.scrollBehavior - How to scroll: 'instant', 'smooth', or 'auto' (default: 'instant') * @param opts.onNavigate - Callback executed after history push but before RSC fetch * * @example * // Basic usage * import { initClient, initClientNavigation } from "rwsdk/client"; * * const { handleResponse, onHydrationUpdate } = initClientNavigation(); * initClient({ handleResponse, onHydrationUpdate }); * * @example * // With custom scroll behavior * const { handleResponse } = initClientNavigation({ * scrollBehavior: "smooth", * scrollToTop: true, * }); * initClient({ handleResponse }); * * @example * // Preserve scroll position (e.g., for infinite scroll) * const { handleResponse } = initClientNavigation({ * scrollToTop: false, * }); * initClient({ handleResponse }); * * @example * // With navigation callback * const { handleResponse } = initClientNavigation({ * onNavigate: () => { * console.log("Navigating to:", window.location.href); * }, * }); * initClient({ handleResponse }); */ export function initClientNavigation(opts = {}) { IS_CLIENT_NAVIGATION = true; history.scrollRestoration = "auto"; document.addEventListener("click", async function handleClickEvent(event) { if (!validateClickEvent(event, event.target)) { return; } event.preventDefault(); const el = event.target; const a = el.closest("a"); const href = a?.getAttribute("href"); await navigate(href); }, true); window.addEventListener("popstate", async function handlePopState() { await globalThis.__rsc_callServer(null, null, "navigation"); }); function handleResponse(response) { if (!response.ok) { // Redirect to the current page (window.location) to show the error // This means the page that produced the error is called twice. window.location.href = window.location.href; return false; } return true; } // Store cacheStorage globally for use in client.tsx if (opts.cacheStorage && typeof globalThis !== "undefined") { globalThis.__rsc_cacheStorage = opts.cacheStorage; } function onHydrationUpdate() { // After each RSC hydration/update, increment generation and evict old caches, // then warm the navigation cache based on any <link rel="prefetch"> tags // rendered for the current location. onNavigationCommit(undefined, opts.cacheStorage); void preloadFromLinkTags(undefined, undefined, opts.cacheStorage); } // Return callbacks for use with initClient return { handleResponse, onHydrationUpdate, }; }