rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
135 lines (134 loc) • 4.83 kB
JavaScript
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;
}
export function initClientNavigation(opts = {}) {
// Merge user options with defaults
const options = {
onNavigate: async function onNavigate() {
// @ts-expect-error
await globalThis.__rsc_callServer();
},
scrollToTop: true,
scrollBehavior: "instant",
...opts,
};
// Prevent browser's automatic scroll restoration for popstate
if ("scrollRestoration" in history) {
history.scrollRestoration = "manual";
}
// Set up scroll behavior management
let popStateWasCalled = false;
let savedScrollPosition = null;
const observer = new MutationObserver(() => {
if (popStateWasCalled && savedScrollPosition) {
// Restore scroll position for popstate navigation (always instant)
window.scrollTo({
top: savedScrollPosition.y,
left: savedScrollPosition.x,
behavior: "instant",
});
savedScrollPosition = null;
}
else if (options.scrollToTop && !popStateWasCalled) {
// Scroll to top for anchor click navigation (configurable)
window.scrollTo({
top: 0,
left: 0,
behavior: options.scrollBehavior,
});
// Update the current history entry with the new scroll position (top)
// This ensures that if we navigate back and then forward again,
// we return to the top position, not some previous scroll position
window.history.replaceState({
...window.history.state,
scrollX: 0,
scrollY: 0,
}, "", window.location.href);
}
popStateWasCalled = false;
});
const handleScrollPopState = (event) => {
popStateWasCalled = true;
// Save the scroll position that the browser would have restored to
const state = event.state;
if (state &&
typeof state === "object" &&
"scrollX" in state &&
"scrollY" in state) {
savedScrollPosition = { x: state.scrollX, y: state.scrollY };
}
else {
// Fallback: try to get scroll position from browser's session history
// This is a best effort since we can't directly access the browser's stored position
savedScrollPosition = { x: window.scrollX, y: window.scrollY };
}
};
const main = document.querySelector("main") || document.body;
if (main) {
window.addEventListener("popstate", handleScrollPopState);
observer.observe(main, { childList: true, subtree: true });
}
// Intercept all anchor tag clicks
document.addEventListener("click", async function handleClickEvent(event) {
// Prevent default navigation
if (!validateClickEvent(event, event.target)) {
return;
}
event.preventDefault();
const el = event.target;
const a = el.closest("a");
const href = a?.getAttribute("href");
// Save current scroll position before navigating
window.history.replaceState({
path: window.location.pathname,
scrollX: window.scrollX,
scrollY: window.scrollY,
}, "", window.location.href);
window.history.pushState({ path: href }, "", window.location.origin + href);
await options.onNavigate();
}, true);
// Handle browser back/forward buttons
window.addEventListener("popstate", async function handlePopState() {
await options.onNavigate();
});
// Return a handleResponse function for use with initClient
return {
handleResponse: function handleResponse(response) {
if (!response.ok) {
// Redirect to the current page (window.location) to show the error
window.location.href = window.location.href;
return false;
}
return true;
},
};
}