From 977c101d973e8a649b3a05d24df7aa95771e265f Mon Sep 17 00:00:00 2001 From: cj-vana Date: Fri, 10 Oct 2025 14:49:05 -0400 Subject: [PATCH] fix: add automatic WebSocket reconnection for Run of Show viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves persistent WebSocket connection failures with robust reconnection logic. Changes: - Add useRealtimeSubscription hook with exponential backoff retry strategy - Configurable retry attempts (default: 10) and delays (1s → 30s max) - Proper channel cleanup before reconnect attempts - Jitter to prevent thundering herd problem - Update SharedShowModePage to use new hook instead of manual subscription - Add real-time connection status indicator UI: - Live (green) - Connected and receiving updates - Connecting (blue) - Initial connection attempt - Reconnecting (yellow) - Auto-retry in progress with attempt count - Offline (gray) - Disconnected - Connection failed (red) - Max retries exceeded with manual retry button Before: WebSocket fails → terminal error → manual page refresh required After: WebSocket fails → automatic reconnection with visual feedback 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/web/src/hooks/useRealtimeSubscription.ts | 249 ++++++++++++++++++ apps/web/src/pages/SharedShowModePage.tsx | 123 +++++---- 2 files changed, 324 insertions(+), 48 deletions(-) create mode 100644 apps/web/src/hooks/useRealtimeSubscription.ts diff --git a/apps/web/src/hooks/useRealtimeSubscription.ts b/apps/web/src/hooks/useRealtimeSubscription.ts new file mode 100644 index 0000000..bfa832e --- /dev/null +++ b/apps/web/src/hooks/useRealtimeSubscription.ts @@ -0,0 +1,249 @@ +/** + * React hook for Supabase real-time subscriptions with automatic reconnection. + * Handles network failures, timeouts, and connection errors with exponential backoff. + */ + +import { useEffect, useState, useCallback, useRef } from "react"; +import { supabase } from "@/lib/supabase"; +import type { RealtimeChannel } from "@supabase/supabase-js"; + +export type ConnectionStatus = + | "connecting" + | "connected" + | "reconnecting" + | "disconnected" + | "error"; + +interface UseRealtimeSubscriptionOptions { + /** Table name to subscribe to */ + table: string; + /** Event type (INSERT, UPDATE, DELETE, or *) */ + event: "INSERT" | "UPDATE" | "DELETE" | "*"; + /** Filter expression (e.g., "id=eq.123") */ + filter?: string; + /** Callback when update is received */ + onUpdate: (payload: any) => void; + /** Whether subscription is enabled */ + enabled?: boolean; + /** Maximum retry attempts before giving up (default: 10) */ + maxRetries?: number; + /** Initial retry delay in ms (default: 1000) */ + initialRetryDelay?: number; + /** Maximum retry delay in ms (default: 30000) */ + maxRetryDelay?: number; +} + +interface UseRealtimeSubscriptionResult { + /** Current connection status */ + status: ConnectionStatus; + /** Current retry attempt (0 if connected) */ + retryCount: number; + /** Error message if status is 'error' */ + error: string | null; + /** Manually trigger reconnection */ + reconnect: () => void; +} + +/** + * Hook for Supabase real-time subscriptions with automatic reconnection. + * + * Features: + * - Exponential backoff retry strategy + * - Automatic reconnection on network failures + * - Connection health monitoring + * - Proper cleanup on unmount + * + * @example + * ```tsx + * const { status, retryCount } = useRealtimeSubscription({ + * table: 'run_of_shows', + * event: 'UPDATE', + * filter: `id=eq.${showId}`, + * onUpdate: (payload) => { + * setShowData(payload.new); + * }, + * }); + * ``` + */ +export const useRealtimeSubscription = ({ + table, + event, + filter, + onUpdate, + enabled = true, + maxRetries = 10, + initialRetryDelay = 1000, + maxRetryDelay = 30000, +}: UseRealtimeSubscriptionOptions): UseRealtimeSubscriptionResult => { + const [status, setStatus] = useState("disconnected"); + const [retryCount, setRetryCount] = useState(0); + const [error, setError] = useState(null); + + const channelRef = useRef(null); + const retryTimeoutRef = useRef(null); + const mountedRef = useRef(true); + + /** + * Calculate retry delay with exponential backoff. + */ + const getRetryDelay = useCallback( + (attempt: number): number => { + const delay = Math.min(initialRetryDelay * Math.pow(2, attempt), maxRetryDelay); + // Add jitter to prevent thundering herd + const jitter = Math.random() * 0.3 * delay; + return delay + jitter; + }, + [initialRetryDelay, maxRetryDelay], + ); + + /** + * Cleanup existing channel. + */ + const cleanup = useCallback(async () => { + if (channelRef.current) { + try { + await supabase.removeChannel(channelRef.current); + console.log(`[Realtime] Removed channel for ${table}`); + } catch (err) { + console.error("[Realtime] Error removing channel:", err); + } + channelRef.current = null; + } + + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + }, [table]); + + /** + * Attempt to connect/reconnect to realtime channel. + */ + const connect = useCallback( + async (attempt: number = 0) => { + if (!mountedRef.current) return; + + // Give up after max retries + if (attempt >= maxRetries) { + setStatus("error"); + setError(`Failed to connect after ${maxRetries} attempts. Please refresh the page.`); + console.error(`[Realtime] Max retries (${maxRetries}) exceeded for ${table}`); + return; + } + + // Cleanup any existing channel first + await cleanup(); + + // Update status + if (attempt === 0) { + setStatus("connecting"); + setRetryCount(0); + setError(null); + } else { + setStatus("reconnecting"); + setRetryCount(attempt); + } + + console.log( + `[Realtime] Connecting to ${table} (attempt ${attempt + 1}/${maxRetries})${filter ? ` with filter: ${filter}` : ""}`, + ); + + try { + // Create channel with unique name + const channelName = `public:${table}${filter ? `:${filter}` : ""}:${Date.now()}`; + const channel = supabase.channel(channelName); + + // Setup postgres_changes listener + channel.on( + "postgres_changes", + { + event, + schema: "public", + table, + ...(filter && { filter }), + }, + (payload) => { + if (mountedRef.current) { + console.log(`[Realtime] Received ${event} event:`, payload); + onUpdate(payload); + } + }, + ); + + // Subscribe with status callback + channel.subscribe((subscriptionStatus, err) => { + if (!mountedRef.current) return; + + console.log(`[Realtime] Subscription status: ${subscriptionStatus}`, err || ""); + + if (subscriptionStatus === "SUBSCRIBED") { + setStatus("connected"); + setRetryCount(0); + setError(null); + console.log(`[Realtime] Successfully subscribed to ${table}`); + } else if (subscriptionStatus === "CHANNEL_ERROR") { + console.error(`[Realtime] Channel error for ${table}:`, err); + // Schedule reconnection + const delay = getRetryDelay(attempt); + console.log(`[Realtime] Retrying in ${Math.round(delay / 1000)}s...`); + retryTimeoutRef.current = setTimeout(() => { + connect(attempt + 1); + }, delay); + } else if (subscriptionStatus === "TIMED_OUT") { + console.warn(`[Realtime] Subscription timed out for ${table}`); + // Schedule reconnection + const delay = getRetryDelay(attempt); + console.log(`[Realtime] Retrying in ${Math.round(delay / 1000)}s...`); + retryTimeoutRef.current = setTimeout(() => { + connect(attempt + 1); + }, delay); + } + }); + + channelRef.current = channel; + } catch (err) { + console.error(`[Realtime] Error setting up channel for ${table}:`, err); + // Schedule reconnection + const delay = getRetryDelay(attempt); + console.log(`[Realtime] Retrying in ${Math.round(delay / 1000)}s...`); + retryTimeoutRef.current = setTimeout(() => { + connect(attempt + 1); + }, delay); + } + }, + [table, event, filter, onUpdate, maxRetries, getRetryDelay, cleanup], + ); + + /** + * Manually trigger reconnection. + */ + const reconnect = useCallback(() => { + console.log(`[Realtime] Manual reconnect triggered for ${table}`); + connect(0); + }, [connect, table]); + + /** + * Setup subscription on mount and when dependencies change. + */ + useEffect(() => { + if (!enabled) { + setStatus("disconnected"); + return; + } + + mountedRef.current = true; + connect(0); + + return () => { + mountedRef.current = false; + cleanup(); + }; + }, [enabled, connect, cleanup]); + + return { + status, + retryCount, + error, + reconnect, + }; +}; diff --git a/apps/web/src/pages/SharedShowModePage.tsx b/apps/web/src/pages/SharedShowModePage.tsx index 3b81c0b..6a61b59 100644 --- a/apps/web/src/pages/SharedShowModePage.tsx +++ b/apps/web/src/pages/SharedShowModePage.tsx @@ -4,8 +4,9 @@ import { supabase } from "../lib/supabase"; import { getSharedResource, SharedRunOfShowData } from "../lib/shareUtils"; import Header from "../components/Header"; import Footer from "../components/Footer"; -import { Loader, Clock, AlertTriangle } from "lucide-react"; +import { Loader, Clock, AlertTriangle, WifiOff, RefreshCw } from "lucide-react"; import { RunOfShowItem, CustomColumnDefinition } from "./RunOfShowEditor"; // Assuming these types are exported +import { useRealtimeSubscription } from "../hooks/useRealtimeSubscription"; // Utility functions (can be moved to a shared util file later) const parseDurationToSeconds = (durationStr?: string): number | null => { @@ -86,47 +87,30 @@ const SharedShowModePage: React.FC = () => { fetchSharedData(); }, [shareCode]); - useEffect(() => { - if (!sharedData || !sharedData.id) return; - - const channel = supabase - .channel(`public:run_of_shows:id=eq.${sharedData.id}`) - .on( - "postgres_changes", - { - event: "UPDATE", - schema: "public", - table: "run_of_shows", - filter: `id=eq.${sharedData.id}`, - }, - (payload) => { - console.log("Real-time update received:", payload); - const updatedRunOfShow = payload.new as SharedRunOfShowData; - setSharedData((prevData) => { - if (!prevData) return null; - return { - ...prevData, - live_show_data: updatedRunOfShow.live_show_data, - items: updatedRunOfShow.items || prevData.items, - last_edited: updatedRunOfShow.last_edited || prevData.last_edited, - }; - }); - }, - ) - .subscribe((status, err) => { - if (status === "SUBSCRIBED") { - console.log("Subscribed to real-time updates for Run of Show ID:", sharedData.id); - } - if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") { - console.error("Real-time subscription error:", status, err); - setError("Connection lost. Please refresh to see live updates."); - } + // Real-time subscription with automatic reconnection + const { + status: realtimeStatus, + retryCount, + reconnect, + } = useRealtimeSubscription({ + table: "run_of_shows", + event: "UPDATE", + filter: sharedData?.id ? `id=eq.${sharedData.id}` : undefined, + enabled: !!sharedData?.id, + onUpdate: (payload) => { + console.log("Real-time update received:", payload); + const updatedRunOfShow = payload.new as SharedRunOfShowData; + setSharedData((prevData) => { + if (!prevData) return null; + return { + ...prevData, + live_show_data: updatedRunOfShow.live_show_data, + items: updatedRunOfShow.items || prevData.items, + last_edited: updatedRunOfShow.last_edited || prevData.last_edited, + }; }); - - return () => { - supabase.removeChannel(channel); - }; - }, [sharedData]); + }, + }); // Auto-scroll to current item useEffect(() => { @@ -274,14 +258,57 @@ const SharedShowModePage: React.FC = () => { -
-
- - Current Cue +
+
+
+ + Current Cue +
+
+ + Next Cue +
-
- - Next Cue + + {/* Connection Status Indicator */} +
+ {realtimeStatus === "connected" && ( +
+ + Live +
+ )} + {realtimeStatus === "connecting" && ( +
+ + Connecting... +
+ )} + {realtimeStatus === "reconnecting" && ( +
+ + Reconnecting ({retryCount})... +
+ )} + {realtimeStatus === "disconnected" && ( +
+ + Offline +
+ )} + {realtimeStatus === "error" && ( +
+ + Connection failed + +
+ )}