Skip to content
Clone
eeff292
148 lines 5.4 KB
import { peekCache, readCache, writeCache } from '@app/services/storage'
import { useEffect, useMemo, useRef, useState } from 'react'
import { runShared } from './prefetch'
export interface AsyncState<T> {
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<T> {
/** 重新拉取并保留屏幕上的数据(只标记 `refreshing`)。 */
reload: () => void
/** 静默重新拉取:不切 loading,也不显示刷新态,用于事件驱动的后台校准。 */
reloadSilently: () => void
/** 本地合并,不发起请求。 */
mutate: (updater: (current: T | undefined) => T) => void
}
export interface AsyncCacheOptions<T> {
/**
* 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<T>(
fn: () => Promise<T>,
deps: readonly unknown[],
opts?: AsyncCacheOptions<T>,
): AsyncState<T> & AsyncControls<T> {
const cacheKey = opts?.cacheKey ?? null
const [nonce, setNonce] = useState(0)
const silentMode = useRef(false)
const [state, setState] = useState<AsyncState<T>>(() => {
if (!cacheKey) return { loading: true }
const hit = peekCache<T>(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<T>(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<T>(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<T>(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<AsyncControls<T>>(
() => ({
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 }
}