diff --git a/app/hooks/useDraftSocket.ts b/app/hooks/useDraftSocket.ts index ea483a4..e559fe6 100644 --- a/app/hooks/useDraftSocket.ts +++ b/app/hooks/useDraftSocket.ts @@ -27,6 +27,7 @@ export function useDraftSocket(seasonId: string, teamId?: string): UseDraftSocke const socket = io({ path: "/socket.io", transports: ["websocket", "polling"], + reconnectionAttempts: 10, }); socketRef.current = socket; @@ -56,8 +57,11 @@ export function useDraftSocket(seasonId: string, teamId?: string): UseDraftSocke socket.on("connect_error", (error) => { console.error("Socket.IO connection error:", error); - setConnectionError(error.message || "Failed to connect to draft server"); - setIsReconnecting(false); + // 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", () => { @@ -75,36 +79,53 @@ export function useDraftSocket(seasonId: string, teamId?: string): UseDraftSocke 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; handleOnline corrects the state if the socket is + // spuriously on mobile; handleReturn corrects the state if the socket is // still alive when the network returns. setIsConnected(false); setIsReconnecting(true); }; - const handleOnline = () => { - if (socketRef.current?.connected) { - // Network blipped but the socket stayed alive (came back within ping timeout window). - // The socket never disconnected so no "connect" event will fire — correct UI directly. + // 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); - } else { - // Genuinely disconnected — skip backoff and reconnect immediately. - socketRef.current?.connect(); + 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", handleOnline); + 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", handleOnline); + 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. const on = useCallback((event: string, callback: (...args: any[]) => void) => { socketRef.current?.on(event, callback); }, []);