From dfdeef04a7617652ebb94bce1f09c05f6df8af93 Mon Sep 17 00:00:00 2001 From: Troray Date: Wed, 14 Jan 2026 01:43:32 +0800 Subject: [PATCH 1/2] fix: stop infinite requests after adding new subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a new subscription triggered an infinite loop of JSON fetches because useSubscriptionSync was subscribing to settingsStore changes, which caused a cycle: store change → state update → sync → save → store change. - Remove settingsStore subscription to break the infinite loop - Add refs to ensure sync runs only once per component mount - Enable 5-minute cooldown to prevent redundant syncs --- lib/hooks/useSubscriptionSync.ts | 141 +++++++++++++++++-------------- 1 file changed, 77 insertions(+), 64 deletions(-) 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 } From 430336c4c1838687b1c71e799458d9375c8e9393 Mon Sep 17 00:00:00 2001 From: Troray Date: Wed, 14 Jan 2026 02:32:55 +0800 Subject: [PATCH 2/2] Update useSubscriptionSync.ts --- lib/hooks/useSubscriptionSync.ts | 48 ++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/lib/hooks/useSubscriptionSync.ts b/lib/hooks/useSubscriptionSync.ts index 5ffd3a7..731765c 100644 --- a/lib/hooks/useSubscriptionSync.ts +++ b/lib/hooks/useSubscriptionSync.ts @@ -5,6 +5,8 @@ import type { SourceSubscription } from '@/lib/types'; // Minimum time between syncs for the same subscription (5 minutes) const SYNC_COOLDOWN_MS = 5 * 60 * 1000; +// Delay before initial sync to ensure settings are fully loaded +const INITIAL_SYNC_DELAY_MS = 1000; export function useSubscriptionSync() { // Track if we've already synced during this component lifecycle @@ -39,40 +41,50 @@ export function useSubscriptionSync() { let updatedSubscriptions = [...settings.subscriptions]; const now = Date.now(); - for (let i = 0; i < activeSubscriptions.length; i++) { - const sub = activeSubscriptions[i]; + // Filter out subscriptions that were synced recently (within cooldown period) + const subsToSync = activeSubscriptions.filter( + (sub: SourceSubscription) => !(sub.lastUpdated && now - sub.lastUpdated < SYNC_COOLDOWN_MS) + ); - // Check if we synced this recently (within cooldown period) to avoid spamming - if (sub.lastUpdated && now - sub.lastUpdated < SYNC_COOLDOWN_MS) { - continue; - } + if (subsToSync.length === 0) { + hasSyncedRef.current = true; + return; + } - try { - const result = await fetchSourcesFromUrl(sub.url); + // Fetch all subscriptions in parallel for better performance + const results = await Promise.allSettled( + subsToSync.map((sub: SourceSubscription) => fetchSourcesFromUrl(sub.url)) + ); - if (result.normalSources.length > 0) { - currentSources = mergeSources(currentSources, result.normalSources); + // Process results + results.forEach((result, index) => { + const sub = subsToSync[index]; + if (result.status === 'fulfilled') { + const fetchResult = result.value; + + if (fetchResult.normalSources.length > 0) { + currentSources = mergeSources(currentSources, fetchResult.normalSources); anyChanged = true; } - if (result.premiumSources.length > 0) { - currentPremiumSources = mergeSources(currentPremiumSources, result.premiumSources); + if (fetchResult.premiumSources.length > 0) { + currentPremiumSources = mergeSources(currentPremiumSources, fetchResult.premiumSources); anyChanged = true; } - // Update timestamp + // Update timestamp for successful sync 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 + anyChanged = true; } - } catch (e) { - console.error(`Failed to sync subscription: ${sub.name}`, e); + } else { + console.error(`Failed to sync subscription: ${sub.name}`, result.reason); } - } + }); if (anyChanged) { settingsStore.saveSettings({ @@ -90,7 +102,7 @@ export function useSubscriptionSync() { }; // Small delay to ensure settings are fully loaded - const timeoutId = setTimeout(sync, 1000); + const timeoutId = setTimeout(sync, INITIAL_SYNC_DELAY_MS); return () => clearTimeout(timeoutId); }, []); // Empty dependency array - only run once on mount }