252 lines
8.5 KiB
TypeScript
252 lines
8.5 KiB
TypeScript
|
|
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<ReturnType<typeof getDraftPicksForSeason>>;
|
||
|
|
type Season = NonNullable<Awaited<ReturnType<typeof findSeasonWithTeamsAndLeague>>>;
|
||
|
|
type Timers = Awaited<ReturnType<typeof getSeasonTimers>>;
|
||
|
|
type AutodraftSettings = Awaited<ReturnType<typeof getSeasonAutodraftSettings>>;
|
||
|
|
|
||
|
|
interface UseDraftAuthRecoveryParams {
|
||
|
|
reconnectCount: number;
|
||
|
|
revalidate: () => void;
|
||
|
|
revalidatorState: "idle" | "loading" | "submitting";
|
||
|
|
clerkUserId: string | null | undefined;
|
||
|
|
currentUserId: string | null | undefined;
|
||
|
|
getToken: (opts?: { skipCache?: boolean }) => Promise<string | null>;
|
||
|
|
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<string, number>) => Record<string, number>) => void;
|
||
|
|
setAutodraftStatus: (fn: () => Record<string, AutodraftStatusEntry>) => 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<ReturnType<typeof setTimeout> | 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<ReturnType<typeof setTimeout> | 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<DraftPicks>([]);
|
||
|
|
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<string, AutodraftStatusEntry> = {};
|
||
|
|
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<Response | null> => {
|
||
|
|
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,
|
||
|
|
};
|
||
|
|
}
|