// Production search bug fix /instrument-search coordinator. // // Root cause of the hung spinner: the API previously did a full catalog fetch // server-side. Client-side we now keep the round trip bounded end to end: // debounce + abort of prior stale queries + a hard AbortController timer, and // explicit handling of the honest failure statuses the backend now returns. // Pure dependency injection makes stale-query cleanup unit testable. import { apiFetch } from './api'; import { extractStatusMessage } from './state'; import type { InstrumentSearchResponse } from './types'; export const SEARCH_TIMEOUT_MS = 12_000; export const SEARCH_DEBOUNCE_MS = 350; export const SEARCH_UNAVAILABLE_MSG = '搜索数据源暂时不可用,请稍后重试'; export const SEARCH_TIMEOUT_MSG = '搜索超时,请重试'; export const SEARCH_EMPTY_MSG = '未找到匹配标的,可使用下方手动录入并核对身份'; export type SearchFetcher = ( q: string, signal?: AbortSignal ) => Promise; export function fetchSearch(q: string, signal?: AbortSignal): Promise { return apiFetch(`/api/instruments?q=${encodeURIComponent(q)}`, { signal }); } export interface SearchHandlers { onBusy(): void; onResult(res: InstrumentSearchResponse): void; onError(message: string): void; onFinish(): void; } export interface SearchRunner { runNow(q: string): void; requestDebounced(q: string): void; dispose(): void; } export interface SearchRunnerOptions { timeoutMs?: number; debounceMs?: number; } export function createSearchRunner( doFetch: SearchFetcher = fetchSearch, handlers: SearchHandlers, options: SearchRunnerOptions = {}, ): SearchRunner { const timeoutMs = options.timeoutMs ?? SEARCH_TIMEOUT_MS; const debounceMs = options.debounceMs ?? SEARCH_DEBOUNCE_MS; let seq = 0; let controller: AbortController | null = null; let timer: ReturnType | null = null; function clearTimer() { if (timer) { clearTimeout(timer); timer = null; } } async function run(q: string) { const mySeq = ++seq; // abort the previous in-flight request first: its callbacks all become // stale (mySeq !== seq) and must not touch the UI controller?.abort(); controller = new AbortController(); const ctl = controller; const timeout = setTimeout(() => ctl.abort(), timeoutMs); timer = timeout; handlers.onBusy(); try { const res = await doFetch(q, ctl.signal); if (mySeq !== seq) return; if (res.status === 'ok') { handlers.onResult(res); } else { handlers.onResult({ items: [], source: 'none', status: res.status }); handlers.onError(SEARCH_UNAVAILABLE_MSG); } } catch (e) { if (mySeq !== seq) return; const signalAborted = ctl.signal.aborted; const abortError = (e as { name?: string }).name === 'AbortError'; handlers.onError( signalAborted || abortError ? SEARCH_TIMEOUT_MSG : extractStatusMessage(e), ); } finally { if (timer === timeout) timer = null; clearTimeout(timeout); // stale requests never touch busy state (their handler may already have // been superseded); only the still-current query may reset the spinner if (mySeq === seq) handlers.onFinish(); } } return { runNow: (q: string) => { clearTimer(); if (!q.trim()) return; void run(q.trim()); }, requestDebounced: (q: string) => { clearTimer(); if (!q.trim()) return; timer = setTimeout(() => { timer = null; void run(q.trim()); }, debounceMs); }, dispose: () => { clearTimer(); seq += 1; // invalidate any in-flight callbacks controller?.abort(); controller = null; }, }; }