Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 249 additions & 0 deletions apps/web/src/hooks/useRealtimeSubscription.ts
Original file line number Diff line number Diff line change
@@ -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<T> {

Check failure on line 17 in apps/web/src/hooks/useRealtimeSubscription.ts

View workflow job for this annotation

GitHub Actions / typescript-checks

'T' is defined but never used
/** 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;

Check failure on line 25 in apps/web/src/hooks/useRealtimeSubscription.ts

View workflow job for this annotation

GitHub Actions / typescript-checks

Unexpected any. Specify a different type
/** 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 = <T = any>({

Check failure on line 68 in apps/web/src/hooks/useRealtimeSubscription.ts

View workflow job for this annotation

GitHub Actions / typescript-checks

Unexpected any. Specify a different type
table,
event,
filter,
onUpdate,
enabled = true,
maxRetries = 10,
initialRetryDelay = 1000,
maxRetryDelay = 30000,
}: UseRealtimeSubscriptionOptions<T>): UseRealtimeSubscriptionResult => {
const [status, setStatus] = useState<ConnectionStatus>("disconnected");
const [retryCount, setRetryCount] = useState(0);
const [error, setError] = useState<string | null>(null);

const channelRef = useRef<RealtimeChannel | null>(null);
const retryTimeoutRef = useRef<NodeJS.Timeout | null>(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,
};
};
123 changes: 75 additions & 48 deletions apps/web/src/pages/SharedShowModePage.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React, { useState, useEffect } from "react";
import { useParams, Link } from "react-router-dom";
import { supabase } from "../lib/supabase";

Check failure on line 3 in apps/web/src/pages/SharedShowModePage.tsx

View workflow job for this annotation

GitHub Actions / typescript-checks

'supabase' is defined but never used
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 => {
Expand Down Expand Up @@ -76,7 +77,7 @@
throw new Error("This share link is not for a Run of Show.");
}
setSharedData(resource as SharedRunOfShowData);
} catch (err: any) {

Check failure on line 80 in apps/web/src/pages/SharedShowModePage.tsx

View workflow job for this annotation

GitHub Actions / typescript-checks

Unexpected any. Specify a different type
console.error("Error fetching shared Run of Show:", err);
setError(err.message || "Failed to load shared Run of Show.");
} finally {
Expand All @@ -86,47 +87,30 @@
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(() => {
Expand Down Expand Up @@ -274,14 +258,57 @@
</div>
</div>

<div className="my-3 flex justify-start items-center gap-x-4 text-xs text-gray-400 bg-gray-800/50 p-2 rounded-md">
<div className="flex items-center">
<span className="h-3 w-3 rounded-full bg-green-500 mr-1.5 border border-green-300 shadow-sm"></span>
<span>Current Cue</span>
<div className="my-3 flex justify-between items-center gap-x-4 text-xs text-gray-400 bg-gray-800/50 p-2 rounded-md">
<div className="flex items-center gap-x-4">
<div className="flex items-center">
<span className="h-3 w-3 rounded-full bg-green-500 mr-1.5 border border-green-300 shadow-sm"></span>
<span>Current Cue</span>
</div>
<div className="flex items-center">
<span className="h-3 w-3 rounded-full bg-orange-500 mr-1.5 border border-orange-300 shadow-sm"></span>
<span>Next Cue</span>
</div>
</div>
<div className="flex items-center">
<span className="h-3 w-3 rounded-full bg-orange-500 mr-1.5 border border-orange-300 shadow-sm"></span>
<span>Next Cue</span>

{/* Connection Status Indicator */}
<div className="flex items-center gap-x-2">
{realtimeStatus === "connected" && (
<div className="flex items-center text-green-400">
<span className="h-2 w-2 rounded-full bg-green-500 mr-1.5 animate-pulse"></span>
<span className="text-xs">Live</span>
</div>
)}
{realtimeStatus === "connecting" && (
<div className="flex items-center text-blue-400">
<Loader className="h-3 w-3 mr-1.5 animate-spin" />
<span className="text-xs">Connecting...</span>
</div>
)}
{realtimeStatus === "reconnecting" && (
<div className="flex items-center text-yellow-400">
<RefreshCw className="h-3 w-3 mr-1.5 animate-spin" />
<span className="text-xs">Reconnecting ({retryCount})...</span>
</div>
)}
{realtimeStatus === "disconnected" && (
<div className="flex items-center text-gray-500">
<WifiOff className="h-3 w-3 mr-1.5" />
<span className="text-xs">Offline</span>
</div>
)}
{realtimeStatus === "error" && (
<div className="flex items-center text-red-400">
<AlertTriangle className="h-3 w-3 mr-1.5" />
<span className="text-xs">Connection failed</span>
<button
onClick={reconnect}
className="ml-2 px-2 py-0.5 bg-red-600 hover:bg-red-700 text-white text-xs rounded transition-colors"
title="Retry connection"
>
Retry
</button>
</div>
)}
</div>
</div>

Expand Down
Loading