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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.87.8"
|
version = "0.87.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.87.8"
|
version = "0.87.9"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.87.8",
|
"version": "0.87.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -89,6 +89,22 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
? null
|
? null
|
||||||
: setTimeout(() => ctrl.abort(), PROXY_TIMEOUT_MS);
|
: 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' } = {
|
const init: RequestInit & { duplex?: 'half' } = {
|
||||||
method: event.request.method,
|
method: event.request.method,
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -743,7 +743,8 @@ export type PageAnalysisDetail = {
|
|||||||
|
|
||||||
/** Paginated per-manga analysis coverage (admin overview). */
|
/** Paginated per-manga analysis coverage (admin overview). */
|
||||||
export async function getAnalysisMangaCoverage(
|
export async function getAnalysisMangaCoverage(
|
||||||
opts: { search?: string; limit?: number; offset?: number } = {}
|
opts: { search?: string; limit?: number; offset?: number } = {},
|
||||||
|
init?: { signal?: AbortSignal }
|
||||||
): Promise<MangaCoveragePage> {
|
): Promise<MangaCoveragePage> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (opts.search) params.set('search', opts.search);
|
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));
|
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return request<MangaCoveragePage>(
|
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 live = $state(false);
|
||||||
let source: EventSource | null = null;
|
let source: EventSource | null = null;
|
||||||
let sseErrors = 0;
|
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
|
// Global "now analyzing" pointer, driven by the `started` SSE event so
|
||||||
// the operator sees what the worker is on even when nothing is
|
// the operator sees what the worker is on even when nothing is
|
||||||
@@ -113,12 +118,24 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
void loadCoverage(true);
|
void loadCoverage(true);
|
||||||
openStream();
|
openStream();
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
window.addEventListener('pagehide', onPageHide);
|
||||||
|
window.addEventListener('pageshow', onPageShow);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
source?.close();
|
closeStream();
|
||||||
source = null;
|
if (typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
window.removeEventListener('pagehide', onPageHide);
|
||||||
|
window.removeEventListener('pageshow', onPageShow);
|
||||||
|
}
|
||||||
if (clearTimer) clearTimeout(clearTimer);
|
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) {
|
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() {
|
function openStream() {
|
||||||
if (source) return;
|
if (source) return;
|
||||||
const es = new EventSource(analysisStatusStreamUrl(), { withCredentials: true });
|
const es = new EventSource(analysisStatusStreamUrl(), { withCredentials: true });
|
||||||
@@ -283,19 +327,35 @@
|
|||||||
loadingMore = true;
|
loadingMore = true;
|
||||||
}
|
}
|
||||||
error = null;
|
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 {
|
try {
|
||||||
const r = await getAnalysisMangaCoverage({
|
const r = await getAnalysisMangaCoverage(
|
||||||
search: query.trim() || undefined,
|
{
|
||||||
limit: LIMIT,
|
search: query.trim() || undefined,
|
||||||
offset
|
limit: LIMIT,
|
||||||
});
|
offset
|
||||||
|
},
|
||||||
|
{ signal: ctrl.signal }
|
||||||
|
);
|
||||||
mangas = reset ? r.items : [...mangas, ...r.items];
|
mangas = reset ? r.items : [...mangas, ...r.items];
|
||||||
total = r.page.total ?? mangas.length;
|
total = r.page.total ?? mangas.length;
|
||||||
} catch (e) {
|
} 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.';
|
error = e instanceof ApiError ? e.message : 'Failed to load coverage.';
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
// Only clear the in-flight markers if this call still owns
|
||||||
loadingMore = false;
|
// 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