"use client"; import { useEffect, useRef, useState } from "react"; import type { RealtimeChannel, RealtimePostgresChangesPayload } from "@supabase/supabase-js"; import { getSupabaseBrowserClient } from "@/lib/supabase/client"; import { type ConnectionStatus, ReconnectionManager, cleanupChannel, } from "@/lib/realtime/subscription-manager"; interface PostgresChangesConfig> { event: "INSERT" | "UPDATE" | "DELETE" | "*"; schema: string; table: string; filter?: string; onPayload: (payload: RealtimePostgresChangesPayload) => void; } interface UseRealtimeChannelOptions> { /** Unique channel name. Changing this unsubscribes from the old channel and subscribes to the new one. */ channelName: string | null; /** Postgres changes subscription config. */ config: PostgresChangesConfig; /** Whether the subscription is enabled. Defaults to true. */ enabled?: boolean; } interface UseRealtimeChannelReturn { status: ConnectionStatus; } /** * Generic hook for Supabase Realtime channel management. * Handles subscribe/unsubscribe lifecycle, connection state tracking, * and reconnection with exponential backoff. */ export function useRealtimeChannel>({ channelName, config, enabled = true, }: UseRealtimeChannelOptions): UseRealtimeChannelReturn { const [status, setStatus] = useState("disconnected"); const channelRef = useRef(null); const reconnectRef = useRef(new ReconnectionManager()); const onPayloadRef = useRef(config.onPayload); const configRef = useRef(config); // Sync refs inside an effect to satisfy react-hooks/refs (React 19 strict mode) useEffect(() => { onPayloadRef.current = config.onPayload; configRef.current = config; }); // Reset status when subscription is disabled const isActive = !!channelName && enabled; if (!isActive && status !== "disconnected") { setStatus("disconnected"); } useEffect(() => { if (!channelName || !enabled) { if (channelRef.current) { const supabase = getSupabaseBrowserClient(); cleanupChannel(channelRef.current, (ch) => supabase.removeChannel(ch)); channelRef.current = null; } return; } const reconnect = reconnectRef.current; // Capture non-null value for use in the subscribe closure const activeChannelName = channelName; function subscribe(): void { const supabase = getSupabaseBrowserClient(); const cfg = configRef.current; if (channelRef.current) { cleanupChannel(channelRef.current, (ch) => supabase.removeChannel(ch)); channelRef.current = null; } setStatus("connecting"); const channel = supabase .channel(activeChannelName) .on( "postgres_changes", { event: cfg.event, schema: cfg.schema, table: cfg.table, ...(cfg.filter ? { filter: cfg.filter } : {}), }, (payload) => { onPayloadRef.current(payload); }, ) .subscribe((subscribedStatus) => { if (subscribedStatus === "SUBSCRIBED") { setStatus("connected"); reconnect.reset(); } else if ( subscribedStatus === "CHANNEL_ERROR" || subscribedStatus === "TIMED_OUT" ) { setStatus("error"); reconnect.schedule(() => subscribe()); } else if (subscribedStatus === "CLOSED") { setStatus("disconnected"); } }); channelRef.current = channel; } subscribe(); return () => { reconnect.clear(); if (channelRef.current) { const supabase = getSupabaseBrowserClient(); cleanupChannel(channelRef.current, (ch) => supabase.removeChannel(ch)); channelRef.current = null; } setStatus("disconnected"); }; }, [channelName, enabled]); return { status }; }