| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133 |
- "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<T extends Record<string, unknown>> {
- event: "INSERT" | "UPDATE" | "DELETE" | "*";
- schema: string;
- table: string;
- filter?: string;
- onPayload: (payload: RealtimePostgresChangesPayload<T>) => void;
- }
- interface UseRealtimeChannelOptions<T extends Record<string, unknown>> {
- /** 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<T>;
- /** 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<T extends Record<string, unknown>>({
- channelName,
- config,
- enabled = true,
- }: UseRealtimeChannelOptions<T>): UseRealtimeChannelReturn {
- const [status, setStatus] = useState<ConnectionStatus>("disconnected");
- const channelRef = useRef<RealtimeChannel | null>(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<T>(
- "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 };
- }
|