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