rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
229 lines (228 loc) • 8.62 kB
JavaScript
import { onNavigationCommit, preloadFromLinkTags, } from "./navigationCache.js";
import { abortPendingNavigation, beginPendingNavigation, commitPendingNavigation, isPendingNavigationCommit, } from "./navigationState.js";
import { createScrollRestoration, } from "./scrollRestoration.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;
let scrollRestoration = null;
let currentPathKey = null;
function getLocationPathKey() {
return `${window.location.pathname ?? ""}${window.location.search ?? ""}`;
}
function getUrlPathKey(url) {
return `${url.pathname ?? ""}${url.search ?? ""}` || getLocationPathKey();
}
export async function navigate(href, options = { history: "push" }) {
if (!IS_CLIENT_NAVIGATION) {
window.location.href = href;
return;
}
const url = new URL(href, window.location.href);
const scrollToTop = options.info?.scrollToTop ?? true;
const scrollBehavior = (options.info?.scrollBehavior ??
"instant");
const nextScrollPosition = scrollToTop
? { x: 0, y: 0 }
: { x: window.scrollX, y: window.scrollY };
if (options.history === "push") {
scrollRestoration?.pushEntry(href, url, nextScrollPosition);
}
else {
scrollRestoration?.replaceEntry(href, url, nextScrollPosition);
}
currentPathKey = getUrlPathKey(url);
if (scrollToTop) {
scrollRestoration?.setPendingScroll({
...nextScrollPosition,
behavior: scrollBehavior,
});
}
else {
scrollRestoration?.recordCurrentPosition(window.scrollX, window.scrollY);
}
const pendingNavigation = beginPendingNavigation(url);
try {
await options.onNavigate?.();
await globalThis.__rsc_callServer(null, null, "navigation");
}
catch (error) {
abortPendingNavigation(pendingNavigation.id);
throw error;
}
}
/**
* Initializes client-side navigation for Single Page App (SPA) behavior.
*
* Intercepts clicks on internal links and fetches page content without full-page reloads.
* Returns handleResponse and onHydrated functions 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, onHydrated } = initClientNavigation();
* initClient({ handleResponse, onHydrated });
*
* @example
* // With custom scroll behavior
* const { handleResponse, onHydrated } = initClientNavigation({
* scrollBehavior: "smooth",
* scrollToTop: true,
* });
* initClient({ handleResponse, onHydrated });
*
* @example
* // Preserve scroll position (e.g., for infinite scroll)
* const { handleResponse, onHydrated } = initClientNavigation({
* scrollToTop: false,
* });
* initClient({ handleResponse, onHydrated });
*
* @example
* // With navigation callback
* const { handleResponse, onHydrated } = initClientNavigation({
* onNavigate: () => {
* console.log("Navigating to:", window.location.href);
* },
* });
* initClient({ handleResponse, onHydrated });
*/
export function initClientNavigation(opts = {}) {
IS_CLIENT_NAVIGATION = true;
scrollRestoration = createScrollRestoration();
scrollRestoration.initialize();
currentPathKey = getLocationPathKey();
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, { history: "push", onNavigate: opts.onNavigate });
}, true);
window.addEventListener("popstate", async function handlePopState() {
const nextPathKey = getLocationPathKey();
const isHashOnlyChange = nextPathKey === currentPathKey;
currentPathKey = nextPathKey;
if (isHashOnlyChange) {
return;
}
scrollRestoration?.restorePopStateScroll();
const pendingNavigation = beginPendingNavigation(window.location.href);
try {
await opts.onNavigate?.();
await globalThis.__rsc_callServer(null, null, "navigation");
}
catch (error) {
abortPendingNavigation(pendingNavigation.id);
throw error;
}
});
// Track the user's scroll position in memory so back/forward navigation can
// restore it after the RSC payload commits. Avoid writing on every scroll via
// history.replaceState because browsers throttle frequent history updates.
window.addEventListener("scroll", () => {
scrollRestoration?.recordCurrentPosition(window.scrollX, window.scrollY);
}, { passive: true });
let didFlushForHiddenPage = false;
function flushForPageLifecycle() {
if (didFlushForHiddenPage) {
return;
}
didFlushForHiddenPage = true;
scrollRestoration?.flushCurrentPositionToHistoryState();
}
window.addEventListener("pagehide", flushForPageLifecycle);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
flushForPageLifecycle();
}
else {
didFlushForHiddenPage = false;
}
});
function handleResponse(response) {
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("Location");
if (location) {
abortPendingNavigation();
window.location.href = location;
return false;
}
}
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.
abortPendingNavigation();
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 onHydrated(meta) {
// Apply any pending scroll intent once the relevant DOM has committed. For
// navigation payloads, ignore stale/superseded commits whose href no longer
// matches the pending navigation target.
const shouldCommitPendingNavigation = !meta || meta.source === "navigation";
const isMatchingNavigationCommit = shouldCommitPendingNavigation && isPendingNavigationCommit(meta?.href);
if (!meta || meta.source === "initial" || isMatchingNavigationCommit) {
scrollRestoration?.applyPendingScroll();
}
// Resolve NavigationPending boundaries only for navigation payloads. If a
// custom transport does not provide metadata, fall back to the historical
// onHydrated behavior and treat the commit as the pending navigation.
if (isMatchingNavigationCommit) {
commitPendingNavigation(meta?.href);
}
// After each RSC hydration/update, increment generation and evict old caches,
// then warm the navigation cache based on any <link rel="x-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,
onHydrated,
};
}