File size: 2,568 Bytes
dd079af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8d1387
 
 
 
 
 
 
 
 
 
dd079af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { useEffect, useRef, useState } from 'react';

export interface AsyncState<T> {
  data: T | null;
  error: Error | null;
  loading: boolean;
}

const cache = new Map<string, unknown>();

/**
 * Fetch data keyed by `key`; aborts stale requests and memoises successful
 * responses for the session so back/forward navigation is instant.
 */
export function useApi<T>(key: string | null, fetcher: (signal: AbortSignal) => Promise<T>): AsyncState<T> {
  const cached = key ? (cache.get(key) as T | undefined) : undefined;
  const [state, setState] = useState<AsyncState<T>>({
    data: cached ?? null,
    error: null,
    loading: key !== null && cached === undefined,
  });
  const fetcherRef = useRef(fetcher);
  fetcherRef.current = fetcher;

  useEffect(() => {
    if (key === null) {
      setState({ data: null, error: null, loading: false });
      return;
    }
    const hit = cache.get(key) as T | undefined;
    if (hit !== undefined) {
      setState({ data: hit, error: null, loading: false });
      return;
    }
    const controller = new AbortController();
    setState((s) => ({ data: s.data, error: null, loading: true }));
    fetcherRef
      .current(controller.signal)
      .then((data) => {
        cache.set(key, data);
        if (!controller.signal.aborted) setState({ data, error: null, loading: false });
      })
      .catch((error: Error) => {
        if (!controller.signal.aborted) setState({ data: null, error, loading: false });
      });
    return () => controller.abort();
  }, [key]);

  return state;
}

/** Warm the useApi cache for a key (e.g. the next thing a user is likely to open). */
export function prefetch<T>(key: string, fetcher: () => Promise<T>): void {
  if (cache.has(key)) return;
  fetcher()
    .then((data) => cache.set(key, data))
    .catch(() => {
      /* best effort */
    });
}

/** Resolve a best-effort promise (e.g. an RCSB title) into state. */
export function usePromise<T>(factory: () => Promise<T> | null, deps: unknown[]): T | null {
  const [value, setValue] = useState<T | null>(null);
  useEffect(() => {
    let alive = true;
    setValue(null);
    const p = factory();
    p?.then((v) => alive && setValue(v));
    return () => {
      alive = false;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);
  return value;
}

export function useDebounced<T>(value: T, ms: number): T {
  const [v, setV] = useState(value);
  useEffect(() => {
    const t = setTimeout(() => setV(value), ms);
    return () => clearTimeout(t);
  }, [value, ms]);
  return v;
}