brackt/app/hooks/useDraftSocket.ts
Chris Parsons e2b178221a
Add oxlint linting setup with zero errors (#194)
* Add oxlint and fix all lint errors

- Install oxlint, add .oxlintrc.json with rules for TypeScript/React
- Add npm run lint / lint:fix scripts
- Add Claude PostToolUse hook to run oxlint on every edited file
- Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array
- Fix no-array-index-key (use stable keys or suppress positional cases)
- Fix exhaustive-deps missing dependency in useEffect
- Promote exhaustive-deps and no-array-index-key to errors
- Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix no-explicit-any warnings and upgrade tsconfig to ES2023

- Replace all `any` types with proper types or `unknown` across ~20 files
- Add typed socket payload interfaces in draft route and useDraftSocket
- Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch)
- Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed
- Fix cascading type errors uncovered by removing any: Map.get narrowing,
  participant relation types, ChartDataPoint, Partial<NewSeason> indexing
- Add ParticipantResultWithParticipant type to participant-result model
- Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult)
- Fix duplicate getQPStandings import in sportsSeasonId.server.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Promote no-explicit-any to error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00

160 lines
6 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;
// eslint-disable-next-line typescript/no-explicit-any -- socket.io callbacks are untyped at the hook level
on: (event: string, callback: (...args: any[]) => void) => void;
// eslint-disable-next-line typescript/no-explicit-any
off: (event: string, callback?: (...args: any[]) => void) => void;
emit: (event: string, ...args: unknown[]) => 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.
// eslint-disable-next-line typescript/no-explicit-any -- socket.io callbacks are untyped at the hook level
const on = useCallback((event: string, callback: (...args: any[]) => void) => {
socketRef.current?.on(event, callback);
}, []);
// eslint-disable-next-line typescript/no-explicit-any
const off = useCallback((event: string, callback?: (...args: any[]) => void) => {
socketRef.current?.off(event, callback);
}, []);
const emit = useCallback((event: string, ...args: unknown[]) => {
socketRef.current?.emit(event, ...args);
}, []);
return {
isConnected,
connectionError,
isReconnecting,
reconnectCount,
socketVersion,
on,
off,
emit,
};
}