refactor(person-mention): split fetchHoverData into pure load + cache wrapper
Felix #1: fetchHoverData was doing four things — cache lookup, fetch, JSON parsing, 404 normalisation. Split into: loadHoverData(personId) — pure fetch + 404→null + non-OK→throw getOrFetchHoverData(personId) — five-line cache wrapper around the above Also document the cache-lifetime trade-off (Markus #4, Elicit OQ-372-02): the cache is per-mount, so closing and reopening the transcription panel rebuilds it. That's intentional given the read-only nature of the view — revisit if stale-card user reports surface. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,9 +26,14 @@ let { blocks, onParagraphClick, highlightBlockId = null }: Props = $props();
|
|||||||
|
|
||||||
let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
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
|
// Per-component (per-mount) in-memory cache: a sweep across 20 mentions of the
|
||||||
// must not fire 20 backend calls (B15.5). The Promise<HoverData | null> shape
|
// same person must not fire 20 backend calls (B15.5). The Promise<HoverData | null>
|
||||||
// lets simultaneous hovers share the same in-flight fetch.
|
// 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<string, Promise<HoverData | null>>();
|
const hoverCache = new SvelteMap<string, Promise<HoverData | null>>();
|
||||||
const deletedPersonIds = new SvelteSet<string>();
|
const deletedPersonIds = new SvelteSet<string>();
|
||||||
|
|
||||||
@@ -57,25 +62,32 @@ function renderBlockHtml(block: TranscriptionBlockData): SafeHtml {
|
|||||||
.join('') as SafeHtml;
|
.join('') as SafeHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchHoverData(personId: string): Promise<HoverData | null> {
|
/**
|
||||||
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<HoverData | null> {
|
||||||
|
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<HoverData | null> {
|
||||||
|
const cached = hoverCache.get(personId);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
const promise = loadHoverData(personId);
|
||||||
cached = (async () => {
|
hoverCache.set(personId, promise);
|
||||||
const personRes = await fetch(`/api/persons/${personId}`);
|
return promise;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentViewport() {
|
function currentViewport() {
|
||||||
@@ -102,7 +114,7 @@ async function handleMentionEnter(event: Event) {
|
|||||||
activeCard = { personId, cardId, position, state: { status: 'loading' } };
|
activeCard = { personId, cardId, position, state: { status: 'loading' } };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchHoverData(personId);
|
const data = await getOrFetchHoverData(personId);
|
||||||
// Bail if a different mention is now active
|
// Bail if a different mention is now active
|
||||||
if (!activeCard || activeCard.personId !== personId) return;
|
if (!activeCard || activeCard.personId !== personId) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user