Suppose a slow search for ca completes after a faster search for cam. The ca response must not replace cam suggestions. A query comparison alone is also insufficient: ca → cam → ca can create two requests with the same query. Use a monotonically increasing request generation and invalidate it on every edit, including edits made while the next search is still waiting for its debounce timer.
type SearchUpdate =
| { kind: "loading"; query: string }
| { kind: "ready"; query: string; items: Suggestion[] }
| { kind: "error"; query: string; message: string };
function createGuardedSearch(
load: (query: string, signal: AbortSignal) => Promise<Suggestion[]>,
publish: (update: SearchUpdate) => void,
) {
let generation = 0;
let controller: AbortController | null = null;
let disposed = false;
function invalidate() {
generation += 1;
controller?.abort();
controller = null;
}
async function run(query: string) {
if (disposed) return;
invalidate();
const ticket = generation;
const request = new AbortController();
controller = request;
const isCurrent = () =>
!disposed && ticket === generation && !request.signal.aborted;
publish({ kind: "loading", query });
try {
const items = await load(query, request.signal);
if (isCurrent()) publish({ kind: "ready", query, items });
} catch {
if (isCurrent()) {
publish({ kind: "error", query, message: "Search failed. Try again." });
}
} finally {
if (ticket === generation) controller = null;
}
}
return {
run,
invalidate,
dispose() {
disposed = true;
invalidate();
},
};
}
Call invalidate as soon as input changes, before scheduling the next search. Cancel the debounce timer on clear, selection, and teardown; call dispose on teardown. The controller separately gates popup visibility, composition, and cache hits. This simple policy discards superseded responses entirely, even if an adapter ignores AbortSignal. A shared cache that deliberately accepts historical responses needs additional per-key version rules.
AbortController can stop supported fetch work, but response validity is still an application decision. Keep the generation check for both success and error paths so a stale failure cannot replace a newer success. Tie a guard instance to one source and account context, or recreate it when that context changes. Deduplicate identical requests only within the same context and retain the same correctness checks.