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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { io, Socket } from "socket.io-client";
|
import { io } from "socket.io-client";
|
||||||
|
|
||||||
interface UseDraftSocketReturn {
|
interface UseDraftSocketReturn {
|
||||||
socket: Socket | null;
|
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
connectionError: string | null;
|
connectionError: string | null;
|
||||||
isReconnecting: boolean;
|
isReconnecting: boolean;
|
||||||
|
reconnectCount: number;
|
||||||
on: (event: string, callback: (...args: any[]) => void) => void;
|
on: (event: string, callback: (...args: any[]) => void) => void;
|
||||||
off: (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 {
|
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 [isConnected, setIsConnected] = useState(false);
|
||||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||||
|
const [reconnectCount, setReconnectCount] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
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({
|
const socket = io({
|
||||||
path: "/socket.io",
|
path: "/socket.io",
|
||||||
transports: ["websocket", "polling"],
|
transports: ["websocket", "polling"],
|
||||||
|
|
@ -27,21 +33,23 @@ export function useDraftSocket(seasonId: string, teamId?: string): UseDraftSocke
|
||||||
|
|
||||||
socket.on("connect", () => {
|
socket.on("connect", () => {
|
||||||
console.log("Connected to Socket.IO:", socket.id);
|
console.log("Connected to Socket.IO:", socket.id);
|
||||||
|
const isReconnect = hasConnectedOnce.current;
|
||||||
|
hasConnectedOnce.current = true;
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
setConnectionError(null);
|
setConnectionError(null);
|
||||||
setIsReconnecting(false);
|
setIsReconnecting(false);
|
||||||
// Join the draft room with optional teamId
|
|
||||||
socket.emit("join-draft", seasonId, teamId);
|
socket.emit("join-draft", seasonId, teamId);
|
||||||
|
if (isReconnect) {
|
||||||
|
setReconnectCount((c) => c + 1);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("disconnect", (reason) => {
|
socket.on("disconnect", (reason) => {
|
||||||
console.log("Disconnected from Socket.IO:", reason);
|
console.log("Disconnected from Socket.IO:", reason);
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
if (reason === "io server disconnect") {
|
if (reason === "io server disconnect") {
|
||||||
// Server disconnected the socket, need to manually reconnect
|
|
||||||
setConnectionError("Server disconnected. Please refresh the page.");
|
setConnectionError("Server disconnected. Please refresh the page.");
|
||||||
} else {
|
} else {
|
||||||
// Client disconnected or network issue, will auto-reconnect
|
|
||||||
setIsReconnecting(true);
|
setIsReconnecting(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -64,32 +72,58 @@ export function useDraftSocket(seasonId: string, teamId?: string): UseDraftSocke
|
||||||
setIsReconnecting(false);
|
setIsReconnecting(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup on unmount
|
const handleOffline = () => {
|
||||||
return () => {
|
// Mark as reconnecting immediately — the OS fires this before Socket.IO's
|
||||||
if (socket) {
|
// heartbeat would detect the drop (~15s later). Note: "offline" can fire
|
||||||
console.log("Leaving draft room:", seasonId);
|
// spuriously on mobile; handleOnline corrects the state if the socket is
|
||||||
socket.emit("leave-draft", seasonId);
|
// still alive when the network returns.
|
||||||
socket.disconnect();
|
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]);
|
}, [seasonId, teamId]);
|
||||||
|
|
||||||
// Helper to subscribe to events
|
const on = useCallback((event: string, callback: (...args: any[]) => void) => {
|
||||||
const on = (event: string, callback: (...args: any[]) => void) => {
|
|
||||||
socketRef.current?.on(event, callback);
|
socketRef.current?.on(event, callback);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// Helper to unsubscribe from events
|
const off = useCallback((event: string, callback?: (...args: any[]) => void) => {
|
||||||
const off = (event: string, callback?: (...args: any[]) => void) => {
|
|
||||||
socketRef.current?.off(event, callback);
|
socketRef.current?.off(event, callback);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
const emit = useCallback((event: string, ...args: any[]) => {
|
||||||
|
socketRef.current?.emit(event, ...args);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
socket: socketRef.current,
|
|
||||||
isConnected,
|
isConnected,
|
||||||
connectionError,
|
connectionError,
|
||||||
isReconnecting,
|
isReconnecting,
|
||||||
|
reconnectCount,
|
||||||
on,
|
on,
|
||||||
off
|
off,
|
||||||
|
emit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@ export default function DraftRoom() {
|
||||||
ownerMap,
|
ownerMap,
|
||||||
} = useLoaderData<typeof loader>();
|
} = useLoaderData<typeof loader>();
|
||||||
const { revalidate } = useRevalidator();
|
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 {
|
const {
|
||||||
permissionState: notificationsPermission,
|
permissionState: notificationsPermission,
|
||||||
enabled: notificationsEnabled,
|
enabled: notificationsEnabled,
|
||||||
|
|
@ -246,6 +246,14 @@ export default function DraftRoom() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
notificationsModeRef.current = notificationsMode;
|
notificationsModeRef.current = notificationsMode;
|
||||||
}, [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 [picks, setPicks] = useState(draftPicks);
|
||||||
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
|
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
@ -554,7 +562,11 @@ export default function DraftRoom() {
|
||||||
off("pick-replaced", handlePickReplaced);
|
off("pick-replaced", handlePickReplaced);
|
||||||
off("draft-rolled-back", handleDraftRolledBack);
|
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
|
// Persist sidebar collapsed state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useDraftSocket } from "~/hooks/useDraftSocket";
|
||||||
|
|
||||||
export default function TestSocket() {
|
export default function TestSocket() {
|
||||||
const [messages, setMessages] = useState<string[]>([]);
|
const [messages, setMessages] = useState<string[]>([]);
|
||||||
const { socket, isConnected, on, off } = useDraftSocket("test-season-123");
|
const { isConnected, on, off, emit } = useDraftSocket("test-season-123");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Listen for test messages
|
// Listen for test messages
|
||||||
|
|
@ -20,13 +20,11 @@ export default function TestSocket() {
|
||||||
}, [on, off]);
|
}, [on, off]);
|
||||||
|
|
||||||
const sendTestMessage = () => {
|
const sendTestMessage = () => {
|
||||||
if (socket) {
|
emit("test-event", {
|
||||||
socket.emit("test-event", {
|
message: "Hello from client!",
|
||||||
message: "Hello from client!",
|
timestamp: new Date().toISOString(),
|
||||||
timestamp: new Date().toISOString(),
|
});
|
||||||
});
|
setMessages((prev) => [...prev, "Sent: Hello from client!"]);
|
||||||
setMessages((prev) => [...prev, "Sent: Hello from client!"]);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -44,11 +42,6 @@ export default function TestSocket() {
|
||||||
Status: {isConnected ? "Connected" : "Disconnected"}
|
Status: {isConnected ? "Connected" : "Disconnected"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{socket && (
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Socket ID: {socket.id}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,13 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
||||||
|
|
||||||
// Create typed Socket.IO server
|
// Create typed Socket.IO server
|
||||||
io = new SocketIOServer<ClientToServerEvents, ServerToClientEvents>(httpServer, {
|
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
|
cors: process.env.NODE_ENV === "production" && process.env.APP_URL
|
||||||
? {
|
? {
|
||||||
origin: process.env.APP_URL,
|
origin: process.env.APP_URL,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue