fix: eliminate socket ghost connection and stale state on reconnect (#38)

- Reduce ping detection window from ~45s to ~15s (pingTimeout 5s, pingInterval 10s)
- Listen to window offline/online events for instant UI feedback on network drop
- Force immediate reconnect on online event instead of waiting for backoff
- Fix stuck-reconnecting bug when network returns within ping timeout window
- Wrap on/off/emit in useCallback to prevent listener re-registration every second
- Expose reconnectCount to trigger revalidator.revalidate() after each reconnect
- Reset hasConnectedOnce ref per socket instance to avoid spurious revalidations
- Remove stale socket ref from hook return value; add emit helper instead

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-02-24 12:24:04 -08:00 committed by GitHub
parent 61f3fa9a4e
commit 64a60a75d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 83 additions and 37 deletions

View file

@ -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<Socket | null>(null);
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(() => {
// 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,
};
}

View file

@ -226,7 +226,7 @@ export default function DraftRoom() {
ownerMap,
} = useLoaderData<typeof loader>();
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(() => {

View file

@ -3,7 +3,7 @@ import { useDraftSocket } from "~/hooks/useDraftSocket";
export default function TestSocket() {
const [messages, setMessages] = useState<string[]>([]);
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"}
</span>
</div>
{socket && (
<p className="text-sm text-muted-foreground mt-1">
Socket ID: {socket.id}
</p>
)}
</div>
<button

View file

@ -76,6 +76,13 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
// Create typed Socket.IO server
io = new SocketIOServer<ClientToServerEvents, ServerToClientEvents>(httpServer, {
// Faster dead-connection detection than the defaults (20s/25s).
// pingTimeout: how long to wait for a pong before declaring the connection dead.
// pingInterval: how often to send a ping.
// Together these detect a crashed server in ~15s instead of ~45s.
// Trade-off: more heartbeat traffic at scale; revisit if server load becomes a concern.
pingTimeout: 5000,
pingInterval: 10000,
cors: process.env.NODE_ENV === "production" && process.env.APP_URL
? {
origin: process.env.APP_URL,