81 lines
2.6 KiB
TypeScript
81 lines
2.6 KiB
TypeScript
/**
|
|
* 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<T> {
|
|
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<string, CacheEntry<unknown>>()
|
|
|
|
export async function swrFetch<T>(
|
|
key: string,
|
|
ttlMs: number,
|
|
fetcher: () => Promise<T>,
|
|
onUpdate?: (fresh: T) => void,
|
|
): Promise<T> {
|
|
const now = Date.now()
|
|
const hit = store.get(key) as CacheEntry<T> | 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)
|
|
}
|