The AbortController is correctness, not economy. Without it a slow response for 'st' can land after the fast one for 'st marys' and replace a correct list with a stale one — the classic autosuggest race. No cache: 'no-store', unlike the compare modal's search. This is the one endpoint where prefix queries repeat most across users, so discarding the browser cache and the backend's ETag 304s would be throwing away the cheapest win available. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
59 lines
1.7 KiB
TypeScript
59 lines
1.7 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { fetchSuggestions, SUGGEST_MIN_QUERY, type Suggestion } from '@/lib/suggest';
|
|
|
|
/*
|
|
* Long enough that a fast typist does not fire a request per character, short
|
|
* enough that the list feels attached to the keyboard.
|
|
*/
|
|
const DEBOUNCE_MS = 200;
|
|
|
|
export function useSchoolSuggest(query: string, enabled: boolean) {
|
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
|
const [open, setOpen] = useState(false);
|
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
// Set when the user dismisses the list, so a re-render does not reopen it.
|
|
const dismissed = useRef('');
|
|
|
|
useEffect(() => {
|
|
const q = query.trim();
|
|
if (!enabled || q.length < SUGGEST_MIN_QUERY || dismissed.current === q) {
|
|
setSuggestions([]);
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
|
|
/*
|
|
* Abort the superseded request on every keystroke. This is correctness,
|
|
* not economy: without it a slow response for "st" can land after the fast
|
|
* one for "st marys" and replace a correct list with a stale one.
|
|
*/
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(async () => {
|
|
const rows = await fetchSuggestions(q, controller.signal);
|
|
if (controller.signal.aborted) return;
|
|
setSuggestions(rows);
|
|
setActiveIndex(-1);
|
|
setOpen(rows.length > 0);
|
|
}, DEBOUNCE_MS);
|
|
|
|
return () => {
|
|
clearTimeout(timer);
|
|
controller.abort();
|
|
};
|
|
}, [query, enabled]);
|
|
|
|
return {
|
|
suggestions,
|
|
open,
|
|
activeIndex,
|
|
setActiveIndex,
|
|
close: () => {
|
|
dismissed.current = query.trim();
|
|
setOpen(false);
|
|
setActiveIndex(-1);
|
|
},
|
|
};
|
|
}
|