test(frontend): extract CancellableLoader; pin debounce-race invariants in unit tests (0.87.23)

0.87.16 followup. Extract the four debounce-race invariants
(predecessor abort, late-result suppression, AbortError swallow,
ownership-aware finally) from `loadCoverage` into a reusable
`CancellableLoader` helper at $lib/util/cancellable, with 9 unit
tests covering each. Page rewrite uses the helper.

Also corrects: hooks.server.test.ts (added Node-fetch rejection
shape for already-aborted entry; removed redundant tick); admin.test.ts
(AbortError-propagation now uses pre-aborted signal + mockRejectedValueOnce
mirroring real Node fetch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-23 21:03:12 +02:00
parent 2ee77bc867
commit 4e14ed09ef
8 changed files with 342 additions and 58 deletions

View File

@@ -17,6 +17,7 @@
type AnalysisEvent
} from '$lib/api/admin';
import { chapterLabel } from '$lib/api/chapters';
import { CancellableLoader } from '$lib/util/cancellable';
import CoverageBadge from '$lib/components/CoverageBadge.svelte';
import Modal from '$lib/components/Modal.svelte';
import AnalysisHistoryTable from '$lib/components/analysis/AnalysisHistoryTable.svelte';
@@ -71,11 +72,14 @@
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;
// Per-loader cancellation guard. A debounced search-keystroke burst
// (or unmount) cancels its predecessor's response — without this,
// a slow first request would overwrite a faster second one.
// `CancellableLoader` lifts the four invariants (predecessor abort,
// late-result suppression, AbortError swallow, ownership-aware
// finally) into $lib/util/cancellable, where they're unit-tested.
// The inlined version in 0.87.9 was structurally untested.
const coverageLoader = new CancellableLoader();
// Global "now analyzing" pointer, driven by the `started` SSE event so
// the operator sees what the worker is on even when nothing is
@@ -135,7 +139,7 @@
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();
coverageLoader.cancel();
});
function pushTicker(text: string) {
@@ -342,35 +346,36 @@
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
},
{ signal: ctrl.signal }
const r = await coverageLoader.run(({ signal }) =>
getAnalysisMangaCoverage(
{
search: query.trim() || undefined,
limit: LIMIT,
offset
},
{ signal }
)
);
// `null` = superseded by a successor call, or AbortError.
// Skip state updates AND leave the spinner alone — the
// successor (which is still in-flight) owns it. Without
// this guard, an unconditional `loading = false` in a
// finally{} would clear the spinner while the successor
// is still loading, producing a flicker.
if (r === null) return;
mangas = reset ? r.items : [...mangas, ...r.items];
total = r.page.total ?? mangas.length;
loading = false;
loadingMore = false;
} 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 {
// 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;
}
// Non-Abort error: this call genuinely failed and OWNS
// (any successor would have flipped run() to null before
// throwing). Clear the spinner so the error message is
// readable.
loading = false;
loadingMore = false;
}
}