brackt/app/hooks/useDraftAuthRecovery.ts

251 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,
};
}