summaryrefslogtreecommitdiff
path: root/frontend/src/lib/instrumentSearch.ts
blob: 8a83a47e9420bc1fb2de6393ccca398abbbaf340 (plain)
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;
    },
  };
}