/** * Lightweight in-memory SWR cache for API data. * * Lives at module scope — persists across React renders and page navigations * within the same browser tab, but clears on hard-refresh / tab close. * * Usage: * const data = await swrFetch('posts-500', 3 * 60_000, () => getPosts(500)) * * On first call → fetches, caches, returns. * On subsequent calls within TTL → returns cached data immediately. * On calls after TTL → returns stale data immediately AND triggers a * background revalidation; the `onUpdate` callback fires when fresh * data arrives so the component can re-render without a loading flash. */ interface CacheEntry { data: T ts: number revalidating: boolean /** onUpdate callbacks of every caller that hit this key while a background * revalidation was in flight (M9 fix). All are notified when the fetch * resolves — previously only the caller that STARTED the revalidation got * fresh data; everyone else silently kept the stale copy. */ subscribers: Array<(fresh: T) => void> } const store = new Map>() export async function swrFetch( key: string, ttlMs: number, fetcher: () => Promise, onUpdate?: (fresh: T) => void, ): Promise { const now = Date.now() const hit = store.get(key) as CacheEntry | undefined if (hit) { const age = now - hit.ts if (age < ttlMs) { // Still fresh — return immediately. return hit.data } // Stale — return immediately AND revalidate in background. Every caller's // onUpdate joins the subscriber list; the in-flight fetch notifies all. if (onUpdate) hit.subscribers.push(onUpdate) if (!hit.revalidating) { hit.revalidating = true fetcher() .then(fresh => { const subs = hit.subscribers store.set(key, { data: fresh, ts: Date.now(), revalidating: false, subscribers: [] }) for (const notify of subs) { try { notify(fresh) } catch { /* one bad subscriber must not starve the rest */ } } }) .catch(() => { hit.revalidating = false hit.subscribers = [] }) } return hit.data } // Cache miss — must wait for the first fetch. const data = await fetcher() store.set(key, { data, ts: Date.now(), revalidating: false, subscribers: [] }) return data } /** Manually invalidate a cache key (e.g. after a write action). */ export function invalidate(key: string) { store.delete(key) } /** Peek at whether a key has any cached data (stale or fresh). */ export function hasCached(key: string): boolean { return store.has(key) }