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

@@ -731,28 +731,19 @@ describe('admin crawler api client', () => {
});
it('getAnalysisMangaCoverage propagates an abort rejection', async () => {
// Mid-flight abort: caller flips the AbortController, the fetch
// promise rejects, and the api client lets the rejection
// surface to its caller (the debounce-race handler treats
// AbortError as silent cancellation rather than an error toast).
// Production Node fetch rejects synchronously with AbortError
// when called with an already-aborted signal. Mirror that
// (instead of wiring our own listener and asserting the
// rejection we self-fire) so the test exercises the contract
// the consumer relies on — the CancellableLoader at
// $lib/util/cancellable.ts treats AbortError as `null` and
// skips the state update.
const ctrl = new AbortController();
fetchSpy.mockImplementationOnce(
(_url, init) =>
new Promise((_resolve, reject) => {
const signal = (init as RequestInit).signal;
signal?.addEventListener(
'abort',
() => reject(new DOMException('aborted', 'AbortError')),
{ once: true }
);
})
);
const inflight = getAnalysisMangaCoverage(
{ limit: 25 },
{ signal: ctrl.signal }
);
ctrl.abort();
await expect(inflight).rejects.toMatchObject({ name: 'AbortError' });
fetchSpy.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'));
await expect(
getAnalysisMangaCoverage({ limit: 25 }, { signal: ctrl.signal })
).rejects.toMatchObject({ name: 'AbortError' });
});
it('getAnalysisChapterCoverage unwraps items', async () => {

View File

@@ -0,0 +1,173 @@
import { describe, it, expect, vi } from 'vitest';
import { CancellableLoader } from './cancellable';
/**
* Helper: a Promise that resolves when `signal` aborts. Mirrors the
* shape a real `fetch(url, { signal })` rejects on cancellation.
*/
function rejectsOnAbort<T = never>(signal: AbortSignal, value?: T): Promise<T> {
return new Promise<T>((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException('aborted', 'AbortError'));
return;
}
signal.addEventListener(
'abort',
() => reject(new DOMException('aborted', 'AbortError')),
{ once: true }
);
// The caller wires its own resolve via the AbortSignal listeners
// in the test; if a value is provided, resolve after a tick.
if (value !== undefined) {
// microtask so the test can call run() again first.
queueMicrotask(() => resolve(value));
}
});
}
describe('CancellableLoader', () => {
it('returns the resolved value when no successor cancels the call', async () => {
const loader = new CancellableLoader();
const result = await loader.run(async () => 42);
expect(result).toBe(42);
});
it('aborts the predecessor signal when a successor starts', async () => {
const loader = new CancellableLoader();
// Predecessor: long-running, observes its own abort.
let predecessorSignal: AbortSignal | null = null;
const predecessor = loader.run(({ signal }) => {
predecessorSignal = signal;
return rejectsOnAbort(signal);
});
// Tick the microtask queue so the predecessor's signal is set.
await Promise.resolve();
expect(predecessorSignal).toBeInstanceOf(AbortSignal);
expect(predecessorSignal!.aborted).toBe(false);
// Successor: resolves immediately.
const successor = loader.run(async () => 'B');
// The predecessor's signal must have been aborted.
expect(predecessorSignal!.aborted).toBe(true);
// Successor wins; predecessor is swallowed.
expect(await successor).toBe('B');
expect(await predecessor).toBeNull();
});
it('suppresses a predecessor whose body resolves AFTER the successor started', async () => {
// Race: predecessor's fetch hasn't been cancelled in time (e.g.
// it had already started writing the response). Its resolved
// value must still NOT leak into the loader's caller — the
// successor owns the state.
const loader = new CancellableLoader();
// Predecessor: ignores its signal, resolves slowly.
let predecessorResolve: (v: string) => void = () => {};
const predecessor = loader.run(
() =>
new Promise<string>((resolve) => {
predecessorResolve = resolve;
})
);
await Promise.resolve();
// Successor: replaces predecessor, resolves quickly.
const successor = loader.run(async () => 'fresh');
expect(await successor).toBe('fresh');
// Now resolve the (ignored) predecessor late.
predecessorResolve('stale');
expect(await predecessor).toBeNull();
});
it('returns null on AbortError without rethrowing', async () => {
const loader = new CancellableLoader();
// Start a never-resolving call. We can fire its abort by
// starting a fresh run() — the AbortError swallow is what
// we're pinning here. Crucially, DON'T await `pending` before
// starting the successor; the await must come after.
const pending = loader.run(({ signal }) => rejectsOnAbort<string>(signal));
await Promise.resolve();
const successor = loader.run(async () => 'B');
expect(await pending).toBeNull();
await successor;
});
it('propagates non-AbortError rejections', async () => {
const loader = new CancellableLoader();
const err = new Error('network kaboom');
await expect(loader.run(async () => { throw err; })).rejects.toBe(err);
});
it('isOwnerOf is true only for the current in-flight call', async () => {
const loader = new CancellableLoader();
// No call in flight: every signal is non-owner.
expect(loader.isOwnerOf(new AbortController().signal)).toBe(false);
let signal: AbortSignal | null = null;
const pending = loader.run(({ signal: s }) => {
signal = s;
return rejectsOnAbort(s);
});
await Promise.resolve();
// Mid-flight: the active call's signal is the owner.
expect(loader.isOwnerOf(signal!)).toBe(true);
// A stranger signal is not.
expect(loader.isOwnerOf(new AbortController().signal)).toBe(false);
// Starting a successor flips ownership.
const successor = loader.run(async () => 'next');
expect(loader.isOwnerOf(signal!)).toBe(false);
await pending;
await successor;
});
it('cancel() aborts the current call and leaves the slot empty', async () => {
const loader = new CancellableLoader();
let signal: AbortSignal | null = null;
const pending = loader.run(({ signal: s }) => {
signal = s;
return rejectsOnAbort(s);
});
await Promise.resolve();
expect(signal!.aborted).toBe(false);
loader.cancel();
expect(signal!.aborted).toBe(true);
expect(await pending).toBeNull();
// After cancel, isOwnerOf is false for the old signal — slot
// empty, no successor queued.
expect(loader.isOwnerOf(signal!)).toBe(false);
});
it('rapid burst of three calls: only the last resolves', async () => {
// The exact debounce-race shape the dashboard's search box used
// to suffer from. Three back-to-back calls; only the third's
// value wins; predecessors return null.
const loader = new CancellableLoader();
const a = loader.run(({ signal }) => rejectsOnAbort<string>(signal));
await Promise.resolve();
const b = loader.run(({ signal }) => rejectsOnAbort<string>(signal));
await Promise.resolve();
const c = loader.run(async () => 'WIN');
const [ra, rb, rc] = await Promise.all([a, b, c]);
expect(ra).toBeNull();
expect(rb).toBeNull();
expect(rc).toBe('WIN');
});
it('cleanup: late re-call after a no-cancellation completion finds an empty slot', async () => {
const loader = new CancellableLoader();
const v = await loader.run(async () => 'first');
expect(v).toBe('first');
// No predecessor abort fires on the second run; ownership flows.
const v2 = await loader.run(async () => 'second');
expect(v2).toBe('second');
});
});

