1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
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;
},
};
}
|