diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte b/frontend/src/lib/components/TranscriptionReadView.svelte index e4ca6d1c..b0ccdf71 100644 --- a/frontend/src/lib/components/TranscriptionReadView.svelte +++ b/frontend/src/lib/components/TranscriptionReadView.svelte @@ -26,9 +26,14 @@ let { blocks, onParagraphClick, highlightBlockId = null }: Props = $props(); let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder)); -// Per-page in-memory cache: a sweep across 20 mentions of the same person -// must not fire 20 backend calls (B15.5). The Promise shape -// lets simultaneous hovers share the same in-flight fetch. +// Per-component (per-mount) in-memory cache: a sweep across 20 mentions of the +// same person must not fire 20 backend calls (B15.5). The Promise +// shape lets simultaneous hovers share the same in-flight fetch. +// +// Trade-off: closing and re-opening the transcription panel rebuilds this cache +// (Elicit OQ-372-02). That's intentional — staleness from another tab deleting +// a person is rare in this read-only view, and a per-document/global cache would +// complicate invalidation. If user reports on stale cards accumulate, revisit. const hoverCache = new SvelteMap>(); const deletedPersonIds = new SvelteSet(); @@ -57,25 +62,32 @@ function renderBlockHtml(block: TranscriptionBlockData): SafeHtml { .join('') as SafeHtml; } -function fetchHoverData(personId: string): Promise { - let cached = hoverCache.get(personId); +/** + * Fetches person + relationships from the backend. 404 returns null + * (deleted person — caller marks the link as tombstoned). Any other + * non-OK response throws so the caller can render the error state. + */ +async function loadHoverData(personId: string): Promise { + const personRes = await fetch(`/api/persons/${personId}`); + if (personRes.status === 404) return null; + if (!personRes.ok) throw new Error(`person fetch failed: ${personRes.status}`); + const person = (await personRes.json()) as Person; + + const relRes = await fetch(`/api/persons/${personId}/relationships`); + const relationships: RelationshipDTO[] = relRes.ok + ? ((await relRes.json()) as RelationshipDTO[]) + : []; + return { person, relationships }; +} + +/** Cache wrapper around `loadHoverData` — first hover fires the fetch, all + * subsequent hovers (and concurrent in-flight ones) share the same Promise. */ +function getOrFetchHoverData(personId: string): Promise { + const cached = hoverCache.get(personId); if (cached) return cached; - - cached = (async () => { - const personRes = await fetch(`/api/persons/${personId}`); - if (personRes.status === 404) return null; - if (!personRes.ok) throw new Error(`person fetch failed: ${personRes.status}`); - const person = (await personRes.json()) as Person; - - const relRes = await fetch(`/api/persons/${personId}/relationships`); - const relationships: RelationshipDTO[] = relRes.ok - ? ((await relRes.json()) as RelationshipDTO[]) - : []; - return { person, relationships }; - })(); - - hoverCache.set(personId, cached); - return cached; + const promise = loadHoverData(personId); + hoverCache.set(personId, promise); + return promise; } function currentViewport() { @@ -102,7 +114,7 @@ async function handleMentionEnter(event: Event) { activeCard = { personId, cardId, position, state: { status: 'loading' } }; try { - const data = await fetchHoverData(personId); + const data = await getOrFetchHoverData(personId); // Bail if a different mention is now active if (!activeCard || activeCard.personId !== personId) return;