Files
tech-tracker-next/src/lib/hooks/useStatusSubscription.ts

148 lines
4.4 KiB
TypeScript

'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { createClient } from '@/utils/supabase';
import type { RealtimeChannel } from '@supabase/supabase-js';
export type ConnectionStatus =
| 'connecting'
| 'connected'
| 'disconnected'
| 'updating';
type UseStatusSubscriptionOptions = {
enabled?: boolean;
onStatusUpdate?: () => void;
maxReconnectAttempts?: number;
reconnectDelay?: number;
}
export const useStatusSubscription = ({
enabled = true,
onStatusUpdate,
maxReconnectAttempts = 5,
reconnectDelay = 2000,
}: UseStatusSubscriptionOptions = {}) => {
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>('disconnected');
const channelRef = useRef<RealtimeChannel | null>(null);
const supabaseRef = useRef(createClient());
const reconnectAttemptsRef = useRef(0);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const isComponentMountedRef = useRef(true);
const visibilityTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const cleanup = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = undefined;
}
if (visibilityTimeoutRef.current) {
clearTimeout(visibilityTimeoutRef.current);
visibilityTimeoutRef.current = undefined;
}
if (channelRef.current) {
supabaseRef.current.removeChannel(channelRef.current).catch((error) => {
console.error('❌ cleanup: Error removing channel:', error);
});
channelRef.current = null;
}
}, []);
const connect = useCallback(() => {
if (!enabled || !isComponentMountedRef.current) return;
cleanup();
setConnectionStatus('connecting');
const channel = supabaseRef.current
.channel('status_updates', {
config: { broadcast: {self: true }}
});
channel
.on('broadcast', { event: 'status_updated' }, (payload) => {
onStatusUpdate?.();
})
.subscribe((status) => {
if (!isComponentMountedRef.current) return;
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (status === 'SUBSCRIBED') {
setConnectionStatus('connected');
reconnectAttemptsRef.current = 0;
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
} else if (status === 'CHANNEL_ERROR' || status === 'CLOSED') {
setConnectionStatus('disconnected');
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
reconnectAttemptsRef.current++;
const delay = reconnectDelay * reconnectAttemptsRef.current;
reconnectTimeoutRef.current = setTimeout(() => {
if (isComponentMountedRef.current) connect();
}, delay);
} else {
console.warn('⚠️ connect: Max reconnection attempts reached');
setConnectionStatus('disconnected');
}
}
});
channelRef.current = channel;
}, [enabled, onStatusUpdate, maxReconnectAttempts, reconnectDelay, cleanup]);
const disconnect = useCallback(() => {
cleanup();
setConnectionStatus('disconnected');
}, [cleanup]);
const reconnect = useCallback(() => {
reconnectAttemptsRef.current = 0;
connect();
}, [connect]);
// Handle visibility change for better reconnection
useEffect(() => {
const handleVisibilityChange = () => {
if (!enabled) return;
if (document.visibilityState === 'visible') {
visibilityTimeoutRef.current = setTimeout(() => {
if (connectionStatus === 'disconnected' && isComponentMountedRef.current) {
reconnect();
}
}, 1000);
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [enabled, connectionStatus, reconnect]);
// Initial connection - SIMPLIFIED to avoid dependency issues
useEffect(() => {
if (!enabled) {
disconnect();
return;
}
const initialTimeout = setTimeout(() => {
if (isComponentMountedRef.current) connect();
}, 1000);
return () => {
clearTimeout(initialTimeout);
};
}, [enabled]);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
return {
connectionStatus,
connect: reconnect,
disconnect,
};
};