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
This commit is contained in:
2026-01-31 00:54:55 -06:00
parent 2acf52a63b
commit 5dd35ed215
2 changed files with 30 additions and 6 deletions
@@ -71,12 +71,14 @@ function scheduleTick() {
const MIN_INTERVAL = 5_000;
const MAX_INTERVAL = 60_000;
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
let destroyed = false;
const MIN_SPIN_MS = 700;
let spinnerVisible = $state(false);
let spinHoldTimer: ReturnType<typeof setTimeout> | 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();
}
});
</script>
+15 -4
View File
@@ -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<string | null>(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 =