import { useState, useEffect, useCallback, useRef } from "react"; import type { getDraftPicksForSeason } from "~/models/draft-pick"; import type { findSeasonWithTeamsAndLeague } from "~/models/season"; import type { getSeasonTimers } from "~/models/draft-timer"; import type { getSeasonAutodraftSettings } from "~/models/autodraft-settings"; import type { AutodraftStatusEntry, QueueItem } from "~/hooks/useDraftRoomState"; type DraftPicks = Awaited>; type Season = NonNullable>>; type Timers = Awaited>; type AutodraftSettings = Awaited>; interface UseDraftAuthRecoveryParams { reconnectCount: number; revalidate: () => void; revalidatorState: "idle" | "loading" | "submitting"; clerkUserId: string | null | undefined; currentUserId: string | null | undefined; getToken: (opts?: { skipCache?: boolean }) => Promise; userAutodraftSettings: { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean } | null; // Loader data for revalidation sync draftPicks: DraftPicks; season: Season; userQueue: QueueItem[]; timers: Timers; autodraftSettings: AutodraftSettings; // State setters to sync after revalidation setPicks: (picks: DraftPicks) => void; setCurrentPick: (pick: number) => void; setIsPaused: (paused: boolean) => void; setIsDraftComplete: (complete: boolean) => void; setQueue: (queue: QueueItem[]) => void; setTeamTimers: (fn: (prev: Record) => Record) => void; setAutodraftStatus: (fn: () => Record) => void; } const MAX_AUTH_RECOVERY_ATTEMPTS = 3; export function useDraftAuthRecovery({ reconnectCount, revalidate, revalidatorState, clerkUserId, currentUserId, getToken, userAutodraftSettings, draftPicks, season, userQueue, timers, autodraftSettings, setPicks, setCurrentPick, setIsPaused, setIsDraftComplete, setQueue, setTeamTimers, setAutodraftStatus, }: UseDraftAuthRecoveryParams) { const [authDegraded, setAuthDegraded] = useState(false); const [userAutodraft, setUserAutodraft] = useState({ isEnabled: userAutodraftSettings?.isEnabled || false, mode: (userAutodraftSettings?.mode || "next_pick") as "next_pick" | "while_on", queueOnly: userAutodraftSettings?.queueOnly || false, }); const userAutodraftRef = useRef(userAutodraft); useEffect(() => { userAutodraftRef.current = userAutodraft; }, [userAutodraft]); // Re-fetch loader data after each reconnect so stale picks/timers are refreshed. const revalidationRetryRef = useRef | null>(null); useEffect(() => { if (reconnectCount > 0) { revalidate(); if (revalidationRetryRef.current) { clearTimeout(revalidationRetryRef.current); } revalidationRetryRef.current = setTimeout(() => { revalidate(); revalidationRetryRef.current = null; }, 3000); } return () => { if (revalidationRetryRef.current) { clearTimeout(revalidationRetryRef.current); } }; }, [reconnectCount, revalidate]); // Guard against Clerk JWT expiry race. const authRecoveryTimerRef = useRef | undefined>(undefined); const authRecoveryAttemptsRef = useRef(0); useEffect(() => { if (clerkUserId && !currentUserId) { if (authRecoveryAttemptsRef.current >= MAX_AUTH_RECOVERY_ATTEMPTS) { setAuthDegraded(true); return; } clearTimeout(authRecoveryTimerRef.current); authRecoveryTimerRef.current = setTimeout(() => { authRecoveryAttemptsRef.current += 1; revalidate(); }, 100); } else if (currentUserId) { authRecoveryAttemptsRef.current = 0; } return () => clearTimeout(authRecoveryTimerRef.current); }, [clerkUserId, currentUserId, revalidate]); // Proactively refresh the Clerk JWT when the tab becomes visible again. useEffect(() => { if (authDegraded) return; let aborted = false; let inFlight = false; const handleVisibilityChange = async () => { if (document.visibilityState !== "visible" || !clerkUserId || inFlight) return; inFlight = true; try { const token = await getToken({ skipCache: true }); if (aborted) return; if (token !== null) { if (!currentUserId) { revalidate(); } } else { await new Promise((r) => setTimeout(r, 2500)); if (aborted) return; const retryToken = await getToken({ skipCache: true }); if (aborted) return; if (retryToken !== null) { revalidate(); } else { setAuthDegraded(true); } } } catch { try { await new Promise((r) => setTimeout(r, 2500)); if (aborted) return; const retryToken = await getToken({ skipCache: true }); if (aborted) return; if (retryToken !== null) { revalidate(); } else { setAuthDegraded(true); } } catch { if (!aborted) setAuthDegraded(true); } } finally { inFlight = false; } }; document.addEventListener("visibilitychange", handleVisibilityChange); return () => { aborted = true; document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, [clerkUserId, currentUserId, getToken, authDegraded, revalidate]); // Track revalidation lifecycle to sync local state from fresh loader data. const revalidatorStateRef = useRef(revalidatorState); const isRevalidatingRef = useRef(false); const pendingPicksDuringRevalidationRef = useRef([]); const draftPicksAtRevalidationStartRef = useRef(draftPicks); const userQueueAtRevalidationStartRef = useRef(userQueue); const pendingQueueMutationsRef = useRef(0); useEffect(() => { const prev = revalidatorStateRef.current; revalidatorStateRef.current = revalidatorState; if (prev !== "loading" && revalidatorState === "loading") { isRevalidatingRef.current = true; pendingPicksDuringRevalidationRef.current = []; draftPicksAtRevalidationStartRef.current = draftPicks; userQueueAtRevalidationStartRef.current = userQueue; } else if (prev === "loading" && revalidatorState === "idle") { isRevalidatingRef.current = false; if (draftPicks === draftPicksAtRevalidationStartRef.current) { pendingPicksDuringRevalidationRef.current = []; return; } const dbPickIds = new Set(draftPicks.map((p) => p.id)); const missedPicks = pendingPicksDuringRevalidationRef.current.filter( (p) => !dbPickIds.has(p.id) ); pendingPicksDuringRevalidationRef.current = []; setPicks([...draftPicks, ...missedPicks]); setCurrentPick(season.currentPickNumber || 1); setIsPaused(season.draftPaused || false); setIsDraftComplete( season.status === "active" || season.status === "completed" ); if (currentUserId && userQueue !== userQueueAtRevalidationStartRef.current) { setQueue(userQueue); } if (timers.length > 0) { setTeamTimers((currentTimers) => { const updated = { ...currentTimers }; timers.forEach((timer) => { updated[timer.teamId] = timer.timeRemaining; }); return updated; }); } setAutodraftStatus(() => { const status: Record = {}; autodraftSettings.forEach((setting) => { status[setting.teamId] = { isEnabled: setting.isEnabled, mode: setting.mode, queueOnly: setting.queueOnly, }; }); return status; }); } }, [revalidatorState, draftPicks, season, userQueue, timers, autodraftSettings, currentUserId, setPicks, setCurrentPick, setIsPaused, setIsDraftComplete, setQueue, setTeamTimers, setAutodraftStatus]); const authFetch = useCallback(async (url: string, init?: RequestInit): Promise => { const response = await fetch(url, init); if (response.status === 401) { setAuthDegraded(true); return null; } return response; }, []); return { authDegraded, authFetch, userAutodraft, setUserAutodraft, userAutodraftRef, isRevalidatingRef, pendingPicksDuringRevalidationRef, pendingQueueMutationsRef, }; }