import { useEffect, useState } from 'react' import { peekCache, readCache, writeCache } from '@/shared/storage' import { runShared } from './prefetch' export interface AsyncState { data?: T loading: boolean error?: unknown /** Data is on screen while a network revalidation is still running. */ refreshing?: boolean /** The data on screen came from the local cache, not this run's response. */ stale?: boolean } export interface AsyncCacheOptions { /** * Enables local-first behaviour for this fetch: paint the stored payload * immediately, revalidate over the network in parallel, merge and persist * the result. `null` keeps the plain promise-state behaviour. */ cacheKey?: string | null /** Folds the stored payload into the fresh response; default: fresh wins. */ merge?: (cached: T, fresh: T) => T } /** * Promise-state hook with an optional local-first tier. * * Lifecycle when `cacheKey` is set: * 1. first render peeks the memory tier synchronously — stored data paints * without a loading flash (on a hard reload the disk tier hydrates in the * first effect tick instead); * 2. the network fetch always runs in parallel, deduplicated with hover * prefetches through the shared in-flight registry; * 3. a successful response is merged over the cache, persisted, and pushed * into state — the UI updates live; * 4. a failed response keeps the cached payload on screen and only flags * `error`, so a working (stale) view never blanks out. * * `fn` results from superseded runs are ignored via the `active` flag, and a * late disk hydration can never overwrite an already-arrived response. */ export function useAsync( fn: () => Promise, deps: readonly unknown[], opts?: AsyncCacheOptions, ): AsyncState { const cacheKey = opts?.cacheKey ?? null const [state, setState] = useState>(() => { if (!cacheKey) return { loading: true } const hit = peekCache(cacheKey) return hit ? { data: hit.data, loading: false, refreshing: true, stale: true } : { loading: true } }) // deps is intentionally dynamic: callers choose their own trigger keys. // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { let active = true // A *successful* response outranks any later disk hydration; a failed one // must not — offline, the stored payload arriving after the network error // is exactly what keeps the screen alive. let succeeded = false let failed = false if (cacheKey) { const hit = peekCache(cacheKey) if (hit) { setState({ data: hit.data, loading: false, refreshing: true, stale: true }) } else { // Either a cold key or a scope switch (roomId changed): drop the // previous scope's payload and hydrate from disk while fetching. setState({ loading: true }) void readCache(cacheKey).then(envelope => { if (!active || succeeded || !envelope) return setState(prev => { if (prev.data !== undefined) return prev return { data: envelope.data, loading: false, refreshing: !failed, stale: true, error: prev.error, } }) }) } } else { setState(prev => ({ data: prev.data, loading: true })) } const run = cacheKey ? runShared(cacheKey, fn) : fn() run.then( fresh => { if (!active) return succeeded = true if (!cacheKey) { setState({ data: fresh, loading: false, error: undefined }) return } const cached = peekCache(cacheKey)?.data const merged = cached !== undefined && opts?.merge ? opts.merge(cached, fresh) : fresh writeCache(cacheKey, merged) setState({ data: merged, loading: false, refreshing: false, stale: false, error: undefined }) }, error => { if (!active) return failed = true setState(prev => ({ ...prev, loading: false, refreshing: false, error })) }, ) return () => { active = false } // eslint-disable-next-line react-hooks/exhaustive-deps }, deps) return state }