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:
parent
61f3fa9a4e
commit
64a60a75d8
4 changed files with 83 additions and 37 deletions
|
|
@ -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
|
||||
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 () => {
|
||||
if (socket) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue