fix: stop infinite requests after adding new subscription

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
This commit is contained in:
Troray
2026-01-14 01:43:32 +08:00
parent 04aa734ff8
commit dfdeef04a7
+77 -64
View File
@@ -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
}