Primary fix: setTeamTimers now bails out with `return prev` when the
value hasn't changed, preventing a full DraftRoom re-render on every
1-second timer tick (was 33% of profiler samples).
Memoization: wrap AvailableParticipantsSection, TeamsDraftedGrid,
QueueSection, SidebarRecentPicks, and DraftGridSection in React.memo
so timer ticks don't cascade into heavy components that don't use
timer state.
Stable refs: wrap nine action handlers in useCallback and extract two
inline arrow functions from props objects so memo() comparisons
actually bail out. Memoize the { numFlexPicks } object passed to
TeamsDraftedGrid.
socketVersion: expose an incrementing counter from useDraftSocket so
the socket handler effect re-registers on socket recreation.
Async cleanup: add abort flag + in-flight guard to the visibilitychange
JWT refresh handler to prevent concurrent executions and stale state
updates after unmount. Add abort flag to useDraftNotifications
permissions.query() to prevent dangling onchange if unmounted
mid-promise.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
156 lines
5.7 KiB
TypeScript
156 lines
5.7 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;
|
|
/** Increments each time a new socket instance is created. Include in
|
|
* useEffect deps so handler effects re-register on socket recreation. */
|
|
socketVersion: 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);
|
|
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", () => {
|
|
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);
|
|
// 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", () => {
|
|
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; 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);
|
|
console.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.
|
|
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,
|
|
socketVersion,
|
|
on,
|
|
off,
|
|
emit,
|
|
};
|
|
}
|