// Stale-while-revalidate resource hook over the shared resources store. // // Usage: // const files = useCachedResource({ // key: 'cloud.my-files', // fetcher: (signal) => rpcClient.call({ method: 'content.list', signal, dedup: true }), // ttlMs: 30_000, // }) // // template: files.data renders instantly on revisit (cache), while // // files.loadState === 'refreshing' drives a subtle refresh indicator. // // Behavior: // - Synchronous hydrate: memory (survives navigation) → sessionStorage // snapshot (survives reload) → fetch. // - Sticky-ready: never regresses ready → loading; refreshes are // 'refreshing' so content stays on screen. // - Stale-while-revalidate: on mount, cached data is shown immediately and a // background refresh runs only if the TTL has lapsed (or never fetched). // - Keep-last-value on error, with `isStale`/`ageMs` for badges. // - revalidateOnFocus: refreshes when the tab regains focus and the data is // stale (debounced by TTL, so focus-flapping is free). // - Abort-on-unmount: the fetcher receives an AbortSignal that fires when // the last subscribed component unmounts. import { computed, getCurrentScope, onScopeDispose, type ComputedRef } from 'vue' import { useResourcesStore, type ResourceEntry, type ResourceLoadState } from '@/stores/resources' export interface CachedResourceOptions { /** Cache key. Include identifying params, e.g. `peer-files:${onion}`. */ key: string /** Fetch fresh data. Receives an abort signal tied to component lifetime. */ fetcher: (signal: AbortSignal) => Promise /** Data older than this triggers a background revalidate (default 30s). */ ttlMs?: number /** Snapshot to sessionStorage so reloads paint instantly (default true). * Disable for large payloads. */ persist?: boolean /** Revalidate (if stale) when the window regains focus (default true). */ revalidateOnFocus?: boolean /** Fetch on first use (default true). Set false for lazy resources. */ immediate?: boolean } export interface CachedResource { entry: ResourceEntry /** Convenience computed views over the entry. */ data: ComputedRef loadState: ComputedRef error: ComputedRef /** True when data exists but is older than the TTL (drive an age badge). */ isStale: ComputedRef ageMs: ComputedRef /** Force a refresh now (deduped with any in-flight one). */ refresh: () => Promise /** Mark stale + debounce-refresh all mounted users of this key. */ invalidate: () => void /** Optimistically update cached data; returns rollback for RPC failure. */ optimistic: (update: (current: T | null) => T) => () => void } export function useCachedResource(opts: CachedResourceOptions): CachedResource { const store = useResourcesStore() const ttlMs = opts.ttlMs ?? 30_000 const persist = opts.persist ?? true const entry = store.entry(opts.key, persist) const aborter = new AbortController() const fetcher = () => opts.fetcher(aborter.signal) const refresh = () => store.refresh(opts.key, fetcher, { persist }) const stale = () => entry.fetchedAt === null || Date.now() - entry.fetchedAt > ttlMs const refreshIfStale = () => { if (stale()) void refresh() } // Register as a live revalidator so invalidate(key) reaches us. const unsubscribe = store.subscribe(opts.key, () => void refresh()) const onFocus = () => refreshIfStale() if (opts.revalidateOnFocus ?? true) { window.addEventListener('focus', onFocus) } // Tied to the owning effect scope (component setup or manual scope); // outside any scope (tests, module init) there's nothing to dispose. if (getCurrentScope()) { onScopeDispose(() => { unsubscribe() window.removeEventListener('focus', onFocus) aborter.abort() }) } if (opts.immediate ?? true) refreshIfStale() return { entry, data: computed(() => entry.data), loadState: computed(() => entry.loadState), error: computed(() => entry.error), isStale: computed(() => entry.data !== null && stale()), ageMs: computed(() => (entry.fetchedAt === null ? null : Date.now() - entry.fetchedAt)), refresh, invalidate: () => store.invalidate(opts.key), optimistic: (update) => store.optimistic(opts.key, update), } }