import { peekCache, readCache, writeCache } from '@app/services/storage' import { useEffect, useMemo, useRef, useState } from 'react' import { runShared } from './prefetch' export interface AsyncState { data?: T loading: boolean error?: unknown /** 静默重取中:数据仍在屏幕上,不显示任何加载态。 */ silent?: boolean /** 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 AsyncControls { /** 重新拉取并保留屏幕上的数据(只标记 `refreshing`)。 */ reload: () => void /** 静默重新拉取:不切 loading,也不显示刷新态,用于事件驱动的后台校准。 */ reloadSilently: () => void /** 本地合并,不发起请求。 */ mutate: (updater: (current: T | undefined) => T) => void } 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 & AsyncControls { const cacheKey = opts?.cacheKey ?? null const [nonce, setNonce] = useState(0) const silentMode = useRef(false) 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 const silentRun = silentMode.current silentMode.current = false if (cacheKey) { const hit = peekCache(cacheKey) if (hit) { if (!silentRun) setState({ data: hit.data, loading: false, refreshing: true, stale: true }) } else if (silentRun) { setState((prev) => ({ ...prev, silent: 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 if (silentRun) { setState((prev) => ({ ...prev, silent: true })) } 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, silent: false, error: undefined }) }, (error) => { if (!active) return failed = true setState((prev) => ({ ...prev, loading: false, refreshing: false, silent: false, error })) }, ) return () => { active = false } // eslint-disable-next-line react-hooks/exhaustive-deps }, [...deps, nonce]) const controls = useMemo>( () => ({ reload: () => setNonce((value) => value + 1), reloadSilently: () => { silentMode.current = true setNonce((value) => value + 1) }, mutate: (updater) => setState((prev) => ({ ...prev, data: updater(prev.data) })), }), [], ) return { ...state, ...controls } }