diff --git a/lib/composables/global-swr-config/index.ts b/lib/composables/global-swr-config/index.ts index b4bc78a..01cf38d 100644 --- a/lib/composables/global-swr-config/index.ts +++ b/lib/composables/global-swr-config/index.ts @@ -9,6 +9,7 @@ import { useScopeState } from '@/composables/scope-state'; export type MutateOptions = { optimisticData?: unknown; rollbackOnError?: boolean; + revalidate?: boolean; }; export const useSWRConfig = () => { @@ -17,6 +18,10 @@ export const useSWRConfig = () => { computed(() => defaultConfig), ); + const cacheProvider = computed(() => contextConfig.value.cacheProvider); + + const { revalidateCache } = useScopeState(cacheProvider); + const mutate = async | AnyFunction>( _key: Key, updateFnOrPromise?: U, @@ -24,7 +29,7 @@ export const useSWRConfig = () => { ) => { const { key } = serializeKey(_key); const cachedValue = contextConfig.value.cacheProvider.get(key); - const { optimisticData, rollbackOnError } = options; + const { optimisticData, rollbackOnError, revalidate = true } = options; if (!cachedValue) return; @@ -41,7 +46,7 @@ export const useSWRConfig = () => { } try { - data.value = await resultPromise; + data.value = isUndefined(resultPromise) ? data.value : await resultPromise; } catch (error) { if (rollbackOnError) { data.value = currentData; @@ -50,6 +55,14 @@ export const useSWRConfig = () => { throw error; } + const revalidationCallbackcs = revalidateCache.value.get(key) || []; + + if (revalidate && revalidationCallbackcs.length) { + const [firstRevalidateCallback] = revalidationCallbackcs; + + await firstRevalidateCallback(); + } + return data.value; }; diff --git a/lib/composables/scope-state/index.ts b/lib/composables/scope-state/index.ts new file mode 100644 index 0000000..c6ec5e3 --- /dev/null +++ b/lib/composables/scope-state/index.ts @@ -0,0 +1,25 @@ +import { computed, ComputedRef, toRefs, unref, watch } from 'vue'; +import { MaybeRef, toReactive } from '@vueuse/core'; + +import { globalState } from '@/config'; +import { CacheProvider, CacheState, ScopeState } from '@/types'; + +const initScopeState = (cacheProvider: CacheProvider) => { + globalState.set(cacheProvider, { revalidateCache: new Map() }); +}; + +export const useScopeState = (_cacheProvider: MaybeRef>) => { + const cacheProvider = computed(() => unref(_cacheProvider)); + const scopeState = computed(() => globalState.get(cacheProvider.value)); + + const onScopeStateChange = () => { + if (!scopeState.value) initScopeState(cacheProvider.value); + }; + + watch(scopeState, onScopeStateChange, { immediate: true }); + + return { + scopeState: scopeState as ComputedRef, + ...toRefs(toReactive(scopeState as ComputedRef)), + }; +}; diff --git a/lib/composables/scope-state/scope-state.spec.ts b/lib/composables/scope-state/scope-state.spec.ts new file mode 100644 index 0000000..1ed1698 --- /dev/null +++ b/lib/composables/scope-state/scope-state.spec.ts @@ -0,0 +1,59 @@ +import { useInjectedSetup, mockedCache } from '@/utils/test'; +import { globalState } from '@/config'; + +import { useScopeState } from '.'; + +const cacheProvider = mockedCache; + +const useScopeStateWrapped = () => + useInjectedSetup( + () => {}, + () => useScopeState(cacheProvider), + ); + +describe('useSWR - mutate', () => { + beforeEach(() => { + vi.resetAllMocks(); + cacheProvider.clear(); + }); + + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('should init scope in case that the scope does not have values', async () => { + const { scopeState, revalidateCache } = useScopeStateWrapped(); + + expect(scopeState.value).not.toBeUndefined(); + expect(scopeState.value.revalidateCache).toBeInstanceOf(Map); + + // toRefs values + expect(revalidateCache.value).toBeInstanceOf(Map); + }); + + it('should return values for current scope', async () => { + const key = 'key1'; + const revalidateCb = vi.fn(); + + globalState.set(cacheProvider, { + revalidateCache: new Map([[key, [revalidateCb]]]), + }); + + const { scopeState, revalidateCache } = useScopeStateWrapped(); + + expect(scopeState.value.revalidateCache).toBeInstanceOf(Map); + expect(scopeState.value.revalidateCache.size).toBe(1); + expect(scopeState.value.revalidateCache.get(key)).toHaveLength(1); + expect(scopeState.value.revalidateCache.get(key)).toContain(revalidateCb); + + // toRefs values + expect(revalidateCache.value).toBeInstanceOf(Map); + expect(revalidateCache.value.size).toBe(1); + expect(revalidateCache.value.get(key)).toHaveLength(1); + expect(revalidateCache.value.get(key)).toContain(revalidateCb); + }); +}); diff --git a/lib/composables/swr/index.ts b/lib/composables/swr/index.ts index 3ccba0b..87f7273 100644 --- a/lib/composables/swr/index.ts +++ b/lib/composables/swr/index.ts @@ -1,5 +1,5 @@ -import { computed, readonly, watch, toRefs, unref, customRef } from 'vue'; -import { toReactive, useEventListener, useIntervalFn } from '@vueuse/core'; +import { computed, readonly, watch, toRefs, unref, customRef, onUnmounted } from 'vue'; +import { createUnrefFn, toReactive, useEventListener, useIntervalFn } from '@vueuse/core'; import type { MaybeRef, @@ -8,10 +8,11 @@ import type { SWRFetcher, SWRKey, } from '@/types'; -import { serializeKey } from '@/utils'; +import { serializeKey, subscribeCallback } from '@/utils'; import { mergeConfig } from '@/utils/merge-config'; import { isClient } from '@/config'; import { useSWRConfig } from '@/composables/global-swr-config'; +import { useScopeState } from '@/composables/scope-state'; type UseCachedRefOptions = { cache: any; @@ -45,6 +46,8 @@ export const useSWR = ( config: SWRComposableConfig = {}, ) => { const { config: contextConfig, mutate } = useSWRConfig(); + const { revalidateCache } = useScopeState(contextConfig.value.cacheProvider); + const mergedConfig = mergeConfig(contextConfig.value, config); const { @@ -104,6 +107,8 @@ export const useSWR = ( } }; + let unsubRevalidateCb: ReturnType | undefined; + const onRefresh = () => { const shouldSkipRefreshOffline = !refreshWhenOffline && !navigator.onLine; const shouldSkipRefreshHidden = !refreshWhenHidden && document.visibilityState === 'hidden'; @@ -121,6 +126,24 @@ export const useSWR = ( fetchData(); }; + const onRevalidate = async () => { + if (!key.value) { + return; + } + + await fetchData(); + }; + + const onKeyChange = (newKey: string, oldKey?: string) => { + if (!!newKey && newKey !== oldKey && (revalidateIfStale || !data.value)) { + fetchData(); + } + + unsubRevalidateCb?.(); + + subscribeCallback(newKey, onRevalidate, revalidateCache.value); + }; + if (isClient && revalidateOnFocus && (revalidateIfStale || !data.value)) { useEventListener(window, 'focus', onWindowFocus); } @@ -133,15 +156,8 @@ export const useSWR = ( useIntervalFn(onRefresh, refreshInterval); } - watch( - key, - (newKey, oldKey) => { - if (!!newKey && newKey !== oldKey && (revalidateIfStale || !data.value)) { - fetchData(); - } - }, - { immediate: true }, - ); + watch(key, onKeyChange, { immediate: true }); + onUnmounted(() => unsubRevalidateCb?.()); if (!hasCachedValue.value) { cacheProvider.set(key.value, { diff --git a/lib/composables/swr/swr-mutate.spec.ts b/lib/composables/swr/swr-mutate.spec.ts new file mode 100644 index 0000000..902134d --- /dev/null +++ b/lib/composables/swr/swr-mutate.spec.ts @@ -0,0 +1,139 @@ +import { nextTick } from 'vue'; + +import { SWRComposableConfig } from '@/types'; +import { useInjectedSetup, setDataToMockedCache, mockedCache } from '@/utils/test'; +import { globalState } from '@/config'; + +import { useSWR } from '.'; +import { configureGlobalSWR, useSWRConfig } from '../global-swr-config'; + +const cacheProvider = mockedCache; +const defaultKey = 'defaultKey'; +const defaultFetcher = vi.fn((key: string) => key); +const defaultOptions: SWRComposableConfig = { dedupingInterval: 0 }; + +const setTimeoutPromise = (ms: number, resolveTo: unknown) => + new Promise((resolve) => { + setTimeout(() => resolve(resolveTo), ms); + }); + +const useSWRWrapped: typeof useSWR = (...params) => { + return useInjectedSetup( + () => configureGlobalSWR({ cacheProvider, dedupingInterval: 0 }), + () => useSWR(...params), + ); +}; + +describe('useSWR - mutate', () => { + beforeEach(() => { + vi.resetAllMocks(); + cacheProvider.clear(); + globalState.delete(cacheProvider); + + vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true); + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); + }); + + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('should change local data variable value when mutate resolves', async () => { + const { mutate, data } = useSWRWrapped(defaultKey, () => 'FetcherResult'); + + await nextTick(); + expect(data.value).toEqual('FetcherResult'); + + await mutate(() => 'newValue', { revalidate: false }); + await nextTick(); + expect(data.value).toEqual('newValue'); + }); + + it('should change local data variable value when mutate is called with `optimistcData`', async () => { + setDataToMockedCache(defaultKey, { data: 'cachedData' }); + + const { mutate, data } = useInjectedSetup( + () => configureGlobalSWR({ cacheProvider }), + () => useSWR(defaultKey, () => 'FetcherResult'), + ); + + expect(data.value).toEqual('cachedData'); + + mutate(() => setTimeoutPromise(1000, 'newValue'), { optimisticData: 'optimistcData' }); + await nextTick(); + expect(data.value).toEqual('optimistcData'); + }); + + it('should update all hooks with the same key when call mutates', async () => { + setDataToMockedCache(defaultKey, { data: 'cachedData' }); + + const { datas, mutate, differentData } = useInjectedSetup( + () => configureGlobalSWR({ cacheProvider }), + () => { + const { data: data1, mutate: localMutate } = useSWR(defaultKey, defaultFetcher); + const { data: data2 } = useSWR(defaultKey, defaultFetcher); + const { data: data3 } = useSWR(defaultKey, defaultFetcher); + const { data: data4 } = useSWR(defaultKey, defaultFetcher); + const { data: differentData1 } = useSWR('key-2', () => 'should not change'); + + return { + differentData: differentData1, + datas: [data1, data2, data3, data4], + mutate: localMutate, + }; + }, + ); + + expect(datas.map((data) => data.value)).toEqual([ + 'cachedData', + 'cachedData', + 'cachedData', + 'cachedData', + ]); + + await mutate(() => 'mutated value'); + expect(datas.map((data) => data.value)).toEqual([ + 'mutated value', + 'mutated value', + 'mutated value', + 'mutated value', + ]); + + expect(differentData.value).toEqual('should not change'); + }); + + it('should trigger revalidation programmatically', async () => { + let value = 0; + + const { mutate, data, globalMutate } = useInjectedSetup( + () => configureGlobalSWR({ cacheProvider }), + () => { + const { mutate: localGlobalMutate } = useSWRConfig(); + // eslint-disable-next-line no-plusplus + const swrResult = useSWR(defaultKey, () => value++, { dedupingInterval: 0 }); + + return { + globalMutate: localGlobalMutate, + ...swrResult, + }; + }, + ); + + await nextTick(); + expect(data.value).toEqual(0); + + await mutate(); + + await nextTick(); + expect(data.value).toEqual(1); + + await globalMutate(defaultKey); + + await nextTick(); + expect(data.value).toEqual(2); + }); +}); diff --git a/lib/config/global-state.ts b/lib/config/global-state.ts new file mode 100644 index 0000000..b6ebfb3 --- /dev/null +++ b/lib/config/global-state.ts @@ -0,0 +1,8 @@ +import { reactive } from 'vue'; + +import { CacheProvider, CacheState, ScopeState } from '@/types'; + +/** + * Holds the scope's states, isolated using cacheProvider as key + */ +export const globalState = reactive(new WeakMap, ScopeState>()); diff --git a/lib/config/index.ts b/lib/config/index.ts index 0ae11f6..6865d78 100644 --- a/lib/config/index.ts +++ b/lib/config/index.ts @@ -1,3 +1,4 @@ export * from './swr-config'; export * from './injection-keys'; export * from './enviroment'; +export * from './global-state'; diff --git a/lib/types/lib.ts b/lib/types/lib.ts index 2fa97a6..20eaab7 100644 --- a/lib/types/lib.ts +++ b/lib/types/lib.ts @@ -30,6 +30,10 @@ export type CacheState = { fetchedIn: Date; }; +export type ScopeState = { + revalidateCache: Map void | Promise>>; // callbacks to revalidate when key changes +}; + export type SWRConfig = { /** * stores the cached values diff --git a/lib/utils/index.ts b/lib/utils/index.ts index 9aa6ccf..03c943d 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -3,3 +3,4 @@ export * from './hash'; export * from './serialize-key'; export * from './merge-config'; export * from './chain-fn'; +export * from './subscribe-key'; diff --git a/lib/utils/subscribe-key/index.ts b/lib/utils/subscribe-key/index.ts new file mode 100644 index 0000000..19e6217 --- /dev/null +++ b/lib/utils/subscribe-key/index.ts @@ -0,0 +1,25 @@ +export type SubscribeCallback = () => void; +export type SubscribeCallbackCache = Map; + +export const unsubscribeCallback = ( + key: string, + cb: SubscribeCallback, + cbCache: SubscribeCallbackCache, +) => { + const callbacks = cbCache.get(key) || []; + const newCallbacks = callbacks.filter((currentCb) => currentCb !== cb); + + cbCache.set(key, newCallbacks); +}; + +export const subscribeCallback = ( + key: string, + cb: SubscribeCallback, + cbCache: SubscribeCallbackCache, +) => { + const callbacks = cbCache.get(key) || []; + + cbCache.set(key, [...callbacks, cb]); + + return () => unsubscribeCallback(key, cb, cbCache); +}; diff --git a/lib/utils/subscribe-key/subscribe-key.spec.ts b/lib/utils/subscribe-key/subscribe-key.spec.ts new file mode 100644 index 0000000..e1e640b --- /dev/null +++ b/lib/utils/subscribe-key/subscribe-key.spec.ts @@ -0,0 +1,90 @@ +import { Key } from '@/types'; + +import { subscribeCallback, unsubscribeCallback, SubscribeCallbackCache } from '.'; + +describe('subscribe-key', () => { + const defaultKey: Key = 'key'; + const defaultCbCache: SubscribeCallbackCache = new Map(); + + beforeEach(() => { + defaultCbCache.clear(); + }); + + describe('subscribeCallback', () => { + it('should subscribe callback to passed key', () => { + const cb = vi.fn(); + + subscribeCallback(defaultKey, cb, defaultCbCache); + + expect(defaultCbCache.get(defaultKey)).toHaveLength(1); + expect(defaultCbCache.get(defaultKey)).toContain(cb); + }); + + it('should subscribe callback isolated by keys', () => { + const keyA = 'key1'; + const keyB = 'test'; + + subscribeCallback(keyA, vi.fn(), defaultCbCache); + subscribeCallback(keyA, vi.fn(), defaultCbCache); + subscribeCallback(keyA, vi.fn(), defaultCbCache); + subscribeCallback(keyB, vi.fn(), defaultCbCache); + + expect(defaultCbCache.get(keyA)).toHaveLength(3); + expect(defaultCbCache.get(keyB)).toHaveLength(1); + }); + + it('should return an unsubscribe function', () => { + const keyA = 'key1'; + const keyB = 'test'; + + const cbA = vi.fn(); + const cbB = vi.fn(); + + const unsubA = subscribeCallback(keyA, cbA, defaultCbCache); + const unsubB = subscribeCallback(keyB, cbB, defaultCbCache); + + subscribeCallback(keyA, vi.fn(), defaultCbCache); + subscribeCallback(keyA, vi.fn(), defaultCbCache); + subscribeCallback(keyB, vi.fn(), defaultCbCache); + + unsubA(); + expect(defaultCbCache.get(keyA)).toHaveLength(2); + expect(defaultCbCache.get(keyA)).not.toContain(cbA); + + unsubB(); + expect(defaultCbCache.get(keyB)).toHaveLength(1); + expect(defaultCbCache.get(keyB)).not.toContain(cbB); + }); + }); + + describe('unsubscribeCallback', () => { + it('should unsubscribe callback to passed key', () => { + const cb = vi.fn(); + + subscribeCallback(defaultKey, cb, defaultCbCache); + unsubscribeCallback(defaultKey, cb, defaultCbCache); + + expect(defaultCbCache.get(defaultKey)).not.toContain(cb); + }); + + it('should unsubscribe isolated callbacks by key', () => { + const keyA = 'key1'; + const keyB = 'test'; + const cb = vi.fn(); + + defaultCbCache.set(keyA, [vi.fn()]); + defaultCbCache.set(keyB, [vi.fn(), cb, vi.fn()]); + + unsubscribeCallback(keyB, cb, defaultCbCache); + + expect(defaultCbCache.get(keyA)).toHaveLength(1); + expect(defaultCbCache.get(keyB)).toHaveLength(2); + }); + + it('should not throw when called upon empty key', () => { + defaultCbCache.delete(defaultKey); + + expect(() => unsubscribeCallback(defaultKey, vi.fn(), defaultCbCache)).not.toThrow(); + }); + }); +});