"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; /** * Guards a form against accidental loss of in-progress data. * * While `isDirty` is true it: * - shows the browser's native warning on hard unloads (reload / tab close / * external navigation), and * - intercepts in-app link clicks so the caller can show a custom prompt * offering to save the work as a draft before leaving. * * Programmatic navigations (e.g. a "Cancel" button calling `router.back()`) * should be wrapped with the returned `guard()` so they are intercepted too. */ export function useUnsavedChanges(isDirty: boolean) { const router = useRouter(); const dirtyRef = useRef(isDirty); dirtyRef.current = isDirty; // Set once the user has chosen to leave, so the pending navigation isn't // re-intercepted on the way out. const bypassRef = useRef(false); const [pendingNav, setPendingNav] = useState void)>(null); const pendingRef = useRef void)>(null); pendingRef.current = pendingNav; // Native warning for reload / tab-close / external links. useEffect(() => { if (!isDirty) return; function onBeforeUnload(e: BeforeUnloadEvent) { e.preventDefault(); e.returnValue = ""; } window.addEventListener("beforeunload", onBeforeUnload); return () => window.removeEventListener("beforeunload", onBeforeUnload); }, [isDirty]); // Intercept clicks on in-app links so we can prompt before leaving. useEffect(() => { function onClick(e: MouseEvent) { if (!dirtyRef.current || bypassRef.current) return; if (e.defaultPrevented || e.button !== 0) return; if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; const anchor = (e.target as HTMLElement | null)?.closest?.("a"); if (!anchor) return; if (anchor.hasAttribute("download")) return; const target = anchor.getAttribute("target"); if (target && target !== "_self") return; const href = anchor.getAttribute("href"); if (!href) return; let url: URL; try { url = new URL(anchor.href, window.location.href); } catch { return; } if (url.origin !== window.location.origin) return; const dest = url.pathname + url.search + url.hash; const here = window.location.pathname + window.location.search; // Same page (e.g. an in-page #anchor) — let it through. if (url.pathname + url.search === here) return; e.preventDefault(); setPendingNav(() => () => router.push(dest)); } document.addEventListener("click", onClick, true); return () => document.removeEventListener("click", onClick, true); }, [router]); /** Wrap a programmatic navigation so it is intercepted while dirty. */ const guard = useCallback((run: () => void) => { if (!dirtyRef.current || bypassRef.current) { run(); return; } setPendingNav(() => run); }, []); /** Proceed with the pending navigation, discarding changes. */ const leave = useCallback(() => { bypassRef.current = true; const run = pendingRef.current; setPendingNav(null); run?.(); }, []); /** Cancel the prompt and stay on the page. */ const stay = useCallback(() => setPendingNav(null), []); return { promptOpen: pendingNav !== null, guard, leave, stay }; }