import * as React from "react"; const NAVIGATION_EVENT = "mechanica:navigation"; export interface BrowserLocationSnapshot { pathname: string; search: string; hash: string; href: string; } export interface NavigateOptions { replace?: boolean; preserveScroll?: boolean; } // Stable reference returned during SSR (window === undefined) so the server // snapshot never changes identity across calls. const serverSnapshot: BrowserLocationSnapshot = { pathname: "/", search: "", hash: "", href: "/", }; // Last client snapshot. useSyncExternalStore compares snapshots by reference // (Object.is), so getSnapshot must return the SAME object whenever the URL is // unchanged — otherwise React sees a "new" store every render and loops forever // ("The result of getSnapshot should be cached..."), rendering a blank page. let cachedSnapshot: BrowserLocationSnapshot = serverSnapshot; function getSnapshot(): BrowserLocationSnapshot { if (typeof window === "undefined") { return serverSnapshot; } const { pathname, search, hash, href } = window.location; if ( cachedSnapshot.pathname === pathname && cachedSnapshot.search === search && cachedSnapshot.hash === hash && cachedSnapshot.href === href ) { return cachedSnapshot; } cachedSnapshot = { pathname, search, hash, href }; return cachedSnapshot; } function emitNavigationEvent(): void { if (typeof window === "undefined") { return; } window.dispatchEvent(new Event(NAVIGATION_EVENT)); } export function navigateTo(to: string, options: NavigateOptions = {}): void { if (typeof window === "undefined") { return; } const nextUrl = new URL(to, window.location.href); if (nextUrl.origin !== window.location.origin) { window.location.assign(nextUrl.toString()); return; } const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`; const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`; if (nextPath === currentPath) { return; } const historyState = { mechanica: true, path: nextPath, }; if (options.replace) { window.history.replaceState(historyState, "", nextPath); } else { window.history.pushState(historyState, "", nextPath); } if (!options.preserveScroll) { window.scrollTo({ top: 0, left: 0, behavior: "auto" }); } emitNavigationEvent(); } export function replaceUrl(to: string, options: Omit = {}): void { navigateTo(to, { ...options, replace: true }); } export function useBrowserLocation(): BrowserLocationSnapshot { return React.useSyncExternalStore( (onStoreChange) => { if (typeof window === "undefined") { return () => undefined; } window.addEventListener("popstate", onStoreChange); window.addEventListener(NAVIGATION_EVENT, onStoreChange); return () => { window.removeEventListener("popstate", onStoreChange); window.removeEventListener(NAVIGATION_EVENT, onStoreChange); }; }, getSnapshot, getSnapshot, ); } export function useSpaLinkInterceptor(): void { React.useEffect(() => { if (typeof window === "undefined" || typeof document === "undefined") { return undefined; } const handleClick = (event: MouseEvent) => { if ( event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey ) { return; } const target = event.target; if (!(target instanceof Element)) { return; } const anchor = target.closest("a[href]"); if (!anchor) { return; } if ( anchor.target && anchor.target.toLowerCase() !== "_self" || anchor.hasAttribute("download") ) { return; } const href = anchor.getAttribute("href"); if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) { return; } const url = new URL(href, window.location.href); if (url.origin !== window.location.origin) { return; } event.preventDefault(); navigateTo(`${url.pathname}${url.search}${url.hash}`); }; document.addEventListener("click", handleClick); return () => document.removeEventListener("click", handleClick); }, []); }