diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-17 14:32:37 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-17 14:32:37 +0800 |
| commit | 5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch) | |
| tree | 948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /frontend/src/lib/instrumentSearch.ts | |
| download | strategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz | |
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src/lib/instrumentSearch.ts')
| -rw-r--r-- | frontend/src/lib/instrumentSearch.ts | 120 |
1 files changed, 120 insertions, 0 deletions
diff --git a/frontend/src/lib/instrumentSearch.ts b/frontend/src/lib/instrumentSearch.ts new file mode 100644 index 0000000..8a83a47 --- /dev/null +++ b/frontend/src/lib/instrumentSearch.ts @@ -0,0 +1,120 @@ +// 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<InstrumentSearchResponse>; + +export function fetchSearch(q: string, signal?: AbortSignal): Promise<InstrumentSearchResponse> { + return apiFetch<InstrumentSearchResponse>(`/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<typeof setTimeout> | 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; + }, + }; +} |
