"use client"; import { useEffect, useRef, useState, type MutableRefObject } from "react"; import Image from "next/image"; import { usePathname } from "next/navigation"; /** * LoadingScreen * --------------------------------------------------------------------------- * Route-transition loader only: a black full-screen overlay with a centered, * pulsing Doormile logo. It intentionally does not run on initial page render, * image loading, lazy component loading, API requests, or scroll. * * The App Router does not expose the old `next/router` routeChangeStart / * routeChangeComplete event API. This component provides the same behavior by * detecting internal route-link starts and completing when `usePathname()` * reports the committed route. */ type Phase = "hidden" | "visible" | "hiding"; const MIN_VISIBLE_MS = 420; const MAX_VISIBLE_MS = 800; function getRoutePath(url: URL) { return `${url.pathname}${url.search}`; } export default function LoadingScreen() { const pathname = usePathname(); const [phase, setPhase] = useState("hidden"); const phaseRef = useRef("hidden"); const visibleSince = useRef(0); const pendingPath = useRef(null); const currentRoutePath = useRef(null); const hideTimer = useRef | null>(null); const safetyTimer = useRef | null>(null); const setLoaderPhase = (nextPhase: Phase) => { phaseRef.current = nextPhase; setPhase(nextPhase); }; const clearTimer = (timer: MutableRefObject | null>) => { if (!timer.current) return; clearTimeout(timer.current); timer.current = null; }; const completeTransition = () => { pendingPath.current = null; clearTimer(safetyTimer); if (phaseRef.current === "hidden" || phaseRef.current === "hiding") return; const elapsed = performance.now() - visibleSince.current; const wait = Math.max(0, MIN_VISIBLE_MS - elapsed); clearTimer(hideTimer); hideTimer.current = setTimeout(() => { setLoaderPhase("hiding"); hideTimer.current = setTimeout(() => setLoaderPhase("hidden"), 360); }, wait); }; useEffect(() => { currentRoutePath.current = `${pathname}${window.location.search}`; completeTransition(); // `phase` intentionally stays out of this dependency list. The route commit // is the completion signal; phase changes should not repeatedly restart hide. // eslint-disable-next-line react-hooks/exhaustive-deps }, [pathname]); useEffect(() => { const startTransition = (targetPath: string, force = false) => { if (!force && targetPath === getRoutePath(new URL(window.location.href))) return; if (pendingPath.current === targetPath && phaseRef.current === "visible") return; pendingPath.current = targetPath; clearTimer(hideTimer); clearTimer(safetyTimer); visibleSince.current = performance.now(); setLoaderPhase("visible"); safetyTimer.current = setTimeout(() => { completeTransition(); }, MAX_VISIBLE_MS); }; const getInternalRouteTarget = (anchor: HTMLAnchorElement) => { const rawHref = anchor.getAttribute("href"); if (!rawHref || rawHref.startsWith("#")) return null; if (anchor.target && anchor.target !== "_self") return null; if (anchor.hasAttribute("download")) return null; if (/^(mailto:|tel:|sms:|javascript:)/i.test(rawHref)) return null; const url = new URL(rawHref, window.location.href); if (url.origin !== window.location.origin) return null; const current = new URL(window.location.href); const sameRoute = url.pathname === current.pathname && url.search === current.search; if (sameRoute) return null; return getRoutePath(url); }; const handleDocumentClick = (event: MouseEvent) => { if (event.defaultPrevented) return; if (event.button !== 0) return; if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; const anchor = (event.target as Element | null)?.closest("a[href]"); if (!anchor || !(anchor instanceof HTMLAnchorElement)) return; const targetPath = getInternalRouteTarget(anchor); if (targetPath) startTransition(targetPath); }; const originalPushState = window.history.pushState; const originalReplaceState = window.history.replaceState; window.history.pushState = function patchedPushState(...args) { const urlArg = args[2]; if (typeof urlArg === "string" || urlArg instanceof URL) { const url = new URL(urlArg, window.location.href); if (url.origin === window.location.origin) startTransition(getRoutePath(url)); } return originalPushState.apply(this, args); }; window.history.replaceState = function patchedReplaceState(...args) { const urlArg = args[2]; if (typeof urlArg === "string" || urlArg instanceof URL) { const url = new URL(urlArg, window.location.href); if (url.origin === window.location.origin) startTransition(getRoutePath(url)); } return originalReplaceState.apply(this, args); }; const handlePopState = () => { const targetPath = getRoutePath(new URL(window.location.href)); if (targetPath !== currentRoutePath.current) startTransition(targetPath, true); }; document.addEventListener("click", handleDocumentClick, true); window.addEventListener("popstate", handlePopState); return () => { document.removeEventListener("click", handleDocumentClick, true); window.removeEventListener("popstate", handlePopState); window.history.pushState = originalPushState; window.history.replaceState = originalReplaceState; clearTimer(hideTimer); clearTimer(safetyTimer); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); if (phase === "hidden") return null; return (
{ if (e.propertyName === "opacity" && phase === "hiding") { setPhase("hidden"); phaseRef.current = "hidden"; } }} >
Doormile
); }