All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 1m36s
🚀 Deploy / ʦ TypeScript (pull_request) Successful in 1m21s
🚀 Deploy / 🔍 Lint (pull_request) Successful in 50s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
The 15-minute keep-alive interval in useDraftAuthRecovery called
authClient.getSession() with no try-catch. When the network is
unavailable (e.g. computer waking from sleep), better-fetch throws a
TypeError rather than returning an in-band error, producing an
unhandled promise rejection that was surfacing in Sentry.
Adds a try-catch that silently swallows network TypeErrors — real
session expiry (401) is still caught by the existing !session check
because better-fetch returns HTTP errors in-band as { data: null }.
Adds tests covering the three ping outcomes: network throw, null
session (expired), and valid session.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
232 lines
8 KiB
TypeScript
232 lines
8 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { authClient } from "~/lib/auth-client";
|
|
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";
|
|
currentUserId: string | null | undefined;
|
|
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;
|
|
setIsSyncing: (value: boolean) => void;
|
|
}
|
|
|
|
|
|
export function useDraftAuthRecovery({
|
|
reconnectCount,
|
|
revalidate,
|
|
revalidatorState,
|
|
currentUserId,
|
|
userAutodraftSettings,
|
|
draftPicks,
|
|
season,
|
|
userQueue,
|
|
timers,
|
|
autodraftSettings,
|
|
setPicks,
|
|
setCurrentPick,
|
|
setIsPaused,
|
|
setIsDraftComplete,
|
|
setQueue,
|
|
setTeamTimers,
|
|
setAutodraftStatus,
|
|
setIsSyncing,
|
|
}: 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,
|
|
});
|
|
// 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]);
|
|
|
|
// Re-check session when the tab becomes visible — cookie-based sessions don't
|
|
// need manual token refresh, but a long-idle tab may have an expired session.
|
|
useEffect(() => {
|
|
if (authDegraded) return;
|
|
|
|
let aborted = false;
|
|
let inFlight = false;
|
|
|
|
const handleVisibilityChange = async () => {
|
|
if (document.visibilityState !== "visible" || inFlight) return;
|
|
inFlight = true;
|
|
try {
|
|
const { data: session } = await authClient.getSession();
|
|
if (aborted) return;
|
|
if (session) {
|
|
if (!currentUserId) revalidate();
|
|
} else {
|
|
setAuthDegraded(true);
|
|
}
|
|
} catch {
|
|
if (!aborted) setAuthDegraded(true);
|
|
} finally {
|
|
inFlight = false;
|
|
}
|
|
};
|
|
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
return () => {
|
|
aborted = true;
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
};
|
|
}, [currentUserId, authDegraded, revalidate]);
|
|
|
|
// Periodically ping the session endpoint to keep it alive during long drafts.
|
|
useEffect(() => {
|
|
if (authDegraded) return;
|
|
const FIFTEEN_MINUTES = 15 * 60 * 1000;
|
|
const id = setInterval(async () => {
|
|
try {
|
|
const { data: session } = await authClient.getSession();
|
|
if (!session) setAuthDegraded(true);
|
|
} catch {
|
|
// Network failures throw a TypeError from fetch(), but real session
|
|
// expiry (401) is returned in-band as { data: null } by better-fetch
|
|
// and caught by the !session check above. Swallowing the throw here
|
|
// means a transient network drop doesn't trigger the auth overlay
|
|
// mid-draft.
|
|
}
|
|
}, FIFTEEN_MINUTES);
|
|
return () => clearInterval(id);
|
|
}, [authDegraded]);
|
|
|
|
// 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;
|
|
});
|
|
|
|
if (userAutodraftSettings) {
|
|
setUserAutodraft({
|
|
isEnabled: userAutodraftSettings.isEnabled,
|
|
mode: userAutodraftSettings.mode,
|
|
queueOnly: userAutodraftSettings.queueOnly,
|
|
});
|
|
}
|
|
|
|
setIsSyncing(false);
|
|
}
|
|
}, [revalidatorState, draftPicks, season, userQueue, timers, autodraftSettings, currentUserId, userAutodraftSettings,
|
|
setPicks, setCurrentPick, setIsPaused, setIsDraftComplete, setQueue, setTeamTimers, setAutodraftStatus, setUserAutodraft, setIsSyncing]);
|
|
|
|
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,
|
|
isRevalidatingRef,
|
|
pendingPicksDuringRevalidationRef,
|
|
pendingQueueMutationsRef,
|
|
};
|
|
}
|