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));
|
||||
|
||||
// Per-page in-memory cache: a sweep across 20 mentions of the same person
|
||||
// must not fire 20 backend calls (B15.5). The Promise<HoverData | null> 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<HoverData | null>
|
||||
// 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 deletedPersonIds = new SvelteSet<string>();
|
||||
|
||||
@@ -57,25 +62,32 @@ function renderBlockHtml(block: TranscriptionBlockData): 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;
|
||||
|
||||
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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user