admin/hooks/useAsyncResource.ts
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

123 lines
3.6 KiB
TypeScript

'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { texts } from '@/texts'
interface AsyncResourceOptions<T> {
cacheKey?: string
enabled?: boolean
initialData?: T | null
retries?: number
retryDelayMs?: number
staleTimeMs?: number
}
interface CacheEntry {
data: unknown
updatedAt: number
}
const resourceCache = new Map<string, CacheEntry>()
export const invalidateAsyncResource = (key: string) => {
resourceCache.delete(key)
}
/** Drop every module-level entry — call on logout so a later session cannot reuse prior data. */
export const clearAsyncResourceCache = () => {
resourceCache.clear()
}
export const useAsyncResource = <T>(
load: (signal: AbortSignal) => Promise<T>,
{ cacheKey, enabled = true, initialData = null, retries = 1, retryDelayMs = 300, staleTimeMs = 30_000 }: AsyncResourceOptions<T> = {}
) => {
const cached = cacheKey ? resourceCache.get(cacheKey) : undefined
const [data, setData] = useState<T | null>((cached?.data as T | undefined) ?? initialData)
const [error, setError] = useState<Error | null>(null)
const [isLoading, setIsLoading] = useState(enabled)
const requestIdRef = useRef(0)
const controllerRef = useRef<AbortController | null>(null)
useEffect(() => {
const nextCached = cacheKey ? resourceCache.get(cacheKey) : undefined
setData((nextCached?.data as T | undefined) ?? initialData)
}, [cacheKey, initialData])
const execute = useCallback(
async (ignoreCache = false) => {
if (!enabled) return null
const currentCache = cacheKey ? resourceCache.get(cacheKey) : undefined
if (!ignoreCache && currentCache && Date.now() - currentCache.updatedAt < staleTimeMs) {
const cachedData = currentCache.data as T
setData(cachedData)
setError(null)
setIsLoading(false)
return cachedData
}
controllerRef.current?.abort()
const controller = new AbortController()
const requestId = ++requestIdRef.current
controllerRef.current = controller
setIsLoading(true)
setError(null)
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const nextData = await load(controller.signal)
if (!controller.signal.aborted && requestId === requestIdRef.current) {
setData(nextData)
setIsLoading(false)
if (cacheKey) resourceCache.set(cacheKey, { data: nextData, updatedAt: Date.now() })
}
return nextData
} catch (cause) {
if (controller.signal.aborted) return null
if (attempt < retries) {
await new Promise((resolve) => window.setTimeout(resolve, retryDelayMs * (attempt + 1)))
continue
}
const nextError = cause instanceof Error ? cause : new Error(texts.common.unexpectedError)
if (requestId === requestIdRef.current) setError(nextError)
}
}
if (requestId === requestIdRef.current) setIsLoading(false)
return null
},
[cacheKey, enabled, retries, retryDelayMs, staleTimeMs, load]
)
useEffect(() => {
void execute(false)
return () => controllerRef.current?.abort()
}, [execute])
const refetch = useCallback(() => execute(true), [execute])
const updateData = useCallback(
(nextData: T | null) => {
setData(nextData)
if (cacheKey) {
if (nextData === null) resourceCache.delete(cacheKey)
else resourceCache.set(cacheKey, { data: nextData, updatedAt: Date.now() })
}
},
[cacheKey]
)
return { data, error, isLoading, refetch, setData: updateData }
}