brackt/app/hooks/useDraftSocket.ts
Claude 96f02d2643
Fix draft state not updating when returning from backgrounded mobile app
Mobile browsers suspend JavaScript and silently drop WebSocket connections
when the user switches to another app, without firing "offline"/"online"
events. Add a visibilitychange handler so that when the user returns:
- If the socket is disconnected, reconnect immediately (skipping backoff)
- If the socket appears connected but JS was suspended, rejoin the draft
  room and trigger a loader revalidation to catch any missed picks/state

https://claude.ai/code/session_016tCZVFjSeHdQsdKktbDHEt
2026-02-25 17:25:38 +00:00

148 lines
5.2 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { io } from "socket.io-client";
interface UseDraftSocketReturn {
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<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);
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"],
});
socketRef.current = socket;
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);
setConnectionError(error.message || "Failed to connect to draft server");
setIsReconnecting(false);
});
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; 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();
}
};
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible") return;
if (!socketRef.current?.connected) {
// Mobile browsers kill WebSockets when the app is backgrounded without
// firing "offline". Reconnect immediately when the user returns.
socketRef.current?.connect();
} else {
// Socket appears connected but may have gone stale while JS was suspended.
// Rejoin the draft room (idempotent) and bump reconnectCount so the draft
// page revalidates loader data to catch any picks/state changes missed
// while the browser was backgrounded.
socketRef.current?.emit("join-draft", seasonId, teamId);
setReconnectCount((c) => c + 1);
}
};
window.addEventListener("offline", handleOffline);
window.addEventListener("online", handleOnline);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("offline", handleOffline);
window.removeEventListener("online", handleOnline);
document.removeEventListener("visibilitychange", handleVisibilityChange);
console.log("Leaving draft room:", seasonId);
socket.emit("leave-draft", seasonId);
socket.disconnect();
};
}, [seasonId, teamId]);
const on = useCallback((event: string, callback: (...args: any[]) => void) => {
socketRef.current?.on(event, callback);
}, []);
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 {
isConnected,
connectionError,
isReconnecting,
reconnectCount,
on,
off,
emit,
};
}