import { useCallback, useEffect, useRef, useState } from 'react'; import { eventTarget } from '@cornerstonejs/core'; import { Enums, annotation } from '@cornerstonejs/tools'; import { toStored } from './annotationKeying.ts'; import type { StoredAnnotation, StoredAnnotationState } from './annotationKeying.ts'; import type { WadoRsFrame } from './healthImagingToWadoRs.ts'; import type { AnnotationEdition, OwnedAnnotationRequest, SaveAnswer, SavedAnnotations } from '../../services/ImagingAnnotationService.tsx'; import { useOptionalAuthServiceContext } from '../../services/AuthService.tsx'; import { SaveRetiredError, isSaveRetired } from '../../utils/saveOperation'; import { onViewerChange, viewerEpoch } from '../../utils/writeThroughHold'; // A single drag on a measurement tool emits dozens of ANNOTATION_MODIFIED events; writing one save // per event would be dozens of HealthLake writes for one measurement. 1s of quiet after the last // edit is the signal that the practitioner has actually stopped adjusting it. const DEBOUNCE_MS = 1000; /** * Why saving is paused, with nothing written over what is saved. `load`: the saved measurements could not be read. * `changed`: another window or session saved first. `unknown`: a save may or may not have landed. */ export type AutosaveHold = 'load' | 'changed' | 'unknown'; // A FAILED SAVE MUST NEVER LOSE WORK. The measurement state lives in Cornerstone the whole time, read fresh on // every save, and nothing here clears it. A save is made over the edition this view last saw: its own accepted // save advances that edition, and nothing else does. A refusal, an unknown outcome or an unreadable load holds // saving (and so promotion) until the practitioner checks or reloads - never an automatic rebase or retry. export const useAnnotationAutosave = (options: { // Not read by this hook: the caller's `save` closure already targets a specific study. studyId: string; frames: WadoRsFrame[]; enabled: boolean; // The edition this view loaded: null for nothing saved yet; 'loading' until the read answers; 'failed' when it // could not be read, which holds saving rather than reading as nothing saved. loadedEdition: AnnotationEdition | null | 'loading' | 'failed'; save: (state: StoredAnnotationState, basedOn: AnnotationEdition | null, owned: OwnedAnnotationRequest) => Promise; // GET only, for Check saved measurements. read: (owned: OwnedAnnotationRequest) => Promise; // WHAT THE LOAD-TIME fromStored COULD NOT RESOLVE (SOPInstanceUID absent from the current frames). Cornerstone // never held these, so every save merges them back or the first one silently erases them. unresolved: StoredAnnotation[]; }): { status: 'idle' | 'saving' | 'error' | 'held'; hold: AutosaveHold | undefined; dirty: boolean; flush: () => Promise; checkSaved: () => Promise; promotionRequest: () => { owned: OwnedAnnotationRequest; belongs: () => boolean }; } => { const { frames, enabled, save, read, unresolved, loadedEdition } = options; // THIS OPENING'S OWNER: the person signed in and the practice when it mounted. A save is sent only while both // still hold, and its answer is published only while they do and the view is still here. One mount is one // opening - the viewer keys the viewport per study and reload. const practiceRef = useOptionalAuthServiceContext()?.wpOrganization; const owner = useRef<{ epoch: number; practice: string | undefined; controller: AbortController } | null>(null); if (!owner.current) owner.current = { epoch: viewerEpoch(), practice: practiceRef?.current?.id, controller: new AbortController() }; const belongs = useCallback(() => { const mine = owner.current!; return viewerEpoch() === mine.epoch && practiceRef?.current?.id === mine.practice && !mine.controller.signal.aborted; }, [practiceRef]); const owned = useCallback((): OwnedAnnotationRequest => ({ admit: () => { if (!belongs()) throw new SaveRetiredError(); }, signal: owner.current!.controller.signal, organizationId: owner.current!.practice, }), [belongs]); const mounted = useRef(true); // A promotion is this view's own act: unlike the closing save, it is never sent for a view that has closed. const promotions = useRef(new AbortController()); useEffect(() => { mounted.current = true; const mine = new AbortController(); promotions.current = mine; const stop = onViewerChange(() => owner.current?.controller.abort()); return () => { mounted.current = false; mine.abort(); stop(); }; }, []); const publishes = useCallback(() => mounted.current && belongs(), [belongs]); const [status, setStatus] = useState<'idle' | 'saving' | 'error' | 'held'>(loadedEdition === 'failed' ? 'held' : 'idle'); const [hold, setHold] = useState(loadedEdition === 'failed' ? 'load' : undefined); const [dirty, setDirty] = useState(false); // The edition the next save names, and why saving is held. Refs, so a save already queued reads the newest. const basedOnRef = useRef(loadedEdition === 'failed' ? 'loading' : loadedEdition); const holdRef = useRef(loadedEdition === 'failed' ? 'load' : undefined); // The save whose outcome is not known: exactly what it sent, over exactly which edition. const unknownRef = useRef<{ state: StoredAnnotationState; basedOn: AnnotationEdition | null } | null>(null); const holdWith = useCallback((reason: AutosaveHold) => { holdRef.current = reason; if (!mounted.current) return; setHold(reason); setStatus('held'); }, []); const framesRef = useRef(frames); framesRef.current = frames; const saveRef = useRef(save); saveRef.current = save; const readRef = useRef(read); readRef.current = read; const unresolvedRef = useRef(unresolved); unresolvedRef.current = unresolved; const timerRef = useRef | null>(null); // An edit since the last flush attempt; only unmount reads it, so closing a view nobody touched saves nothing. const pendingRef = useRef(false); // Edits counted, so a save that lands is "clean" only if nothing was drawn while it was out. const editsRef = useRef(0); const clearTimer = useCallback(() => { if (timerRef.current !== null) { clearTimeout(timerRef.current); timerRef.current = null; } }, []); const runningRef = useRef | null>(null); const queuedRef = useRef | null>(null); // What the queued save will send: the newest capture asked for while another save was out. const queuedStateRef = useRef<{ state: StoredAnnotationState; edits: number } | null>(null); // This opening's drawings as they are now, built synchronously: the viewport clears Cornerstone as it closes, // and another view may fill it, so nothing may be read from it later on this opening's behalf. const capture = useCallback((): { state: StoredAnnotationState; edits: number } | null => { try { const liveAnnotations = annotation.state.getAllAnnotations() as unknown[]; return { state: toStored(liveAnnotations, framesRef.current, unresolvedRef.current).state, edits: editsRef.current }; } catch { return null; } }, []); // Saves exactly the captured state over the edition current when it runs: its predecessor's accepted one. const runSave = useCallback(async (captured: { state: StoredAnnotationState; edits: number }): Promise => { const basedOn = basedOnRef.current; if (holdRef.current || basedOn === 'loading' || !belongs()) return null; if (mounted.current) setStatus('saving'); const { state } = captured; let answer: SaveAnswer; try { const given: SaveAnswer | undefined = await saveRef.current(state, basedOn, owned()); // An answer that says nothing is not "saved": the write may or may not have landed. answer = given && typeof given === 'object' && 'kind' in given ? given : { kind: 'unknown' }; } catch (error) { // Retired before it was sent: nothing was written and nothing is published. if (isSaveRetired(error)) return null; answer = { kind: 'unknown' }; } if (!belongs()) return null; if (answer.kind === 'saved') { basedOnRef.current = answer.edition; if (mounted.current) { setStatus('idle'); if (editsRef.current === captured.edits) setDirty(false); } return answer.edition; } if (answer.kind === 'changed') holdWith('changed'); else if (answer.kind === 'unknown') { unknownRef.current = { state, basedOn }; holdWith('unknown'); } else if (mounted.current) setStatus('error'); return null; }, [owned, belongs, holdWith]); const start = useCallback((captured: { state: StoredAnnotationState; edits: number }): Promise => { const run = runSave(captured); runningRef.current = run; void run.then(() => { if (runningRef.current === run) runningRef.current = null; }); return run; }, [runSave]); // SINGLE-FLIGHT, AND NOT AS AN OPTIMISATION: two saves racing leave whichever landed last, and promotion charts // that. A caller arriving while a save is out joins the one queued save, which sends the newest capture once the // running save has answered, over the edition it admitted. Resolves the edition its own save admitted, or null. const flush = useCallback((): Promise => { clearTimer(); // Held, or not yet loaded: nothing is written, and the edit stays pending for when saving may resume. if (holdRef.current || basedOnRef.current === 'loading') return Promise.resolve(null); pendingRef.current = false; const captured = capture(); if (!captured) { if (mounted.current) setStatus('error'); return Promise.resolve(null); } if (queuedRef.current) { queuedStateRef.current = captured; return queuedRef.current; } const running = runningRef.current; if (!running) return start(captured); queuedStateRef.current = captured; const queued = running.then(() => { if (queuedRef.current === queued) queuedRef.current = null; const next = queuedStateRef.current ?? captured; queuedStateRef.current = null; return start(next); }); queuedRef.current = queued; return queued; }, [clearTimer, start, capture]); const scheduleAgain = useCallback(() => { if (holdRef.current) return; clearTimer(); timerRef.current = setTimeout(() => { void flush(); }, DEBOUNCE_MS); }, [clearTimer, flush]); const scheduleFlush = useCallback(() => { pendingRef.current = true; editsRef.current += 1; setDirty(true); scheduleAgain(); }, [scheduleAgain]); // GET only. A saved payload equal to what the unknown save sent settles it and adopts that edition - which does // not prove which save wrote it. The edition it was made over, unchanged, means it did not land. Anything else // is somebody else's save, and stays held as changed. const checkSaved = useCallback(async (): Promise => { const unknown = unknownRef.current; if (holdRef.current !== 'unknown' || !unknown) return; let current: SavedAnnotations; try { current = await readRef.current(owned()); } catch { return; } if (!publishes() || holdRef.current !== 'unknown') return; const sameAsSent = current.edition !== null && JSON.stringify(current.annotations) === JSON.stringify(unknown.state); const untouched = current.edition === null ? unknown.basedOn === null : unknown.basedOn !== null && current.edition.documentId === unknown.basedOn.documentId && current.edition.versionId === unknown.basedOn.versionId; if (!sameAsSent && !untouched) { holdWith('changed'); return; } basedOnRef.current = sameAsSent ? current.edition : unknown.basedOn; unknownRef.current = null; holdRef.current = undefined; setHold(undefined); setStatus('idle'); if (pendingRef.current) void flush(); }, [owned, flush, publishes, holdWith]); // The load answers once. An edition or a known absence is adopted only if nothing has replaced it; a read that // failed holds saving, so nothing is written over measurements this view never saw. useEffect(() => { if (loadedEdition === 'loading') return; if (loadedEdition === 'failed') { if (!holdRef.current) holdWith('load'); return; } if (basedOnRef.current !== 'loading' || holdRef.current) return; basedOnRef.current = loadedEdition; if (pendingRef.current) scheduleAgain(); }, [loadedEdition, holdWith, scheduleAgain]); useEffect(() => { // A patient must never trigger a write, so a disabled hook subscribes to nothing at all. if (!enabled) return; const handleVisibilityChange = (): void => { if (document.visibilityState === 'hidden') void flush(); }; const handlePageHide = (): void => { void flush(); }; eventTarget.addEventListener(Enums.Events.ANNOTATION_COMPLETED, scheduleFlush); eventTarget.addEventListener(Enums.Events.ANNOTATION_MODIFIED, scheduleFlush); eventTarget.addEventListener(Enums.Events.ANNOTATION_REMOVED, scheduleFlush); // THE WINDOW BETWEEN FINISHING A MEASUREMENT AND CLOSING THE TAB IS EXACTLY WHEN WORK IS LOST. window.addEventListener('pagehide', handlePageHide); document.addEventListener('visibilitychange', handleVisibilityChange); return () => { clearTimer(); eventTarget.removeEventListener(Enums.Events.ANNOTATION_COMPLETED, scheduleFlush); eventTarget.removeEventListener(Enums.Events.ANNOTATION_MODIFIED, scheduleFlush); eventTarget.removeEventListener(Enums.Events.ANNOTATION_REMOVED, scheduleFlush); window.removeEventListener('pagehide', handlePageHide); document.removeEventListener('visibilitychange', handleVisibilityChange); // THE FLUSH PATH THAT ACTUALLY GETS USED: an in-app route change fires no pagehide. It saves this // opening's own state, over its own edition, as its own owner - and publishes nothing once unmounted. if (pendingRef.current) void flush(); }; }, [enabled, scheduleFlush, clearTimer, flush]); const promotionRequest = useCallback(() => { const live = () => mounted.current && belongs() && !promotions.current.signal.aborted; return { owned: { admit: () => { if (!live()) throw new SaveRetiredError(); }, signal: promotions.current.signal, organizationId: owner.current!.practice, }, belongs: live, }; }, [belongs]); return { status, hold, dirty, flush, checkSaved, promotionRequest }; };