View File

@@ -0,0 +1,95 @@
/**
* A loader that cancels its in-flight predecessor on each call.
*
* Background — 0.87.9 added per-loader AbortControllers to the admin
* analysis dashboard so a debounced search-burst couldn't race itself
* (slow first response overwriting a faster second). The 0.87.16
* rereview noted that the contract (predecessor-abort + result-
* ownership check + spinner guard) was only inlined into a single
* Svelte component and untested.
*
* Lift the four invariants into a reusable, testable helper:
*
* 1. **Predecessor abort.** Every `run()` aborts whatever came before
* via the prior AbortController.
* 2. **Late-result suppression.** A predecessor whose fetch finishes
* AFTER the successor's run() started must not surface its value
* — `run()` returns `null` ("superseded") on that path so the
* caller can skip the state update.
* 3. **AbortError swallowed.** Aborts are normal cancellation, not
* errors to toast; `run()` returns `null` rather than rethrowing.
* 4. **Ownership-aware finally.** Whichever call still owns the
* controller clears `this.current` on completion; a later call
* that overwrote it leaves the marker alone (or the spinner would
* flicker on the older call's abort).
*
* Usage:
* const loader = new CancellableLoader<Result>();
* const result = await loader.run(({ signal }) =>
* fetch(url, { signal }).then(r => r.json())
* );
* if (result === null) return; // superseded or aborted
* // …apply result…
*
* Note the `null` outcome is INTENTIONAL — the caller MUST decide
* whether to flip its spinner off (yes if no other call is in flight,
* no if a successor is). The `isOwnerOf` helper makes that decision
* explicit:
* if (loader.isOwnerOf(signal)) loading = false;
*/
export class CancellableLoader {
private current: AbortController | null = null;
/**
* Run `fn` with a fresh AbortController, aborting any prior in-
* flight call. Returns the resolved value, or `null` when this
* call was superseded mid-flight OR fn rejected with AbortError.
* Other errors propagate.
*/
async run<T>(fn: (ctx: { signal: AbortSignal }) => Promise<T>): Promise<T | null> {
this.current?.abort();
const ctrl = new AbortController();
this.current = ctrl;
try {
const value = await fn({ signal: ctrl.signal });
// Successor may have replaced us between the await above
// and here. Drop the result silently — the caller should
// not write stale data over the successor's state.
if (this.current !== ctrl) return null;
return value;
} catch (e) {
// AbortError is normal cancellation; non-Abort errors
// (network, 5xx, JSON parse) propagate to the caller.
if ((e as { name?: string } | null | undefined)?.name === 'AbortError') {
return null;
}
throw e;
} finally {
// Only clear the marker if this call still owns it. A
// predecessor that lost ownership leaves the successor's
// controller untouched.
if (this.current === ctrl) this.current = null;
}
}
/**
* True iff the given signal belongs to the current in-flight call.
* Use from the caller's finally{} to decide whether to flip
* loading=false. Reading `signal.aborted` directly works for the
* abort case but not for the superseded-without-abort case (only
* `null` from `run()` covers both).
*/
isOwnerOf(signal: AbortSignal): boolean {
return this.current?.signal === signal;
}
/**
* Abort the current in-flight call without queueing a successor.
* Used from onDestroy so a late response can't write into a dead
* page.
*/
cancel(): void {
this.current?.abort();
this.current = null;
}
}