Show "Syncing Draft State..." overlay during draft reconnection
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
This commit is contained in:
parent
d0998458c9
commit
5a455e6cae
6 changed files with 192 additions and 4 deletions
|
|
@ -5,15 +5,16 @@ interface ConnectionOverlayProps {
|
|||
isConnected: boolean;
|
||||
isReconnecting: boolean;
|
||||
connectionError: string | null;
|
||||
isSyncing: boolean;
|
||||
}
|
||||
|
||||
export function ConnectionOverlay({
|
||||
isConnected,
|
||||
isReconnecting,
|
||||
connectionError,
|
||||
isSyncing,
|
||||
}: ConnectionOverlayProps) {
|
||||
// Don't show overlay if connected
|
||||
if (isConnected) {
|
||||
if (isConnected && !isSyncing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -51,15 +52,18 @@ export function ConnectionOverlay({
|
|||
<h2 className="text-2xl font-bold mb-2">
|
||||
{connectionError
|
||||
? "Connection Error"
|
||||
: isSyncing
|
||||
? "Syncing Draft State..."
|
||||
: isReconnecting
|
||||
? "Reconnecting..."
|
||||
: "Connecting to Draft"}
|
||||
</h2>
|
||||
|
||||
{/* Message */}
|
||||
<p className="text-muted-foreground">
|
||||
{connectionError ? (
|
||||
connectionError
|
||||
) : isSyncing ? (
|
||||
"Reconnected. Syncing the latest draft state..."
|
||||
) : isReconnecting ? (
|
||||
"Lost connection to the draft server. Attempting to reconnect..."
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ interface UseDraftAuthRecoveryParams {
|
|||
setQueue: (queue: QueueItem[]) => void;
|
||||
setTeamTimers: (fn: (prev: Record<string, number>) => Record<string, number>) => void;
|
||||
setAutodraftStatus: (fn: () => Record<string, AutodraftStatusEntry>) => void;
|
||||
setIsSyncing: (value: boolean) => void;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -52,6 +53,7 @@ export function useDraftAuthRecovery({
|
|||
setQueue,
|
||||
setTeamTimers,
|
||||
setAutodraftStatus,
|
||||
setIsSyncing,
|
||||
}: UseDraftAuthRecoveryParams) {
|
||||
const [authDegraded, setAuthDegraded] = useState(false);
|
||||
|
||||
|
|
@ -192,9 +194,10 @@ export function useDraftAuthRecovery({
|
|||
});
|
||||
return status;
|
||||
});
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}, [revalidatorState, draftPicks, season, userQueue, timers, autodraftSettings, currentUserId,
|
||||
setPicks, setCurrentPick, setIsPaused, setIsDraftComplete, setQueue, setTeamTimers, setAutodraftStatus]);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ export function useDraftRoomState({
|
|||
const [isOvernightPause, setIsOvernightPause] = useState(false);
|
||||
const [overnightResumesAt, setOvernightResumesAt] = useState<Date | null>(null);
|
||||
const [roomClosed, setRoomClosed] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
return {
|
||||
picks, setPicks,
|
||||
|
|
@ -177,5 +178,6 @@ export function useDraftRoomState({
|
|||
isOvernightPause, setIsOvernightPause,
|
||||
overnightResumesAt, setOvernightResumesAt,
|
||||
roomClosed, setRoomClosed,
|
||||
isSyncing, setIsSyncing,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ interface UseDraftSocketEventsParams {
|
|||
setOvernightResumesAt: (value: Date | null) => void;
|
||||
setWatchedParticipantIds: (value: Set<string>) => void;
|
||||
setRoomClosed: (value: boolean) => void;
|
||||
setIsSyncing: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function useDraftSocketEvents({
|
||||
|
|
@ -68,6 +69,7 @@ export function useDraftSocketEvents({
|
|||
setOvernightResumesAt,
|
||||
setWatchedParticipantIds,
|
||||
setRoomClosed,
|
||||
setIsSyncing,
|
||||
}: UseDraftSocketEventsParams) {
|
||||
useEffect(() => {
|
||||
type PickMadePayload = {
|
||||
|
|
@ -225,6 +227,7 @@ export function useDraftSocketEvents({
|
|||
setWatchedParticipantIds(new Set(data.watchlistParticipantIds));
|
||||
}
|
||||
}
|
||||
setIsSyncing(false);
|
||||
};
|
||||
|
||||
const handleWatchlistUpdated = (data: { participantIds: string[] }) => {
|
||||
|
|
|
|||
|
|
@ -254,6 +254,7 @@ export default function DraftRoom() {
|
|||
isOvernightPause, setIsOvernightPause,
|
||||
overnightResumesAt, setOvernightResumesAt,
|
||||
roomClosed, setRoomClosed,
|
||||
isSyncing, setIsSyncing,
|
||||
} = useDraftRoomState({
|
||||
draftPicks,
|
||||
season,
|
||||
|
|
@ -335,8 +336,15 @@ export default function DraftRoom() {
|
|||
setQueue,
|
||||
setTeamTimers,
|
||||
setAutodraftStatus,
|
||||
setIsSyncing,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (reconnectCount <= 0) return;
|
||||
setIsSyncing(true);
|
||||
const id = setTimeout(() => setIsSyncing(false), 5000);
|
||||
return () => clearTimeout(id);
|
||||
}, [reconnectCount, setIsSyncing]);
|
||||
|
||||
// Shared transforms for eligibility calculations
|
||||
const transformedPicks = useMemo(
|
||||
|
|
@ -463,6 +471,7 @@ export default function DraftRoom() {
|
|||
setOvernightResumesAt,
|
||||
setWatchedParticipantIds,
|
||||
setRoomClosed,
|
||||
setIsSyncing,
|
||||
});
|
||||
|
||||
// Persist sidebar collapsed state
|
||||
|
|
@ -1660,6 +1669,7 @@ export default function DraftRoom() {
|
|||
isConnected={isConnected}
|
||||
isReconnecting={isReconnecting}
|
||||
connectionError={connectionError}
|
||||
isSyncing={isSyncing}
|
||||
/>
|
||||
|
||||
{/* Auth Recovery Overlay - blocks interaction when session expires */}
|
||||
|
|
|
|||
|
|
@ -760,3 +760,169 @@ describe("autodraft and timer sync on revalidation completion", () => {
|
|||
expect(teamTimers["team-3"]).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. isSyncing state — reconnect sync indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("isSyncing – reconnect sync indicator", () => {
|
||||
it("sets isSyncing true when reconnectCount increments", () => {
|
||||
let isSyncing = false;
|
||||
const setIsSyncing = (value: boolean) => { isSyncing = value; };
|
||||
const reconnectCount = 1;
|
||||
|
||||
if (reconnectCount > 0) {
|
||||
setIsSyncing(true);
|
||||
}
|
||||
|
||||
expect(isSyncing).toBe(true);
|
||||
});
|
||||
|
||||
it("does not set isSyncing on initial load (reconnectCount = 0)", () => {
|
||||
let isSyncing = false;
|
||||
const setIsSyncing = (value: boolean) => { isSyncing = value; };
|
||||
const reconnectCount = 0;
|
||||
|
||||
if (reconnectCount > 0) {
|
||||
setIsSyncing(true);
|
||||
}
|
||||
|
||||
expect(isSyncing).toBe(false);
|
||||
});
|
||||
|
||||
it("clears isSyncing when draft-state-sync arrives (socket path)", () => {
|
||||
let isSyncing = true;
|
||||
const setIsSyncing = (value: boolean) => { isSyncing = value; };
|
||||
const isRevalidatingRef = { current: false };
|
||||
let picks: any[] = [];
|
||||
const setPicks = (fn: (prev: any[]) => any[]) => { picks = fn(picks); };
|
||||
|
||||
const handleDraftStateSync = (data: any) => {
|
||||
if (!isRevalidatingRef.current) {
|
||||
setPicks(() => data.picks);
|
||||
setIsSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleDraftStateSync({
|
||||
currentPickNumber: 7,
|
||||
isPaused: false,
|
||||
status: "draft",
|
||||
picks: [makePick(1, "team-1", "player-1")],
|
||||
timers: [],
|
||||
});
|
||||
|
||||
expect(isSyncing).toBe(false);
|
||||
});
|
||||
|
||||
it("clears isSyncing even when draft-state-sync is skipped due to revalidation", () => {
|
||||
let isSyncing = true;
|
||||
const setIsSyncing = (value: boolean) => { isSyncing = value; };
|
||||
const isRevalidatingRef = { current: true };
|
||||
|
||||
const handleDraftStateSync = () => {
|
||||
if (!isRevalidatingRef.current) {
|
||||
// state updates skipped
|
||||
}
|
||||
setIsSyncing(false);
|
||||
};
|
||||
|
||||
handleDraftStateSync();
|
||||
|
||||
expect(isSyncing).toBe(false);
|
||||
});
|
||||
|
||||
it("clears isSyncing when revalidation completes (HTTP path)", () => {
|
||||
let isSyncing = true;
|
||||
const setIsSyncing = (value: boolean) => { isSyncing = value; };
|
||||
|
||||
const stalePicks = [makePick(1, "team-1", "player-1")];
|
||||
const freshPicks = [
|
||||
makePick(1, "team-1", "player-1"),
|
||||
makePick(2, "team-2", "player-2"),
|
||||
];
|
||||
|
||||
if (freshPicks !== stalePicks) {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
|
||||
expect(isSyncing).toBe(false);
|
||||
});
|
||||
|
||||
it("does not clear isSyncing when revalidation fails (staleness guard)", () => {
|
||||
let isSyncing = true;
|
||||
const setIsSyncing = (value: boolean) => { isSyncing = value; };
|
||||
|
||||
const stalePicks = [makePick(1, "team-1", "player-1")];
|
||||
const sameReference = stalePicks;
|
||||
|
||||
if (sameReference !== stalePicks) {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
|
||||
expect(isSyncing).toBe(true);
|
||||
});
|
||||
|
||||
describe("safety timeout", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it("clears isSyncing after 5 seconds even if sync never arrives", () => {
|
||||
let isSyncing = false;
|
||||
const setIsSyncing = (value: boolean) => { isSyncing = value; };
|
||||
|
||||
setIsSyncing(true);
|
||||
const id = setTimeout(() => setIsSyncing(false), 5000);
|
||||
|
||||
expect(isSyncing).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(4999);
|
||||
expect(isSyncing).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(isSyncing).toBe(false);
|
||||
|
||||
clearTimeout(id);
|
||||
});
|
||||
|
||||
it("resets the safety timeout when reconnectCount increments rapidly", () => {
|
||||
let isSyncing = false;
|
||||
const setIsSyncing = (value: boolean) => { isSyncing = value; };
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const simulateReconnect = () => {
|
||||
setIsSyncing(true);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => setIsSyncing(false), 5000);
|
||||
};
|
||||
|
||||
simulateReconnect();
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(isSyncing).toBe(true);
|
||||
|
||||
simulateReconnect();
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(isSyncing).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(isSyncing).toBe(false);
|
||||
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
});
|
||||
|
||||
it("stays false when both socket and HTTP paths clear isSyncing (idempotency)", () => {
|
||||
let isSyncing = false;
|
||||
const setIsSyncing = (value: boolean) => { isSyncing = value; };
|
||||
|
||||
setIsSyncing(true);
|
||||
expect(isSyncing).toBe(true);
|
||||
|
||||
setIsSyncing(false);
|
||||
expect(isSyncing).toBe(false);
|
||||
|
||||
setIsSyncing(false);
|
||||
expect(isSyncing).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue