fix(frontend-admin): SSE/AbortController hygiene + signal-chain through proxy (0.87.9)

Three frontend findings (medium):

1. **`hooks.server.ts` didn't chain client-disconnect into the upstream
   fetch.** Closing an admin tab left the backend's `broadcast::Receiver`
   + spawned tokio task running until next backend restart. Listen for
   `event.request.signal.abort` and forward to the timeout controller.

2. **`admin/analysis/+page.svelte` missing visibility/pagehide/pageshow
   handlers.** Mobile Safari throttles SSE in background tabs; bfcache
   leaves a dead connection on back-nav. Apply the close/open-stream
   pattern from `admin/crawler/+page.svelte`.

3. **`loadCoverage` had no AbortController.** Debounced search
   keystrokes raced. Per-loader controller plumbed through
   `getAnalysisMangaCoverage`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-22 21:52:33 +02:00
parent c0281f7e9b
commit 747bdeda46
6 changed files with 92 additions and 14 deletions

View File

@@ -71,6 +71,11 @@
let live = $state(false);
let source: EventSource | null = null;
let sseErrors = 0;
// Per-loader AbortController so a debounced search-keystroke burst
// (or unmount) can cancel its predecessor's response — without this,
// a slow first request would overwrite a faster second one, mirroring
// the issue the crawler page already solved at line 55.
let coverageAbort: AbortController | null = null;
// Global "now analyzing" pointer, driven by the `started` SSE event so
// the operator sees what the worker is on even when nothing is
@@ -113,12 +118,24 @@
onMount(() => {
void loadCoverage(true);
openStream();
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', onVisibilityChange);
window.addEventListener('pagehide', onPageHide);
window.addEventListener('pageshow', onPageShow);
}
});
onDestroy(() => {
source?.close();
source = null;
closeStream();
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', onVisibilityChange);
window.removeEventListener('pagehide', onPageHide);
window.removeEventListener('pageshow', onPageShow);
}
if (clearTimer) clearTimeout(clearTimer);
// Cancel any in-flight coverage fetch so a late response can't
// overwrite state after the page is gone.
coverageAbort?.abort();
});
function pushTicker(text: string) {
@@ -233,6 +250,33 @@
}
}
function closeStream() {
source?.close();
source = null;
live = false;
}
// Mirrors `admin/crawler/+page.svelte`: close the SSE stream when
// the tab is hidden so the browser doesn't accumulate a stale
// connection (mobile Safari throttles SSE aggressively in background
// tabs), and reopen on visible. Plus pagehide / pageshow to handle
// the BFCache, which would otherwise leave the dashboard reading a
// dead connection on back-navigation.
function onVisibilityChange() {
if (typeof document === 'undefined') return;
if (document.visibilityState === 'hidden') {
closeStream();
} else if (document.visibilityState === 'visible') {
openStream();
}
}
function onPageHide() {
closeStream();
}
function onPageShow() {
openStream();
}
function openStream() {
if (source) return;
const es = new EventSource(analysisStatusStreamUrl(), { withCredentials: true });
@@ -283,19 +327,35 @@
loadingMore = true;
}
error = null;
// Cancel any in-flight predecessor — a debounced search burst
// would otherwise race; the slow first response could overwrite
// the faster second result.
coverageAbort?.abort();
const ctrl = new AbortController();
coverageAbort = ctrl;
try {
const r = await getAnalysisMangaCoverage({
search: query.trim() || undefined,
limit: LIMIT,
offset
});
const r = await getAnalysisMangaCoverage(
{
search: query.trim() || undefined,
limit: LIMIT,
offset
},
{ signal: ctrl.signal }
);
mangas = reset ? r.items : [...mangas, ...r.items];
total = r.page.total ?? mangas.length;
} catch (e) {
// Aborts are normal cancellation, not errors to surface.
if ((e as { name?: string })?.name === 'AbortError') return;
error = e instanceof ApiError ? e.message : 'Failed to load coverage.';
} finally {
loading = false;
loadingMore = false;
// Only clear the in-flight markers if this call still owns
// them — a later call may have superseded us already.
if (coverageAbort === ctrl) coverageAbort = null;
if (!ctrl.signal.aborted) {
loading = false;
loadingMore = false;
}
}
}