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 { useClient } from 'hooks/useClient';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { toast } from 'sonner';
|
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 { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList, Pill } from './Cards';
|
||||||
import {
|
import {
|
||||||
SLSKD_REFRESH_CHANNEL,
|
SLSKD_REFRESH_CHANNEL,
|
||||||
@@ -21,12 +21,21 @@ import {
|
|||||||
// The results subpanel for one past search — loads its STORED responses (GET /searches/{id}/responses),
|
// 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,
|
// 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
|
// 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
|
// per-folder download actions.
|
||||||
// is a later step).
|
//
|
||||||
|
// 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 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[]>();
|
const resultsCache = new Map<string, ResultUser[]>();
|
||||||
|
|
||||||
|
export const dropCachedResults = (searchId: string) => resultsCache.delete(searchId);
|
||||||
|
|
||||||
const fileKey = (username: string, filename: string) => `${username}::${filename}`;
|
const fileKey = (username: string, filename: string) => `${username}::${filename}`;
|
||||||
|
|
||||||
// Client-side filtering over already-loaded results (mirrors slskd's filter tokens, no re-query).
|
// 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 cachedInit = resultsCache.get(search.id) ?? null;
|
||||||
const [users, setUsers] = useState<ResultUser[] | null>(cachedInit);
|
const [users, setUsers] = useState<ResultUser[] | null>(cachedInit);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isComplete, setIsComplete] = useState(search.isComplete);
|
||||||
// Users start expanded; toggling collapses them.
|
// Users start expanded; toggling collapses them.
|
||||||
const [open, setOpen] = useState<Set<string>>(() => new Set(cachedInit?.map((u) => u.username)));
|
const [open, setOpen] = useState<Set<string>>(() => new Set(cachedInit?.map((u) => u.username)));
|
||||||
const [queued, setQueued] = useState<Set<string>>(new Set());
|
const [queued, setQueued] = useState<Set<string>>(new Set());
|
||||||
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
|
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]);
|
const filtered = useMemo(() => (users ? applyFilters(users, filters) : null), [users, filters]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cached = resultsCache.get(search.id);
|
aliveRef.current = true;
|
||||||
if (cached) {
|
return () => {
|
||||||
setUsers(cached);
|
aliveRef.current = false;
|
||||||
setOpen(new Set(cached.map((u) => u.username)));
|
};
|
||||||
return;
|
}, []);
|
||||||
}
|
|
||||||
let cancelled = false;
|
const refresh = useCallback(
|
||||||
setUsers(null);
|
async (quiet: boolean) => {
|
||||||
setError(null);
|
if (!quiet) setLoading(true);
|
||||||
client
|
try {
|
||||||
.get<SlskdResponse[]>(`/slskd/api/v0/searches/${search.id}/responses`)
|
// The summary tells us whether to keep polling; tolerate its absence rather than fail the load.
|
||||||
.then((responses) => {
|
const [responses, meta] = await Promise.all([
|
||||||
if (cancelled) return;
|
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);
|
const grouped = groupResponses(responses);
|
||||||
resultsCache.set(search.id, grouped);
|
resultsCache.set(search.id, grouped);
|
||||||
setUsers(grouped);
|
setUsers(grouped);
|
||||||
setOpen(new Set(grouped.map((u) => u.username)));
|
setError(null);
|
||||||
})
|
setOpen((prev) => {
|
||||||
.catch((err) => !cancelled && setError(err instanceof Error ? err.message : String(err)));
|
const next = new Set(prev);
|
||||||
return () => {
|
for (const u of grouped) {
|
||||||
cancelled = true;
|
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.
|
// useClient() returns a fresh object each render, so depend only on the search id.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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) =>
|
const toggle = (username: string) =>
|
||||||
setOpen((prev) => {
|
setOpen((prev) => {
|
||||||
@@ -169,7 +227,14 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => {
|
|||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<div className="min-w-0 flex-1">
|
<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">
|
<div className="text-xs text-muted-foreground">
|
||||||
{users === null && !error
|
{users === null && !error
|
||||||
? 'Loading results…'
|
? 'Loading results…'
|
||||||
@@ -178,6 +243,15 @@ export const SearchResults = ({ search, onBack }: SearchResultsProps) => {
|
|||||||
}${filtered && filtered.length > shown.length ? ` — showing top ${shown.length}` : ''}`}
|
}${filtered && filtered.length > shown.length ? ` — showing top ${shown.length}` : ''}`}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Filter bar — text tokens plus one-click pills, applied client-side to loaded results */}
|
{/* 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…
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading results…
|
||||||
</div>
|
</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 && (
|
{!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>
|
<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 { useClient } from 'hooks/useClient';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Search, Loader2, RefreshCw, X, History, FileAudio, Users, ChevronRight } from 'lucide-react';
|
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';
|
import { formatWhen, type SlskdSearchSummary } from './shared';
|
||||||
|
|
||||||
// The 'search' section — a search input plus the history of past searches (GET /searches). Submitting
|
// 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) => {
|
const remove = async (id: string) => {
|
||||||
setHistory((prev) => prev?.filter((s) => s.id !== id) ?? prev);
|
setHistory((prev) => prev?.filter((s) => s.id !== id) ?? prev);
|
||||||
|
dropCachedResults(id);
|
||||||
client.delete(`/slskd/api/v0/searches/${id}`).catch(() => load());
|
client.delete(`/slskd/api/v0/searches/${id}`).catch(() => load());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user