import { useState, useEffect, useCallback, useRef } from 'react'; import { probeBackend, setBackendUrl as setServiceUrl } from '../services/backend-client'; import { DEFAULT_BACKEND_URL } from '../config/ui-constants'; // ── localStorage keys ──────────────────────────────────────────────────────── const LS_URL_KEY = 'gitnexus-backend-url'; // ── Public interface ───────────────────────────────────────────────────────── export interface UseBackendResult { /** Backend probe succeeded */ isConnected: boolean; /** Currently checking connection */ isProbing: boolean; /** Current backend URL */ backendUrl: string; /** Start polling for server availability (setTimeout chain, visibility-aware) */ startPolling: () => void; /** Stop polling */ stopPolling: () => void; /** Whether polling is active */ isPolling: boolean; } // ── Hook implementation ────────────────────────────────────────────────────── export function useBackend(): UseBackendResult { const [backendUrl] = useState(() => { try { return localStorage.getItem(LS_URL_KEY) ?? DEFAULT_BACKEND_URL; } catch { return DEFAULT_BACKEND_URL; } }); const [isConnected, setIsConnected] = useState(false); const [isProbing, setIsProbing] = useState(false); // Race-condition guard: monotonically increasing probe ID const probeIdRef = useRef(0); // ── Core probe logic ─────────────────────────────────────────────────────── const probe = useCallback(async (): Promise => { const id = ++probeIdRef.current; setIsProbing(true); try { const ok = await probeBackend(); if (id !== probeIdRef.current) return false; setIsConnected(ok); return ok; } catch { if (id === probeIdRef.current) { setIsConnected(false); } return false; } finally { if (id === probeIdRef.current) { setIsProbing(false); } } }, []); // ── Polling for server detection (setTimeout chain, no overlap) ────────── const pollingTimerRef = useRef | null>(null); const [isPolling, setIsPolling] = useState(false); const probeRef = useRef(probe); probeRef.current = probe; const stopPolling = useCallback(() => { if (pollingTimerRef.current !== null) { clearTimeout(pollingTimerRef.current); pollingTimerRef.current = null; } setIsPolling(false); }, []); const startPolling = useCallback(() => { stopPolling(); setIsPolling(true); const schedule = () => { pollingTimerRef.current = setTimeout(async () => { if (document.hidden) { // Don't reschedule — visibilitychange handler restarts the chain pollingTimerRef.current = null; return; } const ok = await probeRef.current(); if (ok) { setIsPolling(false); pollingTimerRef.current = null; } else { schedule(); } }, 3_000); }; schedule(); }, [stopPolling]); // On tab return during polling, clear pending timer, probe, and reschedule if needed useEffect(() => { if (!isPolling) return; const handleVisibility = () => { if (!document.hidden) { // Clear any pending timer so we don't double-fire if (pollingTimerRef.current !== null) { clearTimeout(pollingTimerRef.current); pollingTimerRef.current = null; } // Probe immediately, then restart the polling chain if still disconnected void probeRef.current().then((ok) => { if (!ok && isPolling) { // Restart the setTimeout chain — schedule is captured in startPolling's closure, // so we re-call startPolling which clears+restarts cleanly. startPolling(); } }); } }; document.addEventListener('visibilitychange', handleVisibility); return () => document.removeEventListener('visibilitychange', handleVisibility); }, [isPolling, startPolling]); // Cleanup polling on unmount useEffect(() => { return () => { if (pollingTimerRef.current !== null) { clearTimeout(pollingTimerRef.current); } }; }, []); // ── Mount: sync service URL + auto-probe ───────────────────────────────── useEffect(() => { setServiceUrl(backendUrl); void probe(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return { isConnected, isProbing, backendUrl, startPolling, stopPolling, isPolling, }; }