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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.87.8",
|
||||
"version": "0.87.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -89,6 +89,22 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
? null
|
||||
: setTimeout(() => ctrl.abort(), PROXY_TIMEOUT_MS);
|
||||
|
||||
// Chain the SvelteKit client's disconnect signal into the upstream
|
||||
// fetch so closing an admin tab tears down the backend SSE
|
||||
// subscription immediately. Without this, the backend's
|
||||
// `broadcast::Receiver` + spawned tokio task leak per closed tab
|
||||
// until backend restart — every page refresh adds another.
|
||||
// `AbortSignal.any` would be cleaner but it's Node 20.3+; chain a
|
||||
// listener so we work on older runtimes (vitest's bundled jsdom
|
||||
// env included). If the client signal is already aborted (e.g.
|
||||
// very fast reload), fire ctrl immediately.
|
||||
if (event.request.signal.aborted) {
|
||||
ctrl.abort();
|
||||
} else {
|
||||
event.request.signal.addEventListener('abort', () => ctrl.abort(), {
|
||||
once: true
|
||||
});
|
||||
}
|
||||
const init: RequestInit & { duplex?: 'half' } = {
|
||||
method: event.request.method,
|
||||
headers,
|
||||
|
||||
@@ -743,7 +743,8 @@ export type PageAnalysisDetail = {
|
||||
|
||||
/** Paginated per-manga analysis coverage (admin overview). */
|
||||
export async function getAnalysisMangaCoverage(
|
||||
opts: { search?: string; limit?: number; offset?: number } = {}
|
||||
opts: { search?: string; limit?: number; offset?: number } = {},
|
||||
init?: { signal?: AbortSignal }
|
||||
): Promise<MangaCoveragePage> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.search) params.set('search', opts.search);
|
||||
@@ -751,7 +752,8 @@ export async function getAnalysisMangaCoverage(
|
||||
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||
const qs = params.toString();
|
||||
return request<MangaCoveragePage>(
|
||||
`/v1/admin/analysis/mangas${qs ? `?${qs}` : ''}`
|
||||
`/v1/admin/analysis/mangas${qs ? `?${qs}` : ''}`,
|
||||
init?.signal ? { signal: init.signal } : undefined
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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