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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user