soulseek: revalidate cached search results instead of freezing them
The results cache was write-once, read-forever: opening a search before its first responses landed cached the empty grouping, and every later visit short-circuited on it — the history row showed results while the detail view stayed empty, permanently. Serve the cache as a first paint only and always re-fetch behind it, poll while the search is still running so responses fill in live, and add a manual refresh. Collapse state now survives a revalidation (only newly arrived users auto-expand), the empty state distinguishes "still searching" from "no results", and deleting a search drops its cache entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { toast } from 'sonner';
|
||||
import { ArrowLeft, Download, Lock, Loader2, Folder, Zap, Users, Search, X } from 'lucide-react';
|
||||
import { ArrowLeft, Download, Lock, Loader2, Folder, Zap, Users, Search, X, RefreshCw } from 'lucide-react';
|
||||
import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList, Pill } from './Cards';
|
||||
import {
|
||||
SLSKD_REFRESH_CHANNEL,
|
||||
@@ -21,12 +21,21 @@ import {
|
||||
// The results subpanel for one past search — loads its STORED responses (GET /searches/{id}/responses),
|
||||
// never re-runs the search. Presented the way slskd does: grouped by responder (user) → folder → files,
|
||||
// each user a collapsible card with its upload speed / free-slot / queue / file-count, and per-file plus
|
||||
// per-folder download actions. Grouped result is memoised per search id for the session (a proper cache
|
||||
// is a later step).
|
||||
// per-folder download actions.
|
||||
//
|
||||
// Caching is stale-while-revalidate: the cached grouping paints immediately, then we always re-fetch in
|
||||
// the background. Never serve the cache alone — opening a search before its first responses land used to
|
||||
// freeze that empty snapshot forever. While the search is still running we also poll, so results fill in
|
||||
// live; the header's refresh re-reads on demand.
|
||||
|
||||
const MAX_USERS = 200;
|
||||
const POLL_MS = 2500;
|
||||
// Safety net: a search whose completion we can't confirm shouldn't poll forever (~2 min of polling).
|
||||
const MAX_POLLS = 48;
|
||||
const resultsCache = new Map<string, ResultUser[]>();
|
||||
|
||||
export const dropCachedResults = (searchId: string) => resultsCache.delete(searchId);
|
||||
|
||||
const fileKey = (username: string, filename: string) => `${username}::${filename}`;
|
||||
|
||||
// Client-side filtering over already-loaded results (mirrors slskd's filter tokens, no re-query).
|
||||
@@ -82,39 +91,88 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => {
|
||||
const cachedInit = resultsCache.get(search.id) ?? null;
|
||||
const [users, setUsers] = useState<ResultUser[] | null>(cachedInit);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isComplete, setIsComplete] = useState(search.isComplete);
|
||||
// Users start expanded; toggling collapses them.
|
||||
const [open, setOpen] = useState<Set<string>>(() => new Set(cachedInit?.map((u) => u.username)));
|
||||
const [queued, setQueued] = useState<Set<string>>(new Set());
|
||||
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
|
||||
// Usernames we've already auto-expanded — lets a revalidation expand newcomers without re-opening
|
||||
// the cards the reader deliberately collapsed.
|
||||
const seenRef = useRef<Set<string>>(new Set(cachedInit?.map((u) => u.username) ?? []));
|
||||
const aliveRef = useRef(true);
|
||||
const pollsRef = useRef(0);
|
||||
|
||||
const filtered = useMemo(() => (users ? applyFilters(users, filters) : null), [users, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
const cached = resultsCache.get(search.id);
|
||||
if (cached) {
|
||||
setUsers(cached);
|
||||
setOpen(new Set(cached.map((u) => u.username)));
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setUsers(null);
|
||||
setError(null);
|
||||
client
|
||||
.get<SlskdResponse[]>(`/slskd/api/v0/searches/${search.id}/responses`)
|
||||
.then((responses) => {
|
||||
if (cancelled) return;
|
||||
aliveRef.current = true;
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (quiet: boolean) => {
|
||||
if (!quiet) setLoading(true);
|
||||
try {
|
||||
// The summary tells us whether to keep polling; tolerate its absence rather than fail the load.
|
||||
const [responses, meta] = await Promise.all([
|
||||
client.get<SlskdResponse[]>(`/slskd/api/v0/searches/${search.id}/responses`),
|
||||
client.get<SlskdSearchSummary>(`/slskd/api/v0/searches/${search.id}`).catch(() => null),
|
||||
]);
|
||||
if (!aliveRef.current) return;
|
||||
const grouped = groupResponses(responses);
|
||||
resultsCache.set(search.id, grouped);
|
||||
setUsers(grouped);
|
||||
setOpen(new Set(grouped.map((u) => u.username)));
|
||||
})
|
||||
.catch((err) => !cancelled && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
setError(null);
|
||||
setOpen((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const u of grouped) {
|
||||
if (!seenRef.current.has(u.username)) {
|
||||
seenRef.current.add(u.username);
|
||||
next.add(u.username);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (meta) setIsComplete(meta.isComplete);
|
||||
} catch (err) {
|
||||
if (aliveRef.current) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (aliveRef.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
// useClient() returns a fresh object each render, so depend only on the search id.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search.id]);
|
||||
[search.id],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const cached = resultsCache.get(search.id) ?? null;
|
||||
seenRef.current = new Set(cached?.map((u) => u.username) ?? []);
|
||||
pollsRef.current = 0;
|
||||
setUsers(cached);
|
||||
setOpen(new Set(cached?.map((u) => u.username)));
|
||||
setError(null);
|
||||
setIsComplete(search.isComplete);
|
||||
// Quiet when something is already on screen — no spinner flash over usable results.
|
||||
refresh(Boolean(cached));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search.id, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isComplete) return;
|
||||
const timer = setInterval(() => {
|
||||
pollsRef.current += 1;
|
||||
if (pollsRef.current > MAX_POLLS) {
|
||||
clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
refresh(true);
|
||||
}, POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [isComplete, refresh]);
|
||||
|
||||
const toggle = (username: string) =>
|
||||
setOpen((prev) => {
|
||||
@@ -169,7 +227,14 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => {
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold">{search.searchText}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold">{search.searchText}</span>
|
||||
{!isComplete && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> searching
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{users === null && !error
|
||||
? 'Loading results…'
|
||||
@@ -178,6 +243,15 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => {
|
||||
}${filtered && filtered.length > shown.length ? ` — showing top ${shown.length}` : ''}`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refresh(false)}
|
||||
disabled={loading}
|
||||
title="Reload results"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter bar — text tokens plus one-click pills, applied client-side to loaded results */}
|
||||
@@ -240,7 +314,17 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => {
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading results…
|
||||
</div>
|
||||
)}
|
||||
{!error && users && users.length === 0 && <p className="p-1 text-sm text-muted-foreground">No results stored.</p>}
|
||||
{!error && users && users.length === 0 && (
|
||||
<p className="flex items-center gap-2 p-1 text-sm text-muted-foreground">
|
||||
{isComplete ? (
|
||||
'No results stored.'
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Waiting for responses…
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{!error && users && users.length > 0 && filtered && filtered.length === 0 && (
|
||||
<p className="p-1 text-sm text-muted-foreground">No files match the current filters.</p>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { toast } from 'sonner';
|
||||
import { Search, Loader2, RefreshCw, X, History, FileAudio, Users, ChevronRight } from 'lucide-react';
|
||||
import { SearchResults } from './SearchResults';
|
||||
import { SearchResults, dropCachedResults } from './SearchResults';
|
||||
import { formatWhen, type SlskdSearchSummary } from './shared';
|
||||
|
||||
// The 'search' section — a search input plus the history of past searches (GET /searches). Submitting
|
||||
@@ -48,6 +48,7 @@ export const SearchView = () => {
|
||||
|
||||
const remove = async (id: string) => {
|
||||
setHistory((prev) => prev?.filter((s) => s.id !== id) ?? prev);
|
||||
dropCachedResults(id);
|
||||
client.delete(`/slskd/api/v0/searches/${id}`).catch(() => load());
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user