From 364b05cd9662307e8ba5d47592cb2219966f4f9c Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:36:52 -0800 Subject: [PATCH] Fix draft state not updating when returning from backgrounded mobile app (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix draft state not updating when returning from backgrounded mobile app Mobile browsers suspend JavaScript and silently drop WebSocket connections when the user switches to another app, without firing "offline"/"online" events. Add a visibilitychange handler so that when the user returns: - If the socket is disconnected, reconnect immediately (skipping backoff) - If the socket appears connected but JS was suspended, rejoin the draft room and trigger a loader revalidation to catch any missed picks/state https://claude.ai/code/session_016tCZVFjSeHdQsdKktbDHEt * Simplify reconnect handlers and fix connect_error overlay flicker - Merge handleOnline and the shared branch of handleVisibilityChange into a single handleReturn function. visibilitychange is now a thin guard that calls it only on show. Both events share the same logic: reconnect if socket dropped, or restore UI state + rejoin room + revalidate if the socket survived. - Remove setIsReconnecting(false) from connect_error: reconnect_attempt fires immediately after and resets it to true anyway, causing the "Reconnecting" overlay to flicker off and back on during every retry cycle. https://claude.ai/code/session_016tCZVFjSeHdQsdKktbDHEt * Fix four issues from useDraftSocket code review - Manager listener leak: add socket.io.off() for reconnect_attempt and reconnect_failed in cleanup — socket.disconnect() only tears down the socket, not the Manager listeners, causing them to accumulate on re-mounts. - reconnect_failed dead code: add reconnectionAttempts: 10 to io() config so the handler is actually reachable after exhausting retries. - connectionError flicker: remove setConnectionError from connect_error — reconnect_attempt fires immediately after and clears it anyway, causing the error overlay to flash on every retry cycle. Error now only appears via reconnect_failed once all attempts are exhausted. connect_error instead ensures setIsReconnecting(true) so the reconnecting overlay shows instead of the initial "Connecting to Draft" spinner. - Add comment to on/off/emit noting they are no-ops if called before the effect runs (socketRef.current === null). https://claude.ai/code/session_016tCZVFjSeHdQsdKktbDHEt --------- Co-authored-by: Claude --- app/hooks/useDraftSocket.ts | 45 +++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/app/hooks/useDraftSocket.ts b/app/hooks/useDraftSocket.ts index ea483a4..e559fe6 100644 --- a/app/hooks/useDraftSocket.ts +++ b/app/hooks/useDraftSocket.ts @@ -27,6 +27,7 @@ export function useDraftSocket(seasonId: string, teamId?: string): UseDraftSocke const socket = io({ path: "/socket.io", transports: ["websocket", "polling"], + reconnectionAttempts: 10, }); socketRef.current = socket; @@ -56,8 +57,11 @@ export function useDraftSocket(seasonId: string, teamId?: string): UseDraftSocke socket.on("connect_error", (error) => { console.error("Socket.IO connection error:", error); - setConnectionError(error.message || "Failed to connect to draft server"); - setIsReconnecting(false); + // 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", () => { @@ -75,36 +79,53 @@ export function useDraftSocket(seasonId: string, teamId?: string): UseDraftSocke 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 + // spuriously on mobile; handleReturn 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. + // 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); - } else { - // Genuinely disconnected — skip backoff and reconnect immediately. - socketRef.current?.connect(); + 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", handleOnline); + 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", handleOnline); + 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); }, []);