diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 000000000..77efb4d86 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,99 @@ +import { CacheInterface, keyInterface, cacheListener } from './types' +import { mutate } from './use-swr' +import hash from './libs/hash' + +export default class Cache implements CacheInterface { + private __cache: Map + private __listeners: cacheListener[] + + constructor(initialData: any = {}) { + this.__cache = new Map(Object.entries(initialData)) + this.__listeners = [] + } + + get(key: keyInterface): any { + const [_key] = this.serializeKey(key) + return this.__cache.get(_key) + } + + set(key: keyInterface, value: any, shouldNotify = true): any { + const [_key] = this.serializeKey(key) + this.__cache.set(_key, value) + if (shouldNotify) mutate(key, value, false) + this.notify() + } + + keys() { + return Array.from(this.__cache.keys()) + } + + has(key: keyInterface) { + const [_key] = this.serializeKey(key) + return this.__cache.has(_key) + } + + clear(shouldNotify = true) { + if (shouldNotify) this.__cache.forEach(key => mutate(key, null, false)) + this.__cache.clear() + this.notify() + } + + delete(key: keyInterface, shouldNotify = true) { + const [_key] = this.serializeKey(key) + if (shouldNotify) mutate(key, null, false) + this.__cache.delete(_key) + this.notify() + } + + // TODO: introduce namespace for the cache + serializeKey(key: keyInterface): [string, any, string] { + let args = null + if (typeof key === 'function') { + try { + key = key() + } catch (err) { + // dependencies not ready + key = '' + } + } + + if (Array.isArray(key)) { + // args array + args = key + key = hash(key) + } else { + // convert null to '' + key = String(key || '') + } + + const errorKey = key ? 'err@' + key : '' + + return [key, args, errorKey] + } + + subscribe(listener: cacheListener) { + if (typeof listener !== 'function') { + throw new Error('Expected the listener to be a function.') + } + + let isSubscribed = true + this.__listeners.push(listener) + + return () => { + if (!isSubscribed) return + isSubscribed = false + const index = this.__listeners.indexOf(listener) + if (index > -1) { + this.__listeners[index] = this.__listeners[this.__listeners.length - 1] + this.__listeners.length-- + } + } + } + + // Notify Cache subscribers about a change in the cache + private notify() { + for (let listener of this.__listeners) { + listener() + } + } +} diff --git a/src/config.ts b/src/config.ts index 6f74219b8..5024cde19 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,21 +6,10 @@ import { RevalidateOptionInterface, revalidateType } from './types' +import Cache from './cache' -// Cache -const __cache = new Map() - -function cacheGet(key: string): any { - return __cache.get(key) -} - -function cacheSet(key: string, value: any) { - return __cache.set(key, value) -} - -function cacheClear() { - __cache.clear() -} +// cache +const cache = new Cache() // state managers const CONCURRENT_PROMISES = {} @@ -107,8 +96,6 @@ export { FOCUS_REVALIDATORS, CACHE_REVALIDATORS, MUTATION_TS, - cacheGet, - cacheSet, - cacheClear + cache } export default defaultConfig diff --git a/src/index.ts b/src/index.ts index 26030211d..e3820cbdc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,13 @@ export * from './use-swr' import { default as useSWR } from './use-swr' export { useSWRPages } from './use-swr-pages' +export { cache } from './config' export { ConfigInterface, revalidateType, RevalidateOptionInterface, keyInterface, - responseInterface + responseInterface, + CacheInterface } from './types' export default useSWR diff --git a/src/types.ts b/src/types.ts index 41d183290..6921637d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -115,3 +115,16 @@ export type actionType = { error?: Error isValidating?: boolean } + +export interface CacheInterface { + get(key: keyInterface): any + set(key: keyInterface, value: any, shouldNotify?: boolean): any + keys(): string[] + has(key: keyInterface): boolean + delete(key: keyInterface, shouldNotify?: boolean): void + clear(shouldNotify?: boolean): void + serializeKey(key: keyInterface): [string, any, string] + subscribe(listener: cacheListener): () => void +} + +export type cacheListener = () => void diff --git a/src/use-swr-pages.tsx b/src/use-swr-pages.tsx index 5e1b06a0d..27659b0bb 100644 --- a/src/use-swr-pages.tsx +++ b/src/use-swr-pages.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo, useState, useRef } from 'react' -import { cacheGet, cacheSet } from './config' +import { cache } from './config' import { pagesResponseInterface, responseInterface, @@ -100,10 +100,10 @@ export function useSWRPages( const pageOffsetKey = `_swr_page_offset_` + pageKey const [pageCount, setPageCount] = useState( - cacheGet(pageCountKey) || 1 + cache.get(pageCountKey) || 1 ) const [pageOffsets, setPageOffsets] = useState( - cacheGet(pageOffsetKey) || [null] + cache.get(pageOffsetKey) || [null] ) const [pageSWRs, setPageSWRs] = useState[]>([]) @@ -134,7 +134,7 @@ export function useSWRPages( const loadMore = useCallback(() => { if (isLoadingMore || isReachingEnd) return setPageCount(c => { - cacheSet(pageCountKey, c + 1) + cache.set(pageCountKey, c + 1) return c + 1 }) }, [isLoadingMore || isReachingEnd]) @@ -167,7 +167,7 @@ export function useSWRPages( setPageOffsets(arr => { const _arr = [...arr] _arr[id + 1] = newPageOffset - cacheSet(pageOffsetKey, _arr) + cache.set(pageOffsetKey, _arr) return _arr }) } diff --git a/src/use-swr.ts b/src/use-swr.ts index 566d4fbdb..a6eec4c38 100644 --- a/src/use-swr.ts +++ b/src/use-swr.ts @@ -9,15 +9,13 @@ import { } from 'react' import defaultConfig, { - cacheGet, - cacheSet, CACHE_REVALIDATORS, CONCURRENT_PROMISES, CONCURRENT_PROMISES_TS, FOCUS_REVALIDATORS, - MUTATION_TS + MUTATION_TS, + cache } from './config' -import hash from './libs/hash' import isDocumentVisible from './libs/is-document-visible' import isOnline from './libs/is-online' import throttle from './libs/throttle' @@ -42,41 +40,18 @@ const IS_SERVER = typeof window === 'undefined' // useLayoutEffect in the browser. const useIsomorphicLayoutEffect = IS_SERVER ? useEffect : useLayoutEffect -// TODO: introduce namepsace for the cache -const getErrorKey = key => (key ? 'err@' + key : '') -const getKeyArgs = key => { - let args = null - if (typeof key === 'function') { - try { - key = key() - } catch (err) { - // dependencies not ready - key = '' - } - } - - if (Array.isArray(key)) { - // args array - args = key - key = hash(key) - } else { - // convert null to '' - key = String(key || '') - } - - return [key, args] -} - const NO_DEDUPE = false const trigger: triggerInterface = (_key, shouldRevalidate = true) => { - const [key] = getKeyArgs(_key) + // we are ignoring the second argument which correspond to the arguments + // the fetcher will receive when key is an array + const [key, , keyErr] = cache.serializeKey(_key) if (!key) return const updaters = CACHE_REVALIDATORS[key] if (key && updaters) { - const currentData = cacheGet(key) - const currentError = cacheGet(getErrorKey(key)) + const currentData = cache.get(key) + const currentError = cache.get(keyErr) for (let i = 0; i < updaters.length; ++i) { updaters[i](shouldRevalidate, currentData, currentError, NO_DEDUPE) } @@ -97,7 +72,7 @@ const mutate: mutateInterface = async ( _data, shouldRevalidate = true ) => { - const [key] = getKeyArgs(_key) + const [key] = cache.serializeKey(_key) if (!key) return // if there is no new data, call revalidate against the key @@ -111,7 +86,7 @@ const mutate: mutateInterface = async ( if (_data && typeof _data === 'function') { // `_data` is a function, call it passing current cache value try { - data = await _data(cacheGet(key)) + data = await _data(cache.get(key)) } catch (err) { error = err } @@ -127,8 +102,8 @@ const mutate: mutateInterface = async ( } if (typeof data !== 'undefined') { - // update cached data - cacheSet(key, data) + // update cached data, avoid notifying from the cache + cache.set(key, data, false) } // update existing SWR Hooks' state @@ -179,10 +154,8 @@ function useSWR( // we assume `key` as the identifier of the request // `key` can change but `fn` shouldn't // (because `revalidate` only depends on `key`) - const [key, fnArgs] = getKeyArgs(_key) - // `keyErr` is the cache key for error objects - const keyErr = getErrorKey(key) + const [key, fnArgs, keyErr] = cache.serializeKey(_key) config = Object.assign( {}, @@ -196,8 +169,8 @@ function useSWR( fn = config.fetcher } - const initialData = cacheGet(key) || config.initialData - const initialError = cacheGet(keyErr) + const initialData = cache.get(key) || config.initialData + const initialError = cache.get(keyErr) // if a state is accessed (data, error or isValidating), // we add the state to dependencies so if the state is @@ -282,7 +255,7 @@ function useSWR( // if no cache being rendered currently (it shows a blank page), // we trigger the loading slow event. - if (config.loadingTimeout && !cacheGet(key)) { + if (config.loadingTimeout && !cache.get(key)) { setTimeout(() => { if (loading) config.onLoadingSlow(key, config) }, config.loadingTimeout) @@ -316,8 +289,8 @@ function useSWR( return false } - cacheSet(key, newData) - cacheSet(keyErr, undefined) + cache.set(key, newData, false) + cache.set(keyErr, undefined, false) keyRef.current = key // new state for the reducer @@ -348,7 +321,7 @@ function useSWR( delete CONCURRENT_PROMISES[key] delete CONCURRENT_PROMISES_TS[key] - cacheSet(keyErr, err) + cache.set(keyErr, err, false) keyRef.current = key // get a new error @@ -399,7 +372,7 @@ function useSWR( // and trigger a revalidation const currentHookData = stateRef.current.data - const latestKeyedData = cacheGet(key) || config.initialData + const latestKeyedData = cache.get(key) || config.initialData // update the state if the key changed or cache updated if ( @@ -570,8 +543,8 @@ function useSWR( // (it should be suspended) // try to get data and error from cache - let latestData = cacheGet(key) - let latestError = cacheGet(keyErr) + let latestData = cache.get(key) + let latestError = cache.get(keyErr) if ( typeof latestData === 'undefined' && diff --git a/test/use-swr.test.tsx b/test/use-swr.test.tsx index e8c28f9d1..dae980109 100644 --- a/test/use-swr.test.tsx +++ b/test/use-swr.test.tsx @@ -7,8 +7,8 @@ import { } from '@testing-library/react' import React, { ReactNode, Suspense, useEffect, useState } from 'react' -import useSWR, { mutate, SWRConfig, trigger } from '../src' -import { cacheSet } from '../src/config' +import useSWR, { mutate, SWRConfig, trigger, cache } from '../src' +import Cache from '../src/cache' class ErrorBoundary extends React.Component<{ fallback: ReactNode }> { state = { hasError: false } @@ -956,7 +956,7 @@ describe('useSWR - local mutation', () => { it('should call function as data passing current cached value', async () => { // prefill cache with data - cacheSet('dynamic-15', 'cached data') + cache.set('dynamic-15', 'cached data') const callback = jest.fn() await mutate('dynamic-15', callback) expect(callback).toHaveBeenCalledWith('cached data') @@ -1251,3 +1251,72 @@ describe('useSWR - suspense', () => { expect(renderedResults).toEqual(['suspense-7', 'suspense-8']) }) }) + +describe('useSWR - cache', () => { + it('should react to direct cache updates', async () => { + cache.set('cache-1', 'custom cache message') + + function Page() { + const { data } = useSWR('cache-1', () => 'random message', { + suspense: true + }) + return
{data}
+ } + + // render using custom cache + const { queryByText, findByText } = render( + + + + ) + + // content should come from custom cache + expect(queryByText('custom cache message')).toMatchInlineSnapshot(` +
+ custom cache message +
+ `) + + // content should be updated with fetcher results + expect(await findByText('random message')).toMatchInlineSnapshot(` +
+ random message +
+ `) + + act(() => cache.set('cache-1', 'a different message')) + + // content should be updated from new cache value + expect(await findByText('a different message')).toMatchInlineSnapshot(` +
+ a different message +
+ `) + + act(() => cache.delete('cache-1')) + + // content should go back to be the fetched value + expect(await findByText('random message')).toMatchInlineSnapshot(` +
+ random message +
+ `) + }) + + it('should notify subscribers when a cache item changed', async () => { + // create new cache instance to don't get affected by other tests + // updating the normal cache instance + const tmpCache = new Cache() + + const listener = jest.fn() + const unsubscribe = tmpCache.subscribe(listener) + tmpCache.set('cache-2', 'random message') + + expect(listener).toHaveBeenCalled() + + unsubscribe() + tmpCache.set('cache-2', 'a different message') + + expect(listener).toHaveBeenCalledTimes(1) + }) +})