diff --git a/app/hooks/useDraftSocket.ts b/app/hooks/useDraftSocket.ts index 12480d3..ea483a4 100644 --- a/app/hooks/useDraftSocket.ts +++ b/app/hooks/useDraftSocket.ts @@ -1,23 +1,29 @@ -import { useEffect, useRef, useState } from "react"; -import { io, Socket } from "socket.io-client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { io } from "socket.io-client"; interface UseDraftSocketReturn { - socket: Socket | null; isConnected: boolean; connectionError: string | null; isReconnecting: boolean; + reconnectCount: number; on: (event: string, callback: (...args: any[]) => void) => void; off: (event: string, callback?: (...args: any[]) => void) => void; + emit: (event: string, ...args: any[]) => void; } export function useDraftSocket(seasonId: string, teamId?: string): UseDraftSocketReturn { - const socketRef = useRef(null); + 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); useEffect(() => { - // Connect to Socket.IO server + // 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"], @@ -27,21 +33,23 @@ export function useDraftSocket(seasonId: string, teamId?: string): UseDraftSocke socket.on("connect", () => { console.log("Connected to Socket.IO:", socket.id); + const isReconnect = hasConnectedOnce.current; + hasConnectedOnce.current = true; setIsConnected(true); setConnectionError(null); setIsReconnecting(false); - // Join the draft room with optional teamId 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") { - // Server disconnected the socket, need to manually reconnect setConnectionError("Server disconnected. Please refresh the page."); } else { - // Client disconnected or network issue, will auto-reconnect setIsReconnecting(true); } }); @@ -64,32 +72,58 @@ export function useDraftSocket(seasonId: string, teamId?: string): UseDraftSocke setIsReconnecting(false); }); - // Cleanup on unmount - return () => { - if (socket) { - console.log("Leaving draft room:", seasonId); - socket.emit("leave-draft", seasonId); - socket.disconnect(); + 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 + // 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. + setIsConnected(true); + setIsReconnecting(false); + } else { + // Genuinely disconnected — skip backoff and reconnect immediately. + socketRef.current?.connect(); } }; + + window.addEventListener("offline", handleOffline); + window.addEventListener("online", handleOnline); + + return () => { + window.removeEventListener("offline", handleOffline); + window.removeEventListener("online", handleOnline); + console.log("Leaving draft room:", seasonId); + socket.emit("leave-draft", seasonId); + socket.disconnect(); + }; }, [seasonId, teamId]); - // Helper to subscribe to events - const on = (event: string, callback: (...args: any[]) => void) => { + const on = useCallback((event: string, callback: (...args: any[]) => void) => { socketRef.current?.on(event, callback); - }; + }, []); - // Helper to unsubscribe from events - const off = (event: string, callback?: (...args: any[]) => void) => { + const off = useCallback((event: string, callback?: (...args: any[]) => void) => { socketRef.current?.off(event, callback); - }; + }, []); + + const emit = useCallback((event: string, ...args: any[]) => { + socketRef.current?.emit(event, ...args); + }, []); return { - socket: socketRef.current, isConnected, connectionError, isReconnecting, + reconnectCount, on, - off + off, + emit, }; } diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 4a9552f..354a979 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -226,7 +226,7 @@ export default function DraftRoom() { ownerMap, } = useLoaderData(); const { revalidate } = useRevalidator(); - const { isConnected, connectionError, isReconnecting, on, off } = useDraftSocket(season.id, userTeam?.id); + const { isConnected, connectionError, isReconnecting, reconnectCount, on, off } = useDraftSocket(season.id, userTeam?.id); const { permissionState: notificationsPermission, enabled: notificationsEnabled, @@ -246,6 +246,14 @@ export default function DraftRoom() { useEffect(() => { notificationsModeRef.current = notificationsMode; }, [notificationsMode]); + + // Re-fetch loader data after each reconnect so stale picks/timers are refreshed + useEffect(() => { + if (reconnectCount > 0) { + revalidate(); + } + }, [reconnectCount, revalidate]); + const [picks, setPicks] = useState(draftPicks); const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1); const [searchQuery, setSearchQuery] = useState(""); @@ -554,7 +562,11 @@ export default function DraftRoom() { off("pick-replaced", handlePickReplaced); off("draft-rolled-back", handleDraftRolledBack); }; - }, [on, off, currentPick, userTeam, draftSlots, season.league.name]); + // `on`/`off` are stable useCallback refs — this effect runs once per socket instance. + // `draftSlots`, `userTeam`, and `season.league.name` are intentionally omitted: they + // come from useLoaderData and are immutable for the lifetime of the page load. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [on, off]); // Persist sidebar collapsed state useEffect(() => { diff --git a/app/routes/test-socket.tsx b/app/routes/test-socket.tsx index b91c9a3..5f3ee9b 100644 --- a/app/routes/test-socket.tsx +++ b/app/routes/test-socket.tsx @@ -3,7 +3,7 @@ import { useDraftSocket } from "~/hooks/useDraftSocket"; export default function TestSocket() { const [messages, setMessages] = useState([]); - const { socket, isConnected, on, off } = useDraftSocket("test-season-123"); + const { isConnected, on, off, emit } = useDraftSocket("test-season-123"); useEffect(() => { // Listen for test messages @@ -20,13 +20,11 @@ export default function TestSocket() { }, [on, off]); const sendTestMessage = () => { - if (socket) { - socket.emit("test-event", { - message: "Hello from client!", - timestamp: new Date().toISOString(), - }); - setMessages((prev) => [...prev, "Sent: Hello from client!"]); - } + emit("test-event", { + message: "Hello from client!", + timestamp: new Date().toISOString(), + }); + setMessages((prev) => [...prev, "Sent: Hello from client!"]); }; return ( @@ -44,11 +42,6 @@ export default function TestSocket() { Status: {isConnected ? "Connected" : "Disconnected"} - {socket && ( -

- Socket ID: {socket.id} -

- )}