import { useCallback, useEffect, useRef, useState } from 'react'; import { useValueSetServiceContext, type IValueSet } from '../../../services'; import { mergeThingMatches, type RecordThing } from '../../../utils/recordThingSearch'; /** * One box, three lookups, one ranked list of things. * * A prescription and a diagnosis come from their own value sets on the terminology server; a lab order * comes from the LOINC search the product already uses to place one. Nothing new is asked of the * server - this reads the same three sources the three surfaces already read, so a thing findable here * is a thing that can actually be filed. * * FAILURE IS PER SOURCE, NOT PER SEARCH. One lookup being down must not empty the box: a clinician * looking for a drug is not helped by losing it because the LOINC service is unavailable. Each source * contributes what it has, and `unavailable` says so rather than reading as "there is no such test". */ const MIN_TERM = 2; const DEBOUNCE_MS = 250; export interface ThingSearchState { searching: boolean; things: readonly RecordThing[]; /** Sources that could not be asked. Named, because "no matches" and "we could not look" differ. */ unavailable: readonly string[]; } const EMPTY: ThingSearchState = { searching: false, things: [], unavailable: [] }; const asThings = ( results: readonly IValueSet[] | undefined, kind: RecordThing['kind'], ): RecordThing[] => (results ?? []) .filter((entry) => typeof entry?.display === 'string' && entry.display.trim() !== '') .map((entry) => ({ id: entry.code ?? (entry.display as string), label: (entry.display as string).trim(), kind, ...(entry.code ? { code: entry.code } : {}), ...(entry.system ? { system: entry.system } : {}), })); export const useRecordThingSearch = (term: string): ThingSearchState => { const valueSets = useValueSetServiceContext(); const [state, setState] = useState(EMPTY); /* THE SERVICE IS HELD BY REFERENCE, NOT BY DEPENDENCY, and it is the difference between a search box and a spin. The value-set context is a plain object rebuilt on its provider's every render, so depending on the functions it hands out makes this effect re-run on every render - which sets state, which renders, which re-runs the effect. It never stops, and the tell is a component that looks correct in the source and hangs the moment anything mounts it. Only `term` may restart a search, because only `term` is what the clinician changed. */ const services = useRef(valueSets); services.current = valueSets; /* THE STALE-ANSWER GUARD, and it is not optional on a box somebody types into. Three lookups fire per keystroke and they do not return in order - so a slow answer for "met" can land after a fast one for "metformin" and replace the right list with the wrong one, seconds later, while the clinician is reading it. Every run carries a number and only the newest may write. */ const runId = useRef(0); const search = useCallback(async (trimmed: string, currentId: number) => { const { searchValueSetWithStatus, searchLoincCodesWithStatus, valueSetUrls } = services.current; /* EVERY SOURCE IS ASKED THROUGH A STATUS-AWARE CALL, and that is the whole of this block. WALKED AND FOUND, 2026-09-04, during the terminology rebuild: the medication and condition lookups went through `fetchValueSetInternal`, which CATCHES its own failures and returns an empty array - deliberately, because every coded control in the product depends on it failing soft. So a dead terminology server and a drug nobody stocks arrived here as the same value, the `.catch` never fired, and the notice this hook exists to raise could never appear. Observed in the product: a clinician searching `metformin` got six lab tests, no drugs, and nothing saying the drug lookup was down. */ const [medications, conditions, labs] = await Promise.all([ Promise.resolve() .then(() => searchValueSetWithStatus(valueSetUrls.medicationCodes, trimmed)) .then((r) => (r.outcome === 'ok' ? asThings(r.results, 'medication') : null)) .catch(() => null), Promise.resolve() .then(() => searchValueSetWithStatus(valueSetUrls.conditionCode, trimmed)) .then((r) => (r.outcome === 'ok' ? asThings(r.results, 'condition') : null)) .catch(() => null), Promise.resolve() .then(() => searchLoincCodesWithStatus(trimmed)) .then((r) => (r.outcome === 'ok' ? asThings(r.results, 'labOrder') : null)) .catch(() => null), ]); if (currentId !== runId.current) return; const unavailable = [ medications === null ? 'medications' : null, conditions === null ? 'diagnoses' : null, labs === null ? 'tests' : null, ].filter((name): name is string => name !== null); setState({ searching: false, things: mergeThingMatches([...(medications ?? []), ...(conditions ?? []), ...(labs ?? [])], trimmed), unavailable, }); }, []); useEffect(() => { const trimmed = term.trim(); // A ONE-LETTER SEARCH IS NOT A SEARCH. It matches most of a terminology, so it costs three // round trips to return a list nobody wants - and it fires on the way to every real query. if (trimmed.length < MIN_TERM) { runId.current += 1; setState(EMPTY); return; } const currentId = ++runId.current; setState((previous) => ({ ...previous, searching: true })); const timer = setTimeout(() => { void search(trimmed, currentId); }, DEBOUNCE_MS); return () => clearTimeout(timer); }, [term, search]); return state; }; export default useRecordThingSearch;