/** ____ *--------------/ \.------------------/ * / swrv \. / // * / / /\. / // * / _____/ / \. / * / / ____/ . \. / * / \ \_____ \. / * / . \_____ \ \ / // * \ _____/ / ./ / // * \ / _____/ ./ / * \ / / . ./ / * \ / / ./ / * . \/ ./ / // * \ ./ / // * \.. / / * . ||| / * ||| / * . ||| / // * ||| / // * ||| / */ import { tinyassert } from "@hiogawa/utils"; import { getCurrentInstance, inject, isReadonly, isRef, // isRef, onMounted, onServerPrefetch, onUnmounted, reactive, ref, toRefs, useSSRContext, watch, type FunctionPlugin } from 'vue'; import SWRVCache from './cache'; import webPreset from './lib/web-preset'; import type { IConfig, IKey, IResponse, fetcherFn, revalidateOptions } from './types'; type StateRef = { data: Data, error: Error, isValidating: boolean, isLoading: boolean, revalidate: Function, key: any }; const DATA_CACHE = new SWRVCache>() const REF_CACHE = new SWRVCache[]>() const PROMISES_CACHE = new SWRVCache>() const defaultConfig: IConfig = { cache: DATA_CACHE, refreshInterval: 0, ttl: 0, serverTTL: 1000, dedupingInterval: 2000, revalidateOnFocus: true, revalidateDebounce: 0, shouldRetryOnError: true, errorRetryInterval: 5000, errorRetryCount: 5, fetcher: webPreset.fetcher, isOnline: webPreset.isOnline, isDocumentVisible: webPreset.isDocumentVisible } /** * Cache the refs for later revalidation */ function setRefCache(key: string, theRef: StateRef, ttl: number) { const refCacheItem = REF_CACHE.get(key) if (refCacheItem) { refCacheItem.data.push(theRef) } else { // #51 ensures ref cache does not evict too soon const gracePeriod = 5000 REF_CACHE.set(key, [theRef], ttl > 0 ? ttl + gracePeriod : ttl) } } function onErrorRetry(revalidate: (any: any, opts: revalidateOptions) => void, errorRetryCount: number, config: IConfig): void { if (!(config as any).isDocumentVisible()) { return } if (config.errorRetryCount !== undefined && errorRetryCount > config.errorRetryCount) { return } const count = Math.min(errorRetryCount || 0, (config as any).errorRetryCount) const timeout = count * (config as any).errorRetryInterval setTimeout(() => { revalidate(null, { errorRetryCount: count + 1, shouldRetryOnError: true }) }, timeout) } /** * Main mutation function for receiving data from promises to change state and * set data cache */ const mutate = async (key: string, res: Promise | Data, cache = DATA_CACHE, ttl = defaultConfig.ttl) => { let data, error, isValidating if (isPromise(res)) { try { data = await res } catch (err) { error = err } } else { data = res } // eslint-disable-next-line prefer-const isValidating = false const newData = { data, error, isValidating } if (typeof data !== 'undefined') { try { cache.set(key, newData, Number(ttl)) } catch (err) { console.error('swrv(mutate): failed to set cache', err) } } /** * Revalidate all swrv instances with new data */ const stateRef = REF_CACHE.get(key) if (stateRef && stateRef.data.length) { // This filter fixes #24 race conditions to only update ref data of current // key, while data cache will continue to be updated if revalidation is // fired let refs = stateRef.data.filter(r => r.key === key) refs.forEach((r, idx) => { if (typeof newData.data !== 'undefined') { r.data = newData.data } r.error = newData.error r.isValidating = newData.isValidating r.isLoading = newData.isValidating const isLast = idx === refs.length - 1 if (!isLast) { // Clean up refs that belonged to old keys delete refs[idx] } }) refs = refs.filter(Boolean) } return newData } /* Stale-While-Revalidate hook to handle fetching, caching, validation, and more... */ function useSWRV( key: IKey ): IResponse function useSWRV( key: IKey, fn: fetcherFn | undefined | null, config?: IConfig ): IResponse function useSWRV(...args: any[]): IResponse { const injectedConfig = inject | null>('swrv-config', null) tinyassert(injectedConfig, 'Injected swrv-config must be an object') let key: IKey let fn: fetcherFn | undefined | null let config: IConfig = { ...defaultConfig, ...injectedConfig } let unmounted = false let isHydrated = false const instance = getCurrentInstance() as any const vm = instance?.proxy || instance // https://github.com/vuejs/composition-api/pull/520 if (!vm) { console.error('Could not get current instance, check to make sure that `useSwrv` is declared in the top level of the setup function.') throw new Error('Could not get current instance') } const IS_SERVER = typeof window === 'undefined' || false // #region ssr const isSsrHydration = Boolean( !IS_SERVER && window !== undefined && (window as any).window.swrv) // #endregion if (args.length >= 1) { key = args[0] } if (args.length >= 2) { fn = args[1] } if (args.length > 2) { config = { ...config, ...args[2] } } const ttl = IS_SERVER ? config.serverTTL : config.ttl const keyRef = typeof key === 'function' ? (key as any) : ref(key) if (typeof fn === 'undefined') { // use the global fetcher fn = config.fetcher } let stateRef: StateRef | null = null // #region ssr if (isSsrHydration) { // component was ssrHydrated, so make the ssr reactive as the initial data const swrvState = (window as any).window.swrv || [] const swrvKey = nanoHex(vm.$.type.__name ?? vm.$.type.name) if (swrvKey !== undefined && swrvKey !== null) { const nodeState = swrvState[swrvKey] || [] const instanceState = nodeState[nanoHex(isRef(keyRef) ? keyRef.value : keyRef())] if (instanceState) { stateRef = reactive(instanceState) isHydrated = true } } } // #endregion if (!stateRef) { stateRef = reactive({ data: undefined, error: undefined, isValidating: true, isLoading: true, key: null }) as StateRef } /** * Revalidate the cache, mutate data */ const revalidate = async (data?: fetcherFn, opts?: revalidateOptions) => { const isFirstFetch = stateRef.data === undefined const keyVal = keyRef.value if (!keyVal) { return } const cacheItem = config.cache!.get(keyVal) const newData = cacheItem && cacheItem.data stateRef.isValidating = true stateRef.isLoading = !newData if (newData) { stateRef.data = newData.data stateRef.error = newData.error } const fetcher = data || fn if ( !fetcher || (!IS_SERVER && !(config as any).isDocumentVisible() && !isFirstFetch) || (opts?.forceRevalidate !== undefined && !opts?.forceRevalidate) ) { stateRef.isValidating = false stateRef.isLoading = false return } // Dedupe items that were created in the last interval #76 if (cacheItem) { const shouldRevalidate = Boolean( ((Date.now() - cacheItem.createdAt) >= (config as any).dedupingInterval) || opts?.forceRevalidate ) if (!shouldRevalidate) { stateRef.isValidating = false stateRef.isLoading = false return } } const trigger = async () => { const promiseFromCache = PROMISES_CACHE.get(keyVal) if (!promiseFromCache) { const fetcherArgs = Array.isArray(keyVal) ? keyVal : [keyVal] const newPromise = fetcher(...fetcherArgs) PROMISES_CACHE.set(keyVal, newPromise, (config as any).dedupingInterval) await mutate(keyVal, newPromise, (config as any).cache, ttl) } else { await mutate(keyVal, promiseFromCache.data, (config as any).cache, ttl) } stateRef.isValidating = false stateRef.isLoading = false PROMISES_CACHE.delete(keyVal) if (stateRef.error !== undefined) { const shouldRetryOnError = !unmounted && config.shouldRetryOnError && (opts ? opts.shouldRetryOnError : true) if (shouldRetryOnError) { onErrorRetry(revalidate, opts ? Number(opts.errorRetryCount) : 1, config) } } } if (newData && config.revalidateDebounce) { setTimeout(async () => { if (!unmounted) { await trigger() } }, config.revalidateDebounce) } else { await trigger() } } const revalidateCall = async () => revalidate(null as any, { shouldRetryOnError: false }) let timer: any = null /** * Setup polling */ onMounted(() => { const tick = async () => { // component might un-mount during revalidate, so do not set a new timeout // if this is the case, but continue to revalidate since promises can't // be cancelled and new hook instances might rely on promise/data cache or // from pre-fetch if (!stateRef.error && (config as any).isOnline()) { // if API request errored, we stop polling in this round // and let the error retry function handle it await revalidate() } else { if (timer) { clearTimeout(timer) } } if (config.refreshInterval && !unmounted) { timer = setTimeout(tick, config.refreshInterval) } } if (config.refreshInterval) { timer = setTimeout(tick, config.refreshInterval) } if (config.revalidateOnFocus) { document.addEventListener('visibilitychange', revalidateCall, false) window.addEventListener('focus', revalidateCall, false) } }) /** * Teardown */ onUnmounted(() => { unmounted = true if (timer) { clearTimeout(timer) } if (config.revalidateOnFocus) { document.removeEventListener('visibilitychange', revalidateCall, false) window.removeEventListener('focus', revalidateCall, false) } const refCacheItem = REF_CACHE.get(keyRef.value) if (refCacheItem) { refCacheItem.data = refCacheItem.data.filter((ref) => ref !== stateRef) } }) // #region ssr if (IS_SERVER) { const ssrContext = useSSRContext() // make sure srwv exists in ssrContext let swrvRes: Record = {} if (ssrContext) { swrvRes = ssrContext.swrv = ssrContext.swrv || swrvRes } const ssrKey = nanoHex(vm.$.type.__name ?? vm.$.type.name) // if (!vm.$vnode || (vm.$node && !vm.$node.data)) { // vm.$vnode = { // data: { attrs: { 'data-swrv-key': ssrKey } } // } // } // const attrs = (vm.$vnode.data.attrs = vm.$vnode.data.attrs || {}) // attrs['data-swrv-key'] = ssrKey // // Nuxt compatibility // if (vm.$ssrContext && vm.$ssrContext.nuxt) { // vm.$ssrContext.nuxt.swrv = swrvRes // } if (ssrContext) { ssrContext.swrv = swrvRes } onServerPrefetch(async () => { await revalidate() if (!swrvRes[ssrKey]) swrvRes[ssrKey] = {} swrvRes[ssrKey][nanoHex(keyRef.value)] = { data: stateRef.data, error: stateRef.error, isValidating: stateRef.isValidating } }) } // #endregion /** * Revalidate when key dependencies change */ try { watch(keyRef, (val) => { if (!isReadonly(keyRef)) { keyRef.value = val } stateRef.key = val stateRef.isValidating = Boolean(val) setRefCache(keyRef.value, stateRef, Number(ttl)) if (!IS_SERVER && !isHydrated && keyRef.value) { revalidate() } isHydrated = false }, { immediate: true }) } catch { // do nothing } const res: IResponse = { ...toRefs(stateRef), mutate: (data?: fetcherFn, opts?: revalidateOptions) => revalidate(data, { ...opts, forceRevalidate: true }) } return res } function isPromise(p: any): p is Promise { return p !== null && typeof p === 'object' && typeof p.then === 'function' } /** * string to hex 8 chars * @param name string * @returns string */ function nanoHex(name: string): string { try { let hash = 0 for (let i = 0; i < name.length; i++) { const chr = name.charCodeAt(i) hash = ((hash << 5) - hash) + chr hash |= 0 // Convert to 32bit integer } let hex = (hash >>> 0).toString(16) while (hex.length < 8) { hex = '0' + hex } return hex } catch { console.error("err name: ", name) return '0000' } } export const vueSWR = (swrvConfig: Partial = defaultConfig): FunctionPlugin => (app) => { app.config.globalProperties.$swrv = useSWRV // app.provide('swrv', useSWRV) app.provide('swrv-config', swrvConfig) } export { mutate }; export default useSWRV