From 3611f5620e013c8b33a6a1ed52be20c7fee3794f Mon Sep 17 00:00:00 2001
From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com>
Date: Thu, 30 Apr 2026 20:33:00 -0700
Subject: [PATCH] Show "Syncing Draft State..." overlay during draft
reconnection (#361)
* 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
* Add team names utility tests
---
app/components/draft/ConnectionOverlay.tsx | 10 +-
app/hooks/useDraftAuthRecovery.ts | 5 +-
app/hooks/useDraftRoomState.ts | 2 +
app/hooks/useDraftSocketEvents.ts | 3 +
.../leagues/$leagueId.draft.$seasonId.tsx | 10 ++
.../__tests__/draft-reconnection-sync.test.ts | 166 ++++++++++++++++++
app/utils/__tests__/team-names.test.ts | 85 +++++++++
7 files changed, 277 insertions(+), 4 deletions(-)
create mode 100644 app/utils/__tests__/team-names.test.ts
diff --git a/app/components/draft/ConnectionOverlay.tsx b/app/components/draft/ConnectionOverlay.tsx
index f1f6916..47f7bda 100644
--- a/app/components/draft/ConnectionOverlay.tsx
+++ b/app/components/draft/ConnectionOverlay.tsx
@@ -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({
{connectionError
? "Connection Error"
+ : isSyncing
+ ? "Syncing Draft State..."
: isReconnecting
? "Reconnecting..."
: "Connecting to Draft"}
- {/* Message */}
{connectionError ? (
connectionError
+ ) : isSyncing ? (
+ "Reconnected. Syncing the latest draft state..."
) : isReconnecting ? (
"Lost connection to the draft server. Attempting to reconnect..."
) : (
diff --git a/app/hooks/useDraftAuthRecovery.ts b/app/hooks/useDraftAuthRecovery.ts
index fcb6988..46dee59 100644
--- a/app/hooks/useDraftAuthRecovery.ts
+++ b/app/hooks/useDraftAuthRecovery.ts
@@ -31,6 +31,7 @@ interface UseDraftAuthRecoveryParams {
setQueue: (queue: QueueItem[]) => void;
setTeamTimers: (fn: (prev: Record) => Record) => void;
setAutodraftStatus: (fn: () => Record) => 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 => {
const response = await fetch(url, init);
diff --git a/app/hooks/useDraftRoomState.ts b/app/hooks/useDraftRoomState.ts
index ff002a1..a370ed3 100644
--- a/app/hooks/useDraftRoomState.ts
+++ b/app/hooks/useDraftRoomState.ts
@@ -136,6 +136,7 @@ export function useDraftRoomState({
const [isOvernightPause, setIsOvernightPause] = useState(false);
const [overnightResumesAt, setOvernightResumesAt] = useState(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,
};
}
diff --git a/app/hooks/useDraftSocketEvents.ts b/app/hooks/useDraftSocketEvents.ts
index 304b8a2..43efa25 100644
--- a/app/hooks/useDraftSocketEvents.ts
+++ b/app/hooks/useDraftSocketEvents.ts
@@ -40,6 +40,7 @@ interface UseDraftSocketEventsParams {
setOvernightResumesAt: (value: Date | null) => void;
setWatchedParticipantIds: (value: Set) => 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[] }) => {
diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx
index 7515047..0b79451 100644
--- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx
+++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx
@@ -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 */}
diff --git a/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts b/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts
index dd5dfa7..743c2a5 100644
--- a/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts
+++ b/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts
@@ -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 | 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);
+ });
+ });
+});
diff --git a/app/utils/__tests__/team-names.test.ts b/app/utils/__tests__/team-names.test.ts
new file mode 100644
index 0000000..157f4a5
--- /dev/null
+++ b/app/utils/__tests__/team-names.test.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect } from "vitest";
+import {
+ prependOwnerToTeamName,
+ stripOwnerFromTeamName,
+} from "~/utils/team-names";
+
+describe("prependOwnerToTeamName", () => {
+ it("prepends username with 's possessive", () => {
+ expect(prependOwnerToTeamName("Kind Cobras", "bob")).toBe(
+ "Bob's Kind Cobras"
+ );
+ });
+
+ it("uses ' possessive when username ends in s", () => {
+ expect(prependOwnerToTeamName("Kind Cobras", "chris")).toBe(
+ "Chris' Kind Cobras"
+ );
+ });
+
+ it("capitalizes first letter of username", () => {
+ expect(prependOwnerToTeamName("Furious Penguins", "christhrowsrock")).toBe(
+ "Christhrowsrock's Furious Penguins"
+ );
+ });
+
+ it("handles username already capitalized", () => {
+ expect(prependOwnerToTeamName("Bold Bears", "Alice")).toBe(
+ "Alice's Bold Bears"
+ );
+ });
+
+ it("handles name ending in multiple s characters", () => {
+ expect(prependOwnerToTeamName("Mighty Tigers", "jess")).toBe(
+ "Jess' Mighty Tigers"
+ );
+ });
+
+ it("handles display name fallback", () => {
+ expect(prependOwnerToTeamName("Wild Wolves", "Member")).toBe(
+ "Member's Wild Wolves"
+ );
+ });
+
+ it("handles single character username", () => {
+ expect(prependOwnerToTeamName("Team 1", "s")).toBe("S's Team 1");
+ });
+
+ it("handles empty base name", () => {
+ expect(prependOwnerToTeamName("", "bob")).toBe("Bob's ");
+ });
+});
+
+describe("stripOwnerFromTeamName", () => {
+ it("strips 's possessive prefix", () => {
+ expect(stripOwnerFromTeamName("Bob's Kind Cobras", "bob")).toBe(
+ "Kind Cobras"
+ );
+ });
+
+ it("strips ' possessive prefix for names ending in s", () => {
+ expect(stripOwnerFromTeamName("Chris' Kind Cobras", "chris")).toBe(
+ "Kind Cobras"
+ );
+ });
+
+ it("returns name unchanged if prefix does not match", () => {
+ expect(
+ stripOwnerFromTeamName("Alice's Awesome Team", "bob")
+ ).toBe("Alice's Awesome Team");
+ });
+
+ it("handles title case mismatch in username", () => {
+ expect(stripOwnerFromTeamName("Bob's Team", "bob")).toBe("Team");
+ });
+
+ it("returns name unchanged if renamed by owner", () => {
+ expect(stripOwnerFromTeamName("Custom Name", "bob")).toBe("Custom Name");
+ });
+
+ it("handles username ending in s matching 's prefix", () => {
+ expect(stripOwnerFromTeamName("Jess' Wild Wolves", "jess")).toBe(
+ "Wild Wolves"
+ );
+ });
+});