diff --git a/lib/hooks/useSubscriptionSync.ts b/lib/hooks/useSubscriptionSync.ts index abc7dbd..5ffd3a7 100644 --- a/lib/hooks/useSubscriptionSync.ts +++ b/lib/hooks/useSubscriptionSync.ts @@ -1,83 +1,96 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef } from 'react'; import { settingsStore } from '@/lib/store/settings-store'; import { fetchSourcesFromUrl, mergeSources } from '@/lib/utils/source-import-utils'; +import type { SourceSubscription } from '@/lib/types'; + +// Minimum time between syncs for the same subscription (5 minutes) +const SYNC_COOLDOWN_MS = 5 * 60 * 1000; export function useSubscriptionSync() { - const [subscriptions, setSubscriptions] = useState(() => settingsStore.getSettings().subscriptions); + // Track if we've already synced during this component lifecycle + const hasSyncedRef = useRef(false); + // Track if sync is currently in progress to avoid concurrent syncs + const isSyncingRef = useRef(false); - // Subscribe to settings changes to detect when subscriptions are updated (e.g. from PasswordGate env sync) + // Effect to run sync only once on mount useEffect(() => { - const unsubscribe = settingsStore.subscribe(() => { - const currentSubs = settingsStore.getSettings().subscriptions; - setSubscriptions(currentSubs); - }); - return () => unsubscribe(); - }, []); + // Prevent multiple syncs if this effect runs multiple times (React StrictMode) + if (hasSyncedRef.current || isSyncingRef.current) return; - // Effect to run the sync when subscriptions change - useEffect(() => { const sync = async () => { - const activeSubscriptions = subscriptions.filter((s: any) => s.autoRefresh !== false); - if (activeSubscriptions.length === 0) return; + // Double-check to prevent race conditions + if (hasSyncedRef.current || isSyncingRef.current) return; - // We need to check if we actually need to sync. - // If we just synced, or if nothing changed, maybe skip? - // For now, let's rely on a simplified approach: - // If the subscription list length/content changes, we might want to re-sync. - // But be careful of infinite loops if we update sources inside this effect. + isSyncingRef.current = true; - const settings = settingsStore.getSettings(); - let anyChanged = false; - let currentSources = [...settings.sources]; - let currentPremiumSources = [...settings.premiumSources]; - // We use a local copy of subscriptions to avoid re-triggering this effect when we update 'lastUpdated' - let updatedSubscriptions = [...subscriptions]; + try { + // Read subscriptions directly from store (not via state to avoid re-renders) + const settings = settingsStore.getSettings(); + const activeSubscriptions = settings.subscriptions.filter((s: SourceSubscription) => s.autoRefresh !== false); - for (let i = 0; i < activeSubscriptions.length; i++) { - const sub = activeSubscriptions[i]; - - // Optional: Check if we synced this recently (e.g. within 5 minutes) to avoid spamming on hot-reload/nav - // const now = Date.now(); - // if (sub.lastUpdated && now - sub.lastUpdated < 5 * 60 * 1000) continue; - - try { - const result = await fetchSourcesFromUrl(sub.url); - - if (result.normalSources.length > 0) { - currentSources = mergeSources(currentSources, result.normalSources); - anyChanged = true; - } - - if (result.premiumSources.length > 0) { - currentPremiumSources = mergeSources(currentPremiumSources, result.premiumSources); - anyChanged = true; - } - - // Update timestamp - const subIdx = updatedSubscriptions.findIndex(s => s.id === sub.id); - if (subIdx !== -1) { - updatedSubscriptions[subIdx] = { - ...updatedSubscriptions[subIdx], - lastUpdated: Date.now() - }; - } - } catch (e) { - console.error(`Failed to sync subscription: ${sub.name}`, e); + if (activeSubscriptions.length === 0) { + hasSyncedRef.current = true; + return; } - } - if (anyChanged) { - settingsStore.saveSettings({ - ...settings, - sources: currentSources, - premiumSources: currentPremiumSources, - subscriptions: updatedSubscriptions - }); + let anyChanged = false; + let currentSources = [...settings.sources]; + let currentPremiumSources = [...settings.premiumSources]; + let updatedSubscriptions = [...settings.subscriptions]; + const now = Date.now(); + + for (let i = 0; i < activeSubscriptions.length; i++) { + const sub = activeSubscriptions[i]; + + // Check if we synced this recently (within cooldown period) to avoid spamming + if (sub.lastUpdated && now - sub.lastUpdated < SYNC_COOLDOWN_MS) { + continue; + } + + try { + const result = await fetchSourcesFromUrl(sub.url); + + if (result.normalSources.length > 0) { + currentSources = mergeSources(currentSources, result.normalSources); + anyChanged = true; + } + + if (result.premiumSources.length > 0) { + currentPremiumSources = mergeSources(currentPremiumSources, result.premiumSources); + anyChanged = true; + } + + // Update timestamp + const subIdx = updatedSubscriptions.findIndex(s => s.id === sub.id); + if (subIdx !== -1) { + updatedSubscriptions[subIdx] = { + ...updatedSubscriptions[subIdx], + lastUpdated: now + }; + anyChanged = true; // Mark changed to save the updated timestamp + } + } catch (e) { + console.error(`Failed to sync subscription: ${sub.name}`, e); + } + } + + if (anyChanged) { + settingsStore.saveSettings({ + ...settings, + sources: currentSources, + premiumSources: currentPremiumSources, + subscriptions: updatedSubscriptions + }); + } + + hasSyncedRef.current = true; + } finally { + isSyncingRef.current = false; } }; - // Debounce slightly to avoid rapid-fire updates if multiple settings change + // Small delay to ensure settings are fully loaded const timeoutId = setTimeout(sync, 1000); return () => clearTimeout(timeoutId); - }, [subscriptions]); // Only re-run if subscriptions array reference changes (which happens on saveSettings) + }, []); // Empty dependency array - only run once on mount }