feat(ui): shared stale-while-revalidate layer — useCachedResource + resources store + rpc-client abort/dedup/retry controls
Demo images / Build & push demo images (push) Successful in 3m28s
Demo images / Build & push demo images (push) Successful in 3m28s
Part B1+B2 of docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md. Foundation for pages
that render instantly from cache on revisit and revalidate in the
background, instead of unmount-refetch-spinner on every navigation.
- stores/resources.ts: keyed {data, loadState, fetchedAt, error} entries
with sticky-ready (never regress ready→loading), keep-last-value on
error, per-key in-flight dedup, sessionStorage snapshot hydrate,
debounced invalidate() fan-out, optimistic-update-with-rollback
- composables/useCachedResource.ts: SWR hook over the store — synchronous
hydrate, TTL-gated background revalidate, revalidate-on-focus,
abort-on-unmount fetcher signal
- rpc-client: AbortSignal support (aborts pending retries too), opt-in
in-flight dedup keyed method+params, per-call maxRetries override
- 10 tests covering the SWR semantics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e24e0a6473
commit
67454974b2
@@ -0,0 +1,107 @@
|
||||
// Stale-while-revalidate resource hook over the shared resources store.
|
||||
//
|
||||
// Usage:
|
||||
// const files = useCachedResource<CloudFile[]>({
|
||||
// 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 } from 'vue'
|
||||
import { useResourcesStore, type ResourceEntry, type ResourceLoadState } from '@/stores/resources'
|
||||
|
||||
export interface CachedResourceOptions<T> {
|
||||
/** 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<T>
|
||||
/** 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<T> {
|
||||
entry: ResourceEntry<T>
|
||||
/** Convenience computed views over the entry. */
|
||||
data: ReturnType<typeof computed<T | null>>
|
||||
loadState: ReturnType<typeof computed<ResourceLoadState>>
|
||||
error: ReturnType<typeof computed<string | null>>
|
||||
/** True when data exists but is older than the TTL (drive an age badge). */
|
||||
isStale: ReturnType<typeof computed<boolean>>
|
||||
ageMs: ReturnType<typeof computed<number | null>>
|
||||
/** Force a refresh now (deduped with any in-flight one). */
|
||||
refresh: () => Promise<void>
|
||||
/** 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<T>(opts: CachedResourceOptions<T>): CachedResource<T> {
|
||||
const store = useResourcesStore()
|
||||
const ttlMs = opts.ttlMs ?? 30_000
|
||||
const persist = opts.persist ?? true
|
||||
const entry = store.entry<T>(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<T>(opts.key, update),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user