From 5a455e6cae9099a637f64ff085a7d1ca75a60ea0 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Thu, 30 Apr 2026 20:23:52 -0700 Subject: [PATCH] Show "Syncing Draft State..." overlay during draft reconnection When a user reconnects to a draft room (socket reconnect, tab visibility return, etc.), there was a gap between when the socket connected and when the full draft state sync arrived. The ConnectionOverlay vanished instantly on reconnect, showing potentially stale data. Add an isSyncing flag that keeps the overlay visible with "Syncing Draft State..." messaging until the actual data arrives via socket or HTTP revalidation. A 5-second safety timeout prevents the overlay from getting stuck. Fixes #78 --- app/components/draft/ConnectionOverlay.tsx | 10 +- app/hooks/useDraftAuthRecovery.ts | 5 +- app/hooks/useDraftRoomState.ts | 2 + app/hooks/useDraftSocketEvents.ts | 3 + .../leagues/$leagueId.draft.$seasonId.tsx | 10 ++ .../__tests__/draft-reconnection-sync.test.ts | 166 ++++++++++++++++++ 6 files changed, 192 insertions(+), 4 deletions(-) diff --git a/app/components/draft/ConnectionOverlay.tsx b/app/components/draft/ConnectionOverlay.tsx index f1f6916..47f7bda 100644 --- a/app/components/draft/ConnectionOverlay.tsx +++ b/app/components/draft/ConnectionOverlay.tsx @@ -5,15 +5,16 @@ interface ConnectionOverlayProps { isConnected: boolean; isReconnecting: boolean; connectionError: string | null; + isSyncing: boolean; } export function ConnectionOverlay({ isConnected, isReconnecting, connectionError, + isSyncing, }: ConnectionOverlayProps) { - // Don't show overlay if connected - if (isConnected) { + if (isConnected && !isSyncing) { return null; } @@ -51,15 +52,18 @@ export function ConnectionOverlay({

{connectionError ? "Connection Error" + : isSyncing + ? "Syncing Draft State..." : isReconnecting ? "Reconnecting..." : "Connecting to Draft"}

- {/* Message */}

{connectionError ? ( connectionError + ) : isSyncing ? ( + "Reconnected. Syncing the latest draft state..." ) : isReconnecting ? ( "Lost connection to the draft server. Attempting to reconnect..." ) : ( diff --git a/app/hooks/useDraftAuthRecovery.ts b/app/hooks/useDraftAuthRecovery.ts index fcb6988..46dee59 100644 --- a/app/hooks/useDraftAuthRecovery.ts +++ b/app/hooks/useDraftAuthRecovery.ts @@ -31,6 +31,7 @@ interface UseDraftAuthRecoveryParams { setQueue: (queue: QueueItem[]) => void; setTeamTimers: (fn: (prev: Record) => Record) => void; setAutodraftStatus: (fn: () => Record) => void; + setIsSyncing: (value: boolean) => void; } @@ -52,6 +53,7 @@ export function useDraftAuthRecovery({ setQueue, setTeamTimers, setAutodraftStatus, + setIsSyncing, }: UseDraftAuthRecoveryParams) { const [authDegraded, setAuthDegraded] = useState(false); @@ -192,9 +194,10 @@ export function useDraftAuthRecovery({ }); return status; }); + setIsSyncing(false); } }, [revalidatorState, draftPicks, season, userQueue, timers, autodraftSettings, currentUserId, - setPicks, setCurrentPick, setIsPaused, setIsDraftComplete, setQueue, setTeamTimers, setAutodraftStatus]); + setPicks, setCurrentPick, setIsPaused, setIsDraftComplete, setQueue, setTeamTimers, setAutodraftStatus, setIsSyncing]); const authFetch = useCallback(async (url: string, init?: RequestInit): Promise => { const response = await fetch(url, init); diff --git a/app/hooks/useDraftRoomState.ts b/app/hooks/useDraftRoomState.ts index ff002a1..a370ed3 100644 --- a/app/hooks/useDraftRoomState.ts +++ b/app/hooks/useDraftRoomState.ts @@ -136,6 +136,7 @@ export function useDraftRoomState({ const [isOvernightPause, setIsOvernightPause] = useState(false); const [overnightResumesAt, setOvernightResumesAt] = useState(null); const [roomClosed, setRoomClosed] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); return { picks, setPicks, @@ -177,5 +178,6 @@ export function useDraftRoomState({ isOvernightPause, setIsOvernightPause, overnightResumesAt, setOvernightResumesAt, roomClosed, setRoomClosed, + isSyncing, setIsSyncing, }; } diff --git a/app/hooks/useDraftSocketEvents.ts b/app/hooks/useDraftSocketEvents.ts index 304b8a2..43efa25 100644 --- a/app/hooks/useDraftSocketEvents.ts +++ b/app/hooks/useDraftSocketEvents.ts @@ -40,6 +40,7 @@ interface UseDraftSocketEventsParams { setOvernightResumesAt: (value: Date | null) => void; setWatchedParticipantIds: (value: Set) => void; setRoomClosed: (value: boolean) => void; + setIsSyncing: (value: boolean) => void; } export function useDraftSocketEvents({ @@ -68,6 +69,7 @@ export function useDraftSocketEvents({ setOvernightResumesAt, setWatchedParticipantIds, setRoomClosed, + setIsSyncing, }: UseDraftSocketEventsParams) { useEffect(() => { type PickMadePayload = { @@ -225,6 +227,7 @@ export function useDraftSocketEvents({ setWatchedParticipantIds(new Set(data.watchlistParticipantIds)); } } + setIsSyncing(false); }; const handleWatchlistUpdated = (data: { participantIds: string[] }) => { diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 7515047..0b79451 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -254,6 +254,7 @@ export default function DraftRoom() { isOvernightPause, setIsOvernightPause, overnightResumesAt, setOvernightResumesAt, roomClosed, setRoomClosed, + isSyncing, setIsSyncing, } = useDraftRoomState({ draftPicks, season, @@ -335,8 +336,15 @@ export default function DraftRoom() { setQueue, setTeamTimers, setAutodraftStatus, + setIsSyncing, }); + useEffect(() => { + if (reconnectCount <= 0) return; + setIsSyncing(true); + const id = setTimeout(() => setIsSyncing(false), 5000); + return () => clearTimeout(id); + }, [reconnectCount, setIsSyncing]); // Shared transforms for eligibility calculations const transformedPicks = useMemo( @@ -463,6 +471,7 @@ export default function DraftRoom() { setOvernightResumesAt, setWatchedParticipantIds, setRoomClosed, + setIsSyncing, }); // Persist sidebar collapsed state @@ -1660,6 +1669,7 @@ export default function DraftRoom() { isConnected={isConnected} isReconnecting={isReconnecting} connectionError={connectionError} + isSyncing={isSyncing} /> {/* Auth Recovery Overlay - blocks interaction when session expires */} diff --git a/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts b/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts index dd5dfa7..743c2a5 100644 --- a/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts +++ b/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts @@ -760,3 +760,169 @@ describe("autodraft and timer sync on revalidation completion", () => { expect(teamTimers["team-3"]).toBe(120); }); }); + +// --------------------------------------------------------------------------- +// 10. isSyncing state — reconnect sync indicator +// --------------------------------------------------------------------------- + +describe("isSyncing – reconnect sync indicator", () => { + it("sets isSyncing true when reconnectCount increments", () => { + let isSyncing = false; + const setIsSyncing = (value: boolean) => { isSyncing = value; }; + const reconnectCount = 1; + + if (reconnectCount > 0) { + setIsSyncing(true); + } + + expect(isSyncing).toBe(true); + }); + + it("does not set isSyncing on initial load (reconnectCount = 0)", () => { + let isSyncing = false; + const setIsSyncing = (value: boolean) => { isSyncing = value; }; + const reconnectCount = 0; + + if (reconnectCount > 0) { + setIsSyncing(true); + } + + expect(isSyncing).toBe(false); + }); + + it("clears isSyncing when draft-state-sync arrives (socket path)", () => { + let isSyncing = true; + const setIsSyncing = (value: boolean) => { isSyncing = value; }; + const isRevalidatingRef = { current: false }; + let picks: any[] = []; + const setPicks = (fn: (prev: any[]) => any[]) => { picks = fn(picks); }; + + const handleDraftStateSync = (data: any) => { + if (!isRevalidatingRef.current) { + setPicks(() => data.picks); + setIsSyncing(false); + } + }; + + handleDraftStateSync({ + currentPickNumber: 7, + isPaused: false, + status: "draft", + picks: [makePick(1, "team-1", "player-1")], + timers: [], + }); + + expect(isSyncing).toBe(false); + }); + + it("clears isSyncing even when draft-state-sync is skipped due to revalidation", () => { + let isSyncing = true; + const setIsSyncing = (value: boolean) => { isSyncing = value; }; + const isRevalidatingRef = { current: true }; + + const handleDraftStateSync = () => { + if (!isRevalidatingRef.current) { + // state updates skipped + } + setIsSyncing(false); + }; + + handleDraftStateSync(); + + expect(isSyncing).toBe(false); + }); + + it("clears isSyncing when revalidation completes (HTTP path)", () => { + let isSyncing = true; + const setIsSyncing = (value: boolean) => { isSyncing = value; }; + + const stalePicks = [makePick(1, "team-1", "player-1")]; + const freshPicks = [ + makePick(1, "team-1", "player-1"), + makePick(2, "team-2", "player-2"), + ]; + + if (freshPicks !== stalePicks) { + setIsSyncing(false); + } + + expect(isSyncing).toBe(false); + }); + + it("does not clear isSyncing when revalidation fails (staleness guard)", () => { + let isSyncing = true; + const setIsSyncing = (value: boolean) => { isSyncing = value; }; + + const stalePicks = [makePick(1, "team-1", "player-1")]; + const sameReference = stalePicks; + + if (sameReference !== stalePicks) { + setIsSyncing(false); + } + + expect(isSyncing).toBe(true); + }); + + describe("safety timeout", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it("clears isSyncing after 5 seconds even if sync never arrives", () => { + let isSyncing = false; + const setIsSyncing = (value: boolean) => { isSyncing = value; }; + + setIsSyncing(true); + const id = setTimeout(() => setIsSyncing(false), 5000); + + expect(isSyncing).toBe(true); + + vi.advanceTimersByTime(4999); + expect(isSyncing).toBe(true); + + vi.advanceTimersByTime(1); + expect(isSyncing).toBe(false); + + clearTimeout(id); + }); + + it("resets the safety timeout when reconnectCount increments rapidly", () => { + let isSyncing = false; + const setIsSyncing = (value: boolean) => { isSyncing = value; }; + let timeoutId: ReturnType | null = null; + + const simulateReconnect = () => { + setIsSyncing(true); + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout(() => setIsSyncing(false), 5000); + }; + + simulateReconnect(); + vi.advanceTimersByTime(3000); + expect(isSyncing).toBe(true); + + simulateReconnect(); + vi.advanceTimersByTime(3000); + expect(isSyncing).toBe(true); + + vi.advanceTimersByTime(2000); + expect(isSyncing).toBe(false); + + if (timeoutId) clearTimeout(timeoutId); + }); + + it("stays false when both socket and HTTP paths clear isSyncing (idempotency)", () => { + let isSyncing = false; + const setIsSyncing = (value: boolean) => { isSyncing = value; }; + + setIsSyncing(true); + expect(isSyncing).toBe(true); + + setIsSyncing(false); + expect(isSyncing).toBe(false); + + setIsSyncing(false); + expect(isSyncing).toBe(false); + }); + }); +});