Skip to content
Clone
2f11d69
116 lines 4.2 KB
import { useEffect, useState } from 'react'
import { peekCache, readCache, writeCache } from '@/shared/storage'
import { runShared } from './prefetch'
export interface AsyncState<T> {
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<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> {
const cacheKey = opts?.cacheKey ?? null
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
if (cacheKey) {
const hit = peekCache<T>(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<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 {
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, 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
}