import { useCallback, useEffect, useRef, useState } from "react"; import { io } from "socket.io-client"; interface UseDraftSocketReturn { isConnected: boolean; connectionError: string | null; isReconnecting: boolean; reconnectCount: number; /** Increments each time a new socket instance is created. Include in * useEffect deps so handler effects re-register on socket recreation. */ socketVersion: number; // eslint-disable-next-line typescript/no-explicit-any -- socket.io callbacks are untyped at the hook level on: (event: string, callback: (...args: any[]) => void) => void; // eslint-disable-next-line typescript/no-explicit-any off: (event: string, callback?: (...args: any[]) => void) => void; emit: (event: string, ...args: unknown[]) => void; } export function useDraftSocket(seasonId: string, teamId?: string): UseDraftSocketReturn { const socketRef = useRef | null>(null); const hasConnectedOnce = useRef(false); const [isConnected, setIsConnected] = useState(false); const [connectionError, setConnectionError] = useState(null); const [isReconnecting, setIsReconnecting] = useState(false); const [reconnectCount, setReconnectCount] = useState(0); const [socketVersion, setSocketVersion] = useState(0); useEffect(() => { // Reset per-socket state so a new seasonId/teamId doesn't inherit the previous // socket's connect history and falsely treat its first connect as a reconnect. hasConnectedOnce.current = false; const socket = io({ path: "/socket.io", transports: ["websocket", "polling"], reconnectionAttempts: 10, }); socketRef.current = socket; setSocketVersion((v) => v + 1); socket.on("connect", () => { console.log("Connected to Socket.IO:", socket.id); const isReconnect = hasConnectedOnce.current; hasConnectedOnce.current = true; setIsConnected(true); setConnectionError(null); setIsReconnecting(false); socket.emit("join-draft", seasonId, teamId); if (isReconnect) { setReconnectCount((c) => c + 1); } }); socket.on("disconnect", (reason) => { console.log("Disconnected from Socket.IO:", reason); setIsConnected(false); if (reason === "io server disconnect") { setConnectionError("Server disconnected. Please refresh the page."); } else { setIsReconnecting(true); } }); socket.on("connect_error", (error) => { console.error("Socket.IO connection error:", error); // Don't set connectionError here — reconnect_attempt fires immediately after // and would clear it again, causing the error overlay to flicker on every // retry. Only show a hard error once all attempts are exhausted (reconnect_failed). // Ensure the "reconnecting" overlay is visible rather than the initial spinner. setIsReconnecting(true); }); socket.io.on("reconnect_attempt", () => { console.log("Attempting to reconnect..."); setIsReconnecting(true); setConnectionError(null); }); socket.io.on("reconnect_failed", () => { console.error("Reconnection failed"); setConnectionError("Failed to reconnect. Please refresh the page."); setIsReconnecting(false); }); const handleOffline = () => { // Mark as reconnecting immediately — the OS fires this before Socket.IO's // heartbeat would detect the drop (~15s later). Note: "offline" can fire // spuriously on mobile; handleReturn corrects the state if the socket is // still alive when the network returns. setIsConnected(false); setIsReconnecting(true); }; // Unified handler for both "network returned" (online event) and // "browser tab/app regained focus" (visibilitychange). Two cases: // - Socket dropped: reconnect immediately, bypassing backoff. // - Socket alive: restore UI state and rejoin the draft room so the page // revalidates and picks up any events missed while the client was away. const handleReturn = () => { if (!socketRef.current?.connected) { socketRef.current?.connect(); } else { setIsConnected(true); setIsReconnecting(false); socketRef.current.emit("join-draft", seasonId, teamId); setReconnectCount((c) => c + 1); } }; // visibilitychange fires on both hide and show — only act on show. const handleVisibilityChange = () => { if (document.visibilityState === "visible") handleReturn(); }; window.addEventListener("offline", handleOffline); window.addEventListener("online", handleReturn); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { socket.io.off("reconnect_attempt"); socket.io.off("reconnect_failed"); window.removeEventListener("offline", handleOffline); window.removeEventListener("online", handleReturn); document.removeEventListener("visibilitychange", handleVisibilityChange); console.log("Leaving draft room:", seasonId); socket.emit("leave-draft", seasonId); socket.disconnect(); }; }, [seasonId, teamId]); // These read socketRef.current at call time, so calls made before the effect // has run (socketRef.current === null) are silently no-ops. Consumers should // only call them inside their own useEffect, not during render or synchronously // after mount. // eslint-disable-next-line typescript/no-explicit-any -- socket.io callbacks are untyped at the hook level const on = useCallback((event: string, callback: (...args: any[]) => void) => { socketRef.current?.on(event, callback); }, []); // eslint-disable-next-line typescript/no-explicit-any const off = useCallback((event: string, callback?: (...args: any[]) => void) => { socketRef.current?.off(event, callback); }, []); const emit = useCallback((event: string, ...args: unknown[]) => { socketRef.current?.emit(event, ...args); }, []); return { isConnected, connectionError, isReconnecting, reconnectCount, socketVersion, on, off, emit, }; }