/** * Stale-while-revalidate resource cache. * * Backs list pages so that switching between sidebar tabs doesn't flash an * empty/skeleton frame on every visit. The first visit to a page does a cold * load (skeleton shown once); every subsequent visit renders the previously * loaded value immediately while a background refresh keeps it current. * * The cache is module-scoped (one instance per resource, created in * `caches.ts`) so its value survives component unmount/remount across * client-side navigation — that persistence is what removes the flicker. * * Style mirrors `navCounts.ts`: a classic `writable` exposed as a `Readable` * plus imperative `refresh()`/`set()` methods, so pages keep using `$store` * auto-subscription. */ import { writable, get, type Readable } from 'svelte/store'; export interface ResourceState { /** Last successfully loaded value (or the initial seed before first load). */ value: T; /** * True only during the *cold* load — i.e. the resource has never loaded * successfully and there is no error yet. Drives the skeleton. Stays false * for background refreshes so revisits never flash. */ loading: boolean; /** True while any fetch (cold or background) is in flight. */ refreshing: boolean; /** Last error message, or '' when the most recent fetch succeeded. */ error: string; /** Whether at least one fetch has ever succeeded. */ loaded: boolean; } export interface ResourceCache extends Readable> { /** * (Re)fetch the resource. Aborts any in-flight fetch so the latest call * wins — important after a mutation, where a stale background refresh must * not clobber fresh data. Safe to call from `onMount` on every visit. */ refresh(): Promise; /** Optimistically replace the cached value (e.g. after a local mutation). */ set(value: T): void; /** Clear back to the cold/initial state (e.g. on logout). */ reset(): void; } export function createResourceCache( fetcher: (signal?: AbortSignal) => Promise, initial: T ): ResourceCache { const seed: ResourceState = { value: initial, loading: true, refreshing: false, error: '', loaded: false }; const store = writable>(seed); let inFlight: AbortController | null = null; async function refresh(): Promise { // Latest-wins: cancel a previous in-flight fetch so a slow background // refresh can't overwrite a newer (e.g. post-mutation) result. if (inFlight) inFlight.abort(); const ac = new AbortController(); inFlight = ac; store.update((s) => ({ ...s, refreshing: true, error: '' })); try { const value = await fetcher(ac.signal); if (ac.signal.aborted) return; store.set({ value, loading: false, refreshing: false, error: '', loaded: true }); } catch (e) { if (ac.signal.aborted) return; const error = e instanceof Error ? e.message : String(e); // Keep the last good value (stale-while-revalidate) and surface the // error; clear `loading` so the cold skeleton resolves to either data // or an error state rather than spinning forever. store.update((s) => ({ ...s, loading: false, refreshing: false, error })); } finally { if (inFlight === ac) inFlight = null; } } function set(value: T): void { store.update((s) => ({ ...s, value, loaded: true, loading: false })); } function reset(): void { if (inFlight) { inFlight.abort(); inFlight = null; } store.set({ ...seed, value: initial }); } return { subscribe: store.subscribe, refresh, set, reset }; } // ───────────────────────────────────────────────────────────────────────── // Keyed (per-id) variant — for detail pages fetched by a route param. // // A single-value cache can't serve detail pages (each `[id]` is different // data). This holds a Map so revisiting (or A→B→A navigating) a // detail renders the cached entity instantly while revalidating that id. // Pages read reactively via `$cache.get(id)` (the store value is the Map) and // call `cache.refresh(id)` / `cache.peek(id)` (methods on the object). // ───────────────────────────────────────────────────────────────────────── export interface KeyedResourceState { /** Last loaded value for this key, or null before its first load. */ value: T | null; /** True on the cold load for this key (never loaded, no error). */ loading: boolean; /** True while a fetch for this key is in flight. */ refreshing: boolean; error: string; loaded: boolean; } function coldKeyed(): KeyedResourceState { return { value: null, loading: true, refreshing: false, error: '', loaded: false }; } export interface KeyedResourceCache extends Readable>> { /** (Re)fetch one key; aborts a prior in-flight fetch for the same key. */ refresh(key: string): Promise; /** Optimistically replace one key's value (e.g. after a mutation). */ set(key: string, value: T): void; /** Non-reactive snapshot read; returns the cold state if the key is absent. */ peek(key: string): KeyedResourceState; /** Drop a single key (e.g. after deleting that entity) so a later visit * doesn't warm-seed a stale/phantom value. */ remove(key: string): void; /** Drop all keys (e.g. on logout). */ reset(): void; } export function createKeyedResourceCache( fetcher: (key: string, signal?: AbortSignal) => Promise ): KeyedResourceCache { const store = writable>>(new Map()); const inFlight = new Map(); function patch(key: string, partial: Partial>): void { store.update((m) => { const next = new Map(m); next.set(key, { ...(next.get(key) ?? coldKeyed()), ...partial }); return next; }); } async function refresh(key: string): Promise { const prev = inFlight.get(key); if (prev) prev.abort(); const ac = new AbortController(); inFlight.set(key, ac); patch(key, { refreshing: true, error: '' }); try { const value = await fetcher(key, ac.signal); if (ac.signal.aborted) return; patch(key, { value, loaded: true, loading: false, refreshing: false, error: '' }); } catch (e) { if (ac.signal.aborted) return; patch(key, { loading: false, refreshing: false, error: e instanceof Error ? e.message : String(e) }); } finally { if (inFlight.get(key) === ac) inFlight.delete(key); } } function set(key: string, value: T): void { patch(key, { value, loaded: true, loading: false }); } function peek(key: string): KeyedResourceState { return get(store).get(key) ?? coldKeyed(); } function remove(key: string): void { const ac = inFlight.get(key); if (ac) { ac.abort(); inFlight.delete(key); } store.update((m) => { if (!m.has(key)) return m; const next = new Map(m); next.delete(key); return next; }); } function reset(): void { for (const ac of inFlight.values()) ac.abort(); inFlight.clear(); store.set(new Map()); } return { subscribe: store.subscribe, refresh, set, peek, remove, reset }; }