mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 06:23:37 -06:00
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user