import { isAsOfIncomplete } from '../utils/searchTruncation'; import { useEffect, useRef, useState, useCallback } from 'react'; import { usePatientServiceContext, useRelatedPersonServiceContext } from '../services'; import { describeFetchFailure, isRefusedRead } from '../utils/fetchFailureMessage'; import { patientCacheGuard } from '../utils/cacheOwner'; import { viewerEpoch } from '../utils/writeThroughHold'; interface UseFhirRelatedPersonsOptions { autoLoad?: boolean; errorMessage?: string; forceReload?: boolean; } interface UseFhirRelatedPersonsReturn { /** Served as of a cutoff and not in full: what it left out is unknown, never none. */ incomplete: boolean; loading: boolean; error: string | undefined; /** The server refused this list (403): a settled answer, not a failure to retry. */ refused: boolean; loadRelatedPersons: () => Promise; clearError: () => void; relatedPersonsBundle: ReturnType['relatedPersonsBundle']; relatedPersonsList: ReturnType['relatedPersonsList']; } export const useFhirRelatedPersons = (options: UseFhirRelatedPersonsOptions = {}): UseFhirRelatedPersonsReturn => { const { autoLoad = true, errorMessage = "Failed to load related persons", forceReload = false } = options; const { patientState } = usePatientServiceContext(); const { cachedForPatientId, fetchRelatedPersonsWithPolling, relatedPersonsBundle, relatedPersonsList } = useRelatedPersonServiceContext(); // What a read settles belongs to that read: its scope's lifetime and only the newest read asked in it. Every // change of viewer, practice or patient starts a new lifetime as it renders, so A, B, A is never the first A. const latest = useRef(0); const scopeKey = `${viewerEpoch()}|${patientState?.id ?? ''}`; const scope = useRef({ key: scopeKey, lifetime: 0 }); if (scope.current.key !== scopeKey) scope.current = { key: scopeKey, lifetime: scope.current.lifetime + 1 }; const lifetimeNow = scope.current.lifetime; const [pending, setPending] = useState<{ lifetime: number; generation: number } | undefined>(); const [settled, setSettled] = useState<{ lifetime: number; error: string; refused: boolean } | undefined>(); const loading = pending?.lifetime === lifetimeNow; const error = settled?.lifetime === lifetimeNow ? settled.error : undefined; const refused = settled?.lifetime === lifetimeNow && settled.refused; const loadRelatedPersons = useCallback(async () => { if (!patientState) { return; } // Owned by the render that made it: a callback kept past any change of patient, viewer or practice, even // one that came back, reads nothing for the scope on screen now. const key = scopeKey; const lifetime = lifetimeNow; if (scope.current.lifetime !== lifetime || `${viewerEpoch()}|${patientState.id ?? ''}` !== key) return; const generation = ++latest.current; const current = () => generation === latest.current && scope.current.lifetime === lifetime && `${viewerEpoch()}|${patientState.id ?? ''}` === key; setPending({ lifetime, generation }); setSettled(undefined); try { await fetchRelatedPersonsWithPolling(patientState); } catch (err) { console.error('Failed to load related persons:', err); if (current()) setSettled({ lifetime, error: describeFetchFailure(err, errorMessage), refused: isRefusedRead(err) }); } finally { if (generation === latest.current) setPending(undefined); } }, [patientState, fetchRelatedPersonsWithPolling, errorMessage, scopeKey, lifetimeNow]); const clearError = useCallback(() => { setSettled(undefined); }, []); useEffect(() => { if (!autoLoad) return; if (!patientState) return; // A bundle belonging to SOMEBODY ELSE is not a reason not to ask. This used to fire only when // there was no bundle at all, so moving between two charts left the previous patient's list in // place, with no request and nothing on screen to say it was not theirs. // NOT a refetch on every navigation: same patient, bundle loaded, no request. if (!relatedPersonsBundle || cachedForPatientId !== patientState.id || forceReload) { loadRelatedPersons(); } }, [autoLoad, patientState, relatedPersonsBundle, cachedForPatientId, forceReload, loadRelatedPersons]); // The cache outlives a change of patient: hand out only the patient asked about. const guard = patientCacheGuard(cachedForPatientId, patientState?.id); return { loading: guard.loading(loading, Boolean(error)), error, refused, loadRelatedPersons, clearError, // Served as of a cutoff and not in full: what it left out is unknown, never none. incomplete: isAsOfIncomplete(guard.bundle(relatedPersonsBundle, null)), relatedPersonsBundle: guard.bundle(relatedPersonsBundle, null), relatedPersonsList: guard.list(relatedPersonsList) }; };