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:
2026-07-29 22:37:57 +00:00
co-authored by Claude Opus 4.8
parent bb90dba952
commit f777ed4197
2 changed files with 112 additions and 27 deletions
@@ -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());
};