/** * Hook for Supabase Realtime Downloads * Subscribes to downloads table changes and updates UI in realtime * * PERFORMANCE: Events are throttled/batched to prevent excessive re-renders * - INSERT: Called immediately (low frequency) * - UPDATE: Batched in 500ms windows (high frequency during downloads) * - DELETE: Called immediately (low frequency) */ import { useEffect, useCallback, useRef } from 'react'; import { RealtimeChannel } from '@supabase/supabase-js'; import { supabase } from './supabase'; // Throttle interval for UPDATE events (ms) const UPDATE_THROTTLE_MS = 500; export interface DownloadRecord { id: number; batch_id: string; ge_id: string; lang: string; file_name: string; file_path: string; mode: 'api' | 'sharing'; status: 'pending' | 'downloading' | 'completed' | 'failed' | 'cancelled'; destination_path: string | null; file_size: number | null; downloaded_size: number | null; progress_percent: number | null; created_at: string | null; started_at: string | null; completed_at: string | null; error_message: string | null; retry_count: number | null; sharing_id: string | null; mongodb_path: string | null; aria2_gid: string | null; } interface UseRealtimeDownloadsOptions { onInsert?: (record: DownloadRecord) => void; onUpdate?: (record: DownloadRecord) => void; onDelete?: (record: DownloadRecord) => void; /** Optional: filter by batch_id */ batchId?: string; } /** * Subscribe to realtime changes on downloads table * UPDATE events are throttled to reduce re-renders during active downloads */ export function useRealtimeDownloads(options: UseRealtimeDownloadsOptions = {}) { const { onInsert, onUpdate, onDelete, batchId } = options; const channelRef = useRef(null); // Throttle state for UPDATE events const pendingUpdatesRef = useRef>(new Map()); const flushTimeoutRef = useRef(null); const onUpdateRef = useRef(onUpdate); // Keep onUpdate ref current useEffect(() => { onUpdateRef.current = onUpdate; }, [onUpdate]); // Flush all pending updates const flushUpdates = useCallback(() => { if (pendingUpdatesRef.current.size > 0) { // Call onUpdate for each pending record pendingUpdatesRef.current.forEach(record => { onUpdateRef.current?.(record); }); pendingUpdatesRef.current.clear(); } flushTimeoutRef.current = null; }, []); // Queue an update (throttled) const queueUpdate = useCallback((record: DownloadRecord) => { // Store latest update for this record (overwrites previous if exists) pendingUpdatesRef.current.set(record.id, record); // Schedule flush if not already scheduled if (!flushTimeoutRef.current) { flushTimeoutRef.current = setTimeout(flushUpdates, UPDATE_THROTTLE_MS); } }, [flushUpdates]); const subscribe = useCallback(() => { // Unsubscribe existing channel if (channelRef.current) { channelRef.current.unsubscribe(); } // Create filter if batchId provided const filter = batchId ? `batch_id=eq.${batchId}` : undefined; const channel = supabase .channel('downloads-changes') .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'downloads', filter, }, (payload) => { onInsert?.(payload.new as DownloadRecord); } ) .on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'downloads', filter, }, (payload) => { // Throttle UPDATE events - they happen frequently during downloads queueUpdate(payload.new as DownloadRecord); } ) .on( 'postgres_changes', { event: 'DELETE', schema: 'public', table: 'downloads', filter, }, (payload) => { onDelete?.(payload.old as DownloadRecord); } ) .subscribe(); channelRef.current = channel; return () => { channel.unsubscribe(); }; }, [onInsert, onDelete, batchId, queueUpdate]); // Removed onUpdate - now using ref useEffect(() => { const cleanup = subscribe(); return () => { cleanup(); // Clear any pending throttle timeout on unmount if (flushTimeoutRef.current) { clearTimeout(flushTimeoutRef.current); flushTimeoutRef.current = null; } // Flush any remaining updates pendingUpdatesRef.current.clear(); }; }, [subscribe]); return { /** Manually resubscribe */ resubscribe: subscribe, }; }