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
221 lines
7.5 KiB
TypeScript
221 lines
7.5 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,
|
|
});
|
|
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]);
|
|
|
|
// 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 () => {
|
|
const { data: session } = await authClient.getSession();
|
|
if (!session) setAuthDegraded(true);
|
|
}, 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;
|
|
});
|
|
setIsSyncing(false);
|
|
}
|
|
}, [revalidatorState, draftPicks, season, userQueue, timers, autodraftSettings, currentUserId,
|
|
setPicks, setCurrentPick, setIsPaused, setIsDraftComplete, setQueue, setTeamTimers, setAutodraftStatus, 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,
|
|
userAutodraftRef,
|
|
isRevalidatingRef,
|
|
pendingPicksDuringRevalidationRef,
|
|
pendingQueueMutationsRef,
|
|
};
|
|
}
|