From 5dd35ed215d3d1f3603e67a2aa59eaddf619f5c9 Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 31 Jan 2026 00:54:55 -0600 Subject: [PATCH] fix(web): prevent duplicate searches and background fetching on navigation - Search page no longer triggers cascading search when validating subjects - Scraper page stops all refresh timers and API calls when navigating away - Wrap initial data references in untrack() to silence Svelte warnings --- .../routes/(app)/admin/scraper/+page.svelte | 17 +++++++++++++++-- web/src/routes/+page.svelte | 19 +++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/web/src/routes/(app)/admin/scraper/+page.svelte b/web/src/routes/(app)/admin/scraper/+page.svelte index 318a928..a450ffc 100644 --- a/web/src/routes/(app)/admin/scraper/+page.svelte +++ b/web/src/routes/(app)/admin/scraper/+page.svelte @@ -71,12 +71,14 @@ function scheduleTick() { const MIN_INTERVAL = 5_000; const MAX_INTERVAL = 60_000; let refreshTimer: ReturnType | undefined; +let destroyed = false; const MIN_SPIN_MS = 700; let spinnerVisible = $state(false); let spinHoldTimer: ReturnType | undefined; async function fetchAll() { + if (destroyed) return; refreshError = false; spinnerVisible = true; clearTimeout(spinHoldTimer); @@ -88,16 +90,19 @@ async function fetchAll() { client.getScraperTimeseries(selectedPeriod), client.getScraperSubjects(), ]); + if (destroyed) return; stats = statsRes; timeseries = timeseriesRes; subjects = subjectsRes.subjects; error = null; refreshInterval = MIN_INTERVAL; } catch (e) { + if (destroyed) return; error = e instanceof Error ? e.message : "Failed to load scraper data"; refreshError = true; refreshInterval = Math.min(refreshInterval * 2, MAX_INTERVAL); } finally { + if (destroyed) return; const elapsed = performance.now() - startedAt; const remaining = MIN_SPIN_MS - elapsed; if (remaining > 0) { @@ -112,6 +117,7 @@ async function fetchAll() { } function scheduleRefresh() { + if (destroyed) return; clearTimeout(refreshTimer); refreshTimer = setTimeout(fetchAll, refreshInterval); } @@ -302,20 +308,27 @@ const detailGridCols = "grid-cols-[7fr_5fr_3fr_4fr_4fr_3fr_4fr_minmax(6rem,1fr)] // --- Lifecycle --- onMount(() => { + destroyed = false; + mounted = true; fetchAll(); scheduleTick(); }); onDestroy(() => { + destroyed = true; + mounted = false; clearTimeout(tickTimer); clearTimeout(refreshTimer); clearTimeout(spinHoldTimer); }); -// Refetch when period changes +// Refetch when period changes (skip initial run since onMount handles it) +let mounted = false; $effect(() => { void selectedPeriod; - fetchAll(); + if (mounted && !destroyed) { + fetchAll(); + } }); diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 5185413..a452f9b 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -21,12 +21,12 @@ let { data } = $props(); const initialParams = untrack(() => new URLSearchParams(data.url.search)); // The default term is the first one returned by the backend (most current) -const defaultTermSlug = data.terms[0]?.slug ?? ""; +const defaultTermSlug = untrack(() => data.terms[0]?.slug ?? ""); // Default to the first term when no URL param is present const urlTerm = initialParams.get("term"); let selectedTerm = $state( - urlTerm && data.terms.some((t) => t.slug === urlTerm) ? urlTerm : defaultTermSlug + untrack(() => (urlTerm && data.terms.some((t) => t.slug === urlTerm) ? urlTerm : defaultTermSlug)) ); let selectedSubjects: string[] = $state(untrack(() => initialParams.getAll("subject"))); let query = $state(initialParams.get("q") ?? ""); @@ -67,6 +67,9 @@ let searchMeta: SearchMeta | null = $state(null); let loading = $state(false); let error = $state(null); +// Track if we're validating subjects to prevent cascading search +let validatingSubjects = false; + // Fetch subjects when term changes $effect(() => { const term = selectedTerm; @@ -76,7 +79,12 @@ $effect(() => { .then((s) => { subjects = s; const validCodes = new Set(s.map((sub) => sub.code)); - selectedSubjects = selectedSubjects.filter((code) => validCodes.has(code)); + const filtered = selectedSubjects.filter((code) => validCodes.has(code)); + if (filtered.length !== selectedSubjects.length) { + validatingSubjects = true; + selectedSubjects = filtered; + validatingSubjects = false; + } }) .catch((e) => { console.error("Failed to fetch subjects:", e); @@ -111,7 +119,9 @@ $effect(() => { $effect(() => { selectedSubjects; - scheduleSearch("subjects"); + if (!validatingSubjects) { + scheduleSearch("subjects"); + } return () => clearTimeout(searchTimeout); }); @@ -160,6 +170,7 @@ async function performSearch( if (!term) return; loading = true; error = null; + searchMeta = null; const sortBy = sort.length > 0 ? SORT_COLUMN_MAP[sort[0].id] : undefined; const sortDir: SortDirection | undefined =