connect_error fires on every retry attempt (e.g. iOS Safari dropping WebSocket during network transitions). Using logger.error routed each attempt to Sentry.captureException, generating noise for a self-healing failure. Switch to logger.log (no-op in production); the real failure path (reconnect_failed) still uses logger.error and surfaces in Sentry. https://claude.ai/code/session_01TpZ9W111Trkv2g4CkCWFpB
161 lines
6.1 KiB
TypeScript
161 lines
6.1 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { io } from "socket.io-client";
|
|
import { logger } from "~/lib/logger";
|
|
|
|
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<ReturnType<typeof io> | null>(null);
|
|
const hasConnectedOnce = useRef(false);
|
|
const [isConnected, setIsConnected] = useState(false);
|
|
const [connectionError, setConnectionError] = useState<string | null>(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", () => {
|
|
logger.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) => {
|
|
logger.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) => {
|
|
logger.log("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", () => {
|
|
logger.log("Attempting to reconnect...");
|
|
setIsReconnecting(true);
|
|
setConnectionError(null);
|
|
});
|
|
|
|
socket.io.on("reconnect_failed", () => {
|
|
logger.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);
|
|
logger.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,
|
|
};
|
|
}
